Files
rspade_system/app/RSpade/Core/Dispatch/Dispatcher.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

1355 lines
50 KiB
PHP
Executable File

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use ReflectionClass;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Rsx;
/**
* Dispatcher - Step 3 of RSX Request Processing
*
* CALLED BY: RsxController::handle() with URL, method, and parameters
*
* WHAT IT DOES:
* 1. Initializes manifest (Manifest::init() handles loading/rebuilding as needed)
* 2. Checks if request is for static asset (serves directly if so)
* 3. Finds matching route using RouteResolver pattern matching
* 4. Loads and validates the handler class
* 5. Processes attributes (middleware, cache, rate limits, etc.) via AttributeProcessor
* 6. Calls optional pre_dispatch hook on handler
* 7. Executes the handler's action method
* 8. Builds appropriate response (JSON, HTML, redirect, etc.)
* 9. Runs post-processing for caching and other attributes
*
* FINAL STEPS:
* → For assets: Returns file response directly to client
* → For routes: Returns processed response (JSON/HTML/etc.) to client
* → For errors: Returns 404/500 error response to client
*/
class Dispatcher
{
// Manifest is now a static class - no instance needed
/**
* @var array Handler type priorities
*/
protected static $handler_priorities = [
'controllers' => 1,
'api' => 2,
'files' => 3,
'custom' => 4,
];
/**
* Dispatch a request to the appropriate handler
*
* @param string $url The URL to dispatch
* @param string $method HTTP method (GET, POST, etc.)
* @param array $extra_params Additional parameters to merge
* @param Request|null $request Optional request object
* @return mixed Response from handler, or null if no route found
* @throws Exception
*/
public static function dispatch($url, $method = 'GET', $extra_params = [], ?Request $request = null)
{
// CRITICAL: No try/catch - let errors fail loud per coding conventions
// Laravel's exception handler will handle all exceptions properly
console_debug('BENCHMARK', "Dispatch started for: {$method} {$url}");
// Initialize manifest (handles all loading/rebuilding logic)
console_debug('BENCHMARK', 'Initializing manifest');
Manifest::init();
console_debug('BENCHMARK', 'Manifest initialized');
// Validate Route attributes are not on classes (development mode only)
static::__validate_route_attributes();
$request = $request ?? request();
// Custom session is handled by Session::init() in RsxAuth
// Check if this is an asset request
console_debug('BENCHMARK', 'Checking for static asset');
if (AssetHandler::is_asset_request($url)) {
console_debug('BENCHMARK', "Serving static asset: {$url}");
return AssetHandler::serve($url, $request);
}
console_debug('BENCHMARK', 'Not a static asset, continuing route dispatch');
// HEAD requests should be treated as GET for route matching and handlers
// Store the original method for response transformation
$original_method = $method;
$route_method = ($method === 'HEAD') ? 'GET' : $method;
// Make the request appear as GET to all handlers if it's HEAD
if ($method === 'HEAD' && $request) {
$request->setMethod('GET');
}
// Find matching route
\Log::debug("Dispatcher: Looking for route: $url, method: $route_method");
console_debug('DISPATCH', 'Looking for route:', $url, 'method:', $route_method);
console_debug('BENCHMARK', 'Finding matching route');
$route_match = static::__find_route($url, $route_method);
console_debug('BENCHMARK', 'Route search complete');
if (!$route_match) {
\Log::debug("Dispatcher: No route found for: $url");
console_debug('DISPATCH', 'No route found for:', $url);
// No route found - try Main pre_dispatch and unhandled_route hooks
$params = array_merge(request()->all(), $extra_params);
// First try Main pre_dispatch
$main_classes = Manifest::php_get_extending('Main_Abstract');
foreach ($main_classes as $main_class) {
if (isset($main_class['fqcn']) && $main_class['fqcn']) {
$main_class_name = $main_class['fqcn'];
if (method_exists($main_class_name, 'pre_dispatch')) {
Debugger::console_debug('[DISPATCH]', 'Main::pre_dispatch');
$result = $main_class_name::pre_dispatch($request, $params);
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
}
}
// Then try unhandled_route hook
foreach ($main_classes as $main_class) {
if (isset($main_class['fqcn']) && $main_class['fqcn']) {
$main_class_name = $main_class['fqcn'];
if (method_exists($main_class_name, 'unhandled_route')) {
$result = $main_class_name::unhandled_route($request, $params);
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
}
}
// Default 404 - return null to let Laravel handle it
return null;
}
// Extract route information
$handler_class = $route_match['class'];
$handler_method = $route_match['method'];
$params = $route_match['params'] ?? [];
// Check for SSR Full Page Cache (FPC) before any processing
$fpc_response = static::__check_ssr_fpc($url, $handler_class, $handler_method, $request);
if ($fpc_response !== null) {
return static::__transform_response($fpc_response, $original_method);
}
// Merge parameters with correct priority order:
// 1. URL route parameters (already in $params from route_match)
// 2. GET parameters (already in $params from route_match via match_with_query)
// 3. POST parameters (from request)
// 4. Extra parameters (usually empty)
$post_params = $request->post();
$params = array_merge($extra_params, $post_params, $params);
// Add special parameters
$params['_method'] = $method;
$params['_route'] = $route_match['pattern'] ?? $url;
$params['_handler'] = $handler_class;
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
// Set current controller and action in Rsx for tracking
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method);
// Load and validate handler class
static::__load_handler_class($handler_class);
// Check controller pre_dispatch Auth attributes
$pre_dispatch_requires = Manifest::get_pre_dispatch_requires($handler_class);
foreach ($pre_dispatch_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, 'pre_dispatch');
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// Check route method Auth attributes
$route_requires = $route_match['require'] ?? [];
// Validate that at least one Auth exists (either on route or pre_dispatch)
if (empty($route_requires) && empty($pre_dispatch_requires)) {
throw new RuntimeException(
"Route method '{$handler_class}::{$handler_method}' is missing required #[\\Auth] attribute.\n\n" .
"All routes must specify access control using #[\\Auth('Permission::method()')].\n\n" .
"Examples:\n" .
" #[\\Auth('Permission::anybody()')] // Public access\n" .
" #[\\Auth('Permission::authenticated()')] // Must be logged in\n" .
" #[\\Auth('Permission::has_role(\"admin\")')] // Custom check with args\n\n" .
"Alternatively, add #[\\Auth] to pre_dispatch() to apply to all routes in this controller.\n\n" .
"To create a permission method, add to rsx/permission.php:\n" .
" public static function custom_check(Request \$request, array \$params): mixed {\n" .
" return RsxAuth::check(); // true = allow, false = deny\n" .
" }"
);
}
foreach ($route_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, $handler_method);
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// Call pre_dispatch hooks
$pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request);
if ($pre_dispatch_result !== null) {
// Pre-dispatch returned a response, build and return it
$response = static::__build_response($pre_dispatch_result);
return static::__transform_response($response, $original_method);
}
// Call the action method
$result = static::__call_action($handler_class, $handler_method, $params, $request);
// Convert result to response
$response = static::__build_response($result);
// Apply response transformations (HEAD body stripping, etc.)
return static::__transform_response($response, $original_method);
}
/**
* Resolve a URL to a route (public interface for code quality checks)
*
* @param string $url The URL to resolve (with or without query string)
* @param string $method HTTP method (default GET)
* @return array|null Array with 'class', 'method', 'params', 'pattern' or null
*/
public static function resolve_url_to_route($url, $method = 'GET')
{
// Initialize manifest if needed
Manifest::init();
// Use internal method
return static::__find_route($url, $method);
}
/**
* Find a matching route for the URL and method
*
* @param string $url
* @param string $method
* @return array|null Route match with class, method, params
*/
protected static function __find_route($url, $method)
{
// Get routes from manifest
$routes = Manifest::get_routes();
if (empty($routes)) {
\Log::debug('Manifest::get_routes() returned empty array');
console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()');
} else {
\Log::debug('Manifest has ' . count($routes) . ' route types');
// Log details for debugging but don't output to console
foreach ($routes as $type => $type_routes) {
\Log::debug("Route type '$type' has " . count($type_routes) . ' patterns');
// Show first few patterns for debugging in logs only
$patterns = array_slice(array_keys($type_routes), 0, 5);
\Log::debug(' First patterns: ' . implode(', ', $patterns));
}
}
// Sort handler types by priority
$sorted_types = array_keys(static::$handler_priorities);
usort($sorted_types, function ($a, $b) {
return static::$handler_priorities[$a] - static::$handler_priorities[$b];
});
// Collect all matching routes
$matches = [];
// Try each handler type in priority order
foreach ($sorted_types as $type) {
if (!isset($routes[$type])) {
continue;
}
$type_routes = $routes[$type];
// Get all patterns for this type
$patterns = array_keys($type_routes);
// Sort patterns by priority
$patterns = RouteResolver::sort_by_priority($patterns);
// Try to match each pattern
foreach ($patterns as $pattern) {
$route_info = $type_routes[$pattern];
// Check if method is supported
if (isset($route_info[$method])) {
// Try to match the URL
$params = RouteResolver::match_with_query($url, $pattern);
if ($params !== false) {
// Handle new structure where each method can have multiple handlers
$handlers = $route_info[$method];
// If it's not an array of handlers, convert it (backwards compatibility)
if (!isset($handlers[0])) {
$handlers = [$handlers];
}
// Add all matching handlers
foreach ($handlers as $handler) {
$matches[] = [
'type' => $type,
'pattern' => $pattern,
'class' => $handler['class'],
'method' => $handler['method'],
'params' => $params,
'file' => $handler['file'] ?? null,
'require' => $handler['require'] ?? [],
];
}
}
}
}
}
// Check for duplicate routes
if (count($matches) > 1) {
$error_msg = "Multiple routes match the request '{$method} {$url}':\n\n";
foreach ($matches as $match) {
$error_msg .= " - Pattern: {$match['pattern']}\n";
$error_msg .= " Class: {$match['class']}::{$match['method']}\n";
if (!empty($match['file'])) {
$error_msg .= " File: {$match['file']}\n";
}
$error_msg .= " Type: {$match['type']}\n\n";
}
$error_msg .= 'Routes must be unique. Please remove duplicate route definitions.';
throw new RuntimeException($error_msg);
}
// Return the single match or null
return $matches[0] ?? null;
}
/**
* Load and validate handler class
*
* @param string $class_name
* @throws Exception
*/
protected static function __load_handler_class($class_name)
{
// Use Manifest to verify the class exists
// Check if this is already a FQCN (contains backslash)
if (strpos($class_name, '\\') !== false) {
// It's a FQCN, use php_get_metadata_by_fqcn
try {
$metadata = Manifest::php_get_metadata_by_fqcn($class_name);
$fqcn = $metadata['fqcn'];
} catch (RuntimeException $e) {
throw new Exception("Handler class not found in manifest: {$class_name}");
}
} else {
// It's a simple name, try different approaches
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
// Class exists in manifest, trigger autoloading by referencing the FQCN
$fqcn = $metadata['fqcn'];
// The autoloader will handle loading when we reference the class
} catch (RuntimeException $e) {
// Try with Rsx namespace prefix
try {
$metadata = Manifest::php_get_metadata_by_class('Rsx\\' . $class_name);
$fqcn = $metadata['fqcn'];
} catch (RuntimeException $e2) {
throw new Exception("Handler class not found in manifest: {$class_name}");
}
}
}
}
/**
* Process cache attributes
*
* @param array $attributes
* @param array $params
* @param mixed $result
*/
protected static function __process_cache($attributes, $params, $result)
{
// This is a simplified implementation
// In production, this would integrate with Laravel's cache system
if (!isset($attributes['App\\RSpade\\Core\\Attributes\\Cache'])) {
return;
}
foreach ($attributes['App\\RSpade\\Core\\Attributes\\Cache'] as $cache) {
if ($cache->is_enabled()) {
$key = $cache->generate_key($params);
Log::debug("Would cache result with key: {$key} for {$cache->ttl} seconds");
// Here we would actually cache the result
}
}
}
/**
* Call pre_dispatch hook
*
* @param string $class_name
* @param string $method_name
* @param array $params
* @param Request|null $request
* @return mixed|null Returns non-null to halt dispatch with that response
*/
protected static function __call_pre_dispatch($class_name, $method_name, &$params, ?Request $request = null)
{
$request = $request ?? request();
// First, call pre_dispatch on Main classes (if any exist)
$main_classes = Manifest::php_get_extending('Main_Abstract');
foreach ($main_classes as $main_class) {
if (isset($main_class['fqcn']) && $main_class['fqcn']) {
$main_class_name = $main_class['fqcn'];
if (method_exists($main_class_name, 'pre_dispatch')) {
$result = $main_class_name::pre_dispatch($request, $params);
if ($result !== null) {
return $result;
}
}
}
}
// Then call pre_dispatch on the controller itself
// Note: Controller pre_dispatch is handled in call_action for instance controllers
// Only handle static pre_dispatch here for non-controller classes
try {
if (!static::__is_controller($class_name)) {
$reflection = new ReflectionClass($class_name);
if ($reflection->hasMethod('pre_dispatch')) {
$pre_dispatch = $reflection->getMethod('pre_dispatch');
if ($pre_dispatch->isStatic() && $pre_dispatch->isPublic()) {
$result = $pre_dispatch->invoke(null, $request, $params);
// If pre_dispatch returns non-null, return that value
if ($result !== null) {
return $result;
}
}
}
}
return null;
} catch (Throwable $e) {
Log::error("Pre-dispatch failed for {$class_name}: " . $e->getMessage());
return null;
}
}
/**
* Call the action method
*
* @param string $class_name
* @param string $method_name
* @param array $params
* @param array $attributes
* @param Request $request
* @return mixed
* @throws Exception
*/
protected static function __call_action($class_name, $method_name, $params, ?Request $request = null)
{
try {
$request = $request ?? request();
$reflection = new ReflectionClass($class_name);
if (!$reflection->hasMethod($method_name)) {
throw new Exception("Method not found: {$class_name}::{$method_name}");
}
$method = $reflection->getMethod($method_name);
if (!$method->isPublic()) {
throw new Exception("Method not public: {$class_name}::{$method_name}");
}
// Set current controller and action for tracking
Rsx::_set_current_controller_action($class_name, $method_name);
// Check if this is a controller (all methods are static)
if (static::__is_controller($class_name)) {
// Call pre_dispatch if it exists
if (method_exists($class_name, 'pre_dispatch')) {
$result = $class_name::pre_dispatch($request, $params);
if ($result !== null) {
// Don't clear tracking - view might need it during rendering
return $result;
}
}
// Call the action method statically with request and params
$result = $class_name::$method_name($request, $params);
// Don't clear tracking - view needs it during rendering
// The values will be overwritten on the next request
return $result;
}
// For other handlers, check if static
if (!$method->isStatic()) {
// Create instance for non-static methods
$instance = app()->make($class_name);
$result = $method->invoke($instance, $request, $params);
// Don't clear tracking - view needs it during rendering
return $result;
}
// Call static method
$result = $method->invoke(null, $request, $params);
// Don't clear tracking - view needs it during rendering
return $result;
} catch (Throwable $e) {
throw new Exception("Failed to call action {$class_name}::{$method_name}: " . $e->getMessage(), 0, $e);
}
}
/**
* Check if class is a controller
*
* @param string $class_name
* @return bool
*/
protected static function __is_controller($class_name)
{
try {
// Check if it extends Rsx_Controller_Abstract using Manifest
return \App\RSpade\Core\Manifest\Manifest::php_is_subclass_of(
$class_name,
'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract'
);
} catch (Throwable $e) {
return false;
}
}
/**
* Build response from handler result
*
* @param mixed $result
* @param array $attributes
* @return Response
*/
protected static function __build_response($result)
{
// Handle special RSX response types
if ($result instanceof \App\RSpade\Core\Response\Rsx_Response_Abstract) {
return static::__handle_special_response($result);
}
// If already a Response object (check Symfony base class to catch all response types)
if ($result instanceof \Symfony\Component\HttpFoundation\Response) {
return $result;
}
// Handle View objects
if ($result instanceof \Illuminate\View\View || $result instanceof \Illuminate\Contracts\View\View) {
return response($result);
}
// Handle array responses with type hints
if (is_array($result) && isset($result['type'])) {
return static::__build_typed_response($result);
}
// Check ResponseType attribute
if (isset($attributes['App\\RSpade\\Core\\Attributes\\ResponseType'])) {
$response_type = $attributes['App\\RSpade\\Core\\Attributes\\ResponseType'][0];
return static::__build_attribute_response($result, $response_type);
}
// Default: return as JSON
return response()->json($result);
}
/**
* Build response from typed result array
*
* @param array $result
* @return Response
*/
protected static function __build_typed_response($result)
{
$type = $result['type'];
$status = $result['status'] ?? 200;
$headers = $result['headers'] ?? [];
switch ($type) {
case 'view':
return response()
->view($result['view'], $result['data'] ?? [], $status)
->withHeaders($headers);
case 'json':
return response()->json($result['data'] ?? $result, $status, $headers);
case 'redirect':
$status = $result['status'] ?? 302;
return response('', $status)->header('Location', $result['url']);
case 'file':
$response = response()->file($result['path'], $headers);
if (isset($result['name'])) {
$disposition = $result['disposition'] ?? 'attachment';
$response->header('Content-Disposition', "{$disposition}; filename=\"" . $result['name'] . '"');
}
return $response;
case 'error':
abort($result['code'] ?? 500, $result['message'] ?? 'Server Error');
// no break
case 'empty':
return response('', $status, $headers);
case 'stream':
return response()->stream($result['callback'], $status, $headers);
default:
// Unknown type, return as JSON
return response()->json($result, $status, $headers);
}
}
/**
* Handle special RSX response types for HTTP requests
*
* @param \App\RSpade\Core\Response\Rsx_Response_Abstract $response
* @return \Illuminate\Http\Response
*/
protected static function __handle_special_response(\App\RSpade\Core\Response\Rsx_Response_Abstract $response)
{
$type = $response->get_type();
$reason = $response->get_reason();
$redirect_url = $response->get_redirect();
$details = $response->get_details();
// Handle fatal error - always throw exception
if ($type === 'response_fatal_error') {
$message = $reason;
if (!empty($details)) {
$message .= ' - ' . json_encode($details);
}
throw new Exception($message);
}
// Handle authentication required
if ($type === 'response_auth_required') {
if ($redirect_url) {
Rsx::flash_error($reason);
return redirect($redirect_url);
}
throw new Exception($reason);
}
// Handle unauthorized
if ($type === 'response_unauthorized') {
if ($redirect_url) {
Rsx::flash_error($reason);
return redirect($redirect_url);
}
throw new Exception($reason);
}
// Handle form error
if ($type === 'response_form_error') {
// Only redirect if this was a POST request
if (request()->isMethod('POST')) {
Rsx::flash_error($reason);
// Redirect to same URL as GET request
return redirect(request()->url());
}
// Not a POST request, throw exception
throw new Exception($reason);
}
// Unknown response type
throw new Exception("Unknown RSX response type: {$type}");
}
/**
* Build response based on ResponseType attribute
*
* @param mixed $result
* @param ResponseType $response_type
* @return Response
*/
protected static function __build_attribute_response($result, $response_type)
{
$status = $response_type->status;
$headers = $response_type->headers;
switch ($response_type->type) {
case 'json':
return response()->json($result, $status, $headers);
case 'html':
if ($response_type->view) {
return response()->view($response_type->view, $result, $status)->withHeaders($headers);
}
return response($result, $status, $headers)->header('Content-Type', 'text/html');
case 'xml':
// Simple XML conversion
$xml = static::__array_to_xml($result);
return response($xml, $status, $headers)->header('Content-Type', 'application/xml');
case 'text':
return response($result, $status, $headers)->header('Content-Type', 'text/plain');
default:
return response()->json($result, $status, $headers);
}
}
/**
* Convert array to XML string
*
* @param array $data
* @param string $root_element
* @return string
*/
protected static function __array_to_xml($data, $root_element = 'response')
{
$xml = new SimpleXMLElement("<?xml version=\"1.0\"?><{$root_element}></{$root_element}>");
static::__array_to_xml_recursive($data, $xml);
return $xml->asXML();
}
/**
* Recursive helper for array to XML conversion
*
* @param array $data
* @param SimpleXMLElement $xml
*/
protected static function __array_to_xml_recursive($data, &$xml)
{
foreach ($data as $key => $value) {
if (is_numeric($key)) {
$key = 'item' . $key;
}
if (is_array($value)) {
$subnode = $xml->addChild($key);
static::__array_to_xml_recursive($value, $subnode);
} else {
$xml->addChild($key, htmlspecialchars((string) $value));
}
}
}
/**
* Handle 404 not found
*
* @param string $url
* @param string $method
* @return Response
*/
protected static function __handle_not_found($url, $method)
{
Log::warning("Route not found: {$method} {$url}");
// Try to find a custom 404 handler
$custom_404 = static::__find_route('/404', 'GET');
if ($custom_404) {
try {
$result = static::__call_action($custom_404['class'], $custom_404['method'], ['url' => $url, 'method' => $method]);
return static::__build_response($result);
} catch (Throwable $e) {
Log::error('Custom 404 handler failed: ' . $e->getMessage());
}
}
// Default 404 response
abort(404, "Route not found: {$url}");
}
/**
* Transform response based on original request method
*
* This centralized method handles all response transformations
* including stripping body for HEAD requests
*
* @param mixed $response The response to transform
* @param string $original_method The original HTTP method
* @return mixed The transformed response
*/
protected static function __transform_response($response, $original_method)
{
// Session cookie handling moved to custom Session class
// If this was originally a HEAD request, strip the body
if ($original_method === 'HEAD' && $response instanceof \Symfony\Component\HttpFoundation\Response) {
$response->setContent('');
}
// Future transformations can be added here
return $response;
}
/**
* Set custom handler priorities
*
* @param array $priorities
*/
public static function set_handler_priorities($priorities)
{
static::$handler_priorities = $priorities;
}
/**
* Get current handler priorities
*
* @return array
*/
public static function get_handler_priorities()
{
return static::$handler_priorities;
}
/**
* Validate that Route attributes are not placed on classes
* Only runs in non-production mode
*
* @throws RuntimeException if Route attributes found on classes
*/
protected static function __validate_route_attributes()
{
// Only validate in non-production mode
if (app()->environment('production')) {
return;
}
// Get all manifest entries
$manifest = Manifest::get_all();
$errors = [];
// Check each file for class-level Route attributes
foreach ($manifest as $file_path => $metadata) {
// Skip non-PHP files (controllers are PHP files)
if (!isset($metadata['type']) || ($metadata['type'] !== 'php' && $metadata['type'] !== 'controller')) {
continue;
}
// Check if this file has class-level attributes
// Attributes are stored with simple names (e.g., "Route" not "App\RSpade\Core\Attributes\Route")
if (isset($metadata['attributes']) && is_array($metadata['attributes']) && !empty($metadata['attributes'])) {
foreach ($metadata['attributes'] as $attr_name => $attr_data) {
// Check for Route-related attributes (simple names)
if ($attr_name === 'Route' || $attr_name === 'Get' || $attr_name === 'Post' ||
$attr_name === 'Put' || $attr_name === 'Delete' || $attr_name === 'Patch') {
$class_name = $metadata['class'] ?? 'Unknown';
$errors[] = [
'file' => $file_path,
'class' => $class_name,
'attribute' => $attr_name,
];
}
}
}
}
// If errors found, throw fatal error with detailed message
if (!empty($errors)) {
$error_msg = "Route attributes should be assigned to static methods in a controller class, not as an attribute on the class itself.\n\n";
$error_msg .= "The following classes have Route attributes incorrectly placed on the class:\n\n";
foreach ($errors as $error) {
$error_msg .= " File: {$error['file']}\n";
$error_msg .= " Class: {$error['class']}\n";
$error_msg .= " Attribute: #{$error['attribute']}\n\n";
}
$error_msg .= "To fix this, move the Route attribute to a static method within the class.\n";
$error_msg .= "Example:\n";
$error_msg .= " class My_Controller extends Rsx_Controller_Abstract {\n";
$error_msg .= " #[Route('/path', methods: ['GET'])]\n";
$error_msg .= " public static function index(Request \$request, array \$params = []) {\n";
$error_msg .= " // ...\n";
$error_msg .= " }\n";
$error_msg .= " }\n";
throw new RuntimeException($error_msg);
}
}
/**
* Check a Auth attribute and execute the permission check
*
* @param array $require_attr The Auth attribute arguments
* @param Request $request
* @param array $params
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return mixed|null Returns response to halt dispatch, or null to continue
* @throws RuntimeException on parsing or execution errors
*/
protected static function __check_require(array $require_attr, Request $request, array $params, string $handler_class, string $handler_method)
{
// Extract parameters - first arg is callable string, rest are named params
$callable_str = $require_attr[0] ?? null;
$message = $require_attr['message'] ?? null;
$redirect = $require_attr['redirect'] ?? null;
$redirect_to = $require_attr['redirect_to'] ?? null;
if (!$callable_str) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} is missing callable string.\n" .
"Expected: #[Auth('Permission::method()')]\n" .
"Got: " . json_encode($require_attr)
);
}
if ($redirect && $redirect_to) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} cannot specify both 'redirect' and 'redirect_to'.\n" .
"Use either redirect: '/path' OR redirect_to: ['Controller', 'action']"
);
}
// Parse callable string - FATAL if parsing fails
[$class, $method, $args] = static::__parse_require_callable($callable_str, $handler_class, $handler_method);
// Verify permission class and method exist
if (!class_exists($class)) {
throw new RuntimeException(
"Permission class '{$class}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Make sure the class exists and is loaded by the manifest."
);
}
if (!method_exists($class, $method)) {
throw new RuntimeException(
"Permission method '{$class}::{$method}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Add this method to {$class}:\n" .
" public static function {$method}(Request \$request, array \$params): mixed {\n" .
" return true; // or false to deny\n" .
" }"
);
}
// Call permission method
try {
$result = $class::$method($request, $params, ...$args);
} catch (\Throwable $e) {
throw new RuntimeException(
"Permission check '{$class}::{$method}' threw exception for {$handler_class}::{$handler_method}:\n" .
$e->getMessage(),
0,
$e
);
}
// Handle result
if ($result === true || $result === null) {
return null; // Pass - continue to next check or route
}
if ($result instanceof \Symfony\Component\HttpFoundation\Response) {
return $result; // Custom response from permission method
}
// Permission failed - detect if Ajax context
$is_ajax = ($handler_class === 'App\\RSpade\\Core\\Dispatch\\Ajax_Endpoint_Controller') ||
str_starts_with($request->path(), '_ajax/') ||
str_starts_with($request->path(), '_fetch/');
if ($is_ajax) {
// Ajax context - return JSON error, ignore redirect parameters
return response()->json([
'success' => false,
'error' => $message ?? 'Permission denied',
'error_type' => 'permission_denied'
], 403);
}
// Regular HTTP context - handle redirect/message
if ($redirect_to) {
if (!is_array($redirect_to) || count($redirect_to) < 1) {
throw new RuntimeException(
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
);
}
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index')->url();
if ($message) {
Rsx::flash_error($message);
}
return redirect($url);
}
if ($redirect) {
if ($message) {
Rsx::flash_error($message);
}
return redirect($redirect);
}
// Default: throw 403 Forbidden
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, $message ?? 'Forbidden');
}
/**
* Parse Auth callable string into class, method, and args
*
* Supports formats:
* - Permission::method()
* - Permission::method(3)
* - Permission::method("string")
* - Permission::method(1, "arg2", 3)
*
* FATAL on parse failure
*
* @param string $str Callable string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return array [class, method, args]
* @throws RuntimeException on parse failure
*/
protected static function __parse_require_callable(string $str, string $handler_class, string $handler_method): array
{
// Pattern: ClassName::method_name(args)
if (!preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)::([a-z_][a-z0-9_]*)\((.*)\)$/i', $str, $matches)) {
throw new RuntimeException(
"Failed to parse Auth callable on {$handler_class}::{$handler_method}.\n\n" .
"Invalid format: '{$str}'\n\n" .
"Expected format: 'ClassName::method_name()' or 'ClassName::method_name(arg1, arg2)'\n\n" .
"Examples:\n" .
" Permission::anybody()\n" .
" Permission::authenticated()\n" .
" Permission::has_role(\"admin\")\n" .
" Permission::has_permission(3, \"write\")"
);
}
$class = $matches[1];
$method = $matches[2];
$args_str = trim($matches[3]);
// Resolve class name if not fully qualified
if (strpos($class, '\\') === false) {
// Try to find the class using manifest discovery
try {
$metadata = Manifest::php_get_metadata_by_class($class);
if (isset($metadata['fqcn'])) {
$class = $metadata['fqcn'];
}
} catch (\RuntimeException $e) {
// Class not found in manifest - leave as-is and let class_exists check fail later with better error
}
}
$args = [];
if ($args_str !== '') {
$args = static::__parse_args($args_str, $handler_class, $handler_method, $str);
}
return [$class, $method, $args];
}
/**
* Parse argument list from Auth callable
*
* Supports: integers, quoted strings, simple values
* FATAL on parse failure
*
* @param string $args_str Argument list string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return array Parsed arguments
* @throws RuntimeException on parse failure
*/
protected static function __parse_args(string $args_str, string $handler_class, string $handler_method, string $full_callable): array
{
$args = [];
$current_arg = '';
$in_quotes = false;
$quote_char = null;
$escaped = false;
for ($i = 0; $i < strlen($args_str); $i++) {
$char = $args_str[$i];
if ($escaped) {
$current_arg .= $char;
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if (!$in_quotes && ($char === '"' || $char === "'")) {
$in_quotes = true;
$quote_char = $char;
continue;
}
if ($in_quotes && $char === $quote_char) {
$in_quotes = false;
$quote_char = null;
continue;
}
if (!$in_quotes && $char === ',') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
$current_arg = '';
continue;
}
$current_arg .= $char;
}
if ($in_quotes) {
throw new RuntimeException(
"Failed to parse Auth arguments on {$handler_class}::{$handler_method}.\n\n" .
"Unclosed quote in: '{$full_callable}'\n\n" .
"Make sure all quoted strings are properly closed."
);
}
if ($current_arg !== '') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
}
return $args;
}
/**
* Convert argument string to appropriate type
*
* @param string $value
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return mixed
*/
protected static function __convert_arg_value(string $value, string $handler_class, string $handler_method, string $full_callable)
{
if ($value === '') {
throw new RuntimeException(
"Empty argument in Auth on {$handler_class}::{$handler_method}.\n" .
"Callable: '{$full_callable}'"
);
}
// Integer
if (preg_match('/^-?\d+$/', $value)) {
return (int) $value;
}
// Float
if (preg_match('/^-?\d+\.\d+$/', $value)) {
return (float) $value;
}
// Boolean
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
// Null
if ($value === 'null') {
return null;
}
// Everything else is a string
return $value;
}
/**
* Check for SSR Full Page Cache and serve if available
*
* @param string $url
* @param string $handler_class
* @param string $handler_method
* @param Request $request
* @return Response|null Returns response if FPC should be served, null otherwise
*/
protected static function __check_ssr_fpc(string $url, string $handler_class, string $handler_method, Request $request)
{
console_debug('SSR_FPC', "FPC check started for {$url}");
// Check if SSR FPC is enabled
if (!config('rsx.ssr_fpc.enabled', false)) {
console_debug('SSR_FPC', 'FPC disabled in config');
return null;
}
// Check if this is the FPC client (prevent infinite loop)
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
console_debug('SSR_FPC', 'Skipping FPC - is FPC client');
return null;
}
// Check if user has a session (only serve FPC to users without sessions)
if (\App\RSpade\Core\Session\Session::has_session()) {
console_debug('SSR_FPC', 'Skipping FPC - user has session');
return null;
}
console_debug('SSR_FPC', 'User has no session, continuing...');
// FEATURE DISABLED: SSR Full Page Cache is not yet complete
// This feature will be finished at a later point
// See docs.dev/SSR_FPC.md for implementation details and current state
// Disable by always returning false for Static_Page check
$has_static_page = false;
if (!$has_static_page) {
console_debug('SSR_FPC', 'SSR FPC feature is disabled - will be completed later');
return null;
}
// Check if user is authenticated (only serve FPC to unauthenticated users)
// TODO: Should use has_session() instead of is_logged_in(), but using is_logged_in() for now to simplify debugging
if (\App\RSpade\Core\Session\Session::is_logged_in()) {
return null;
}
// Strip GET parameters from URL for cache key
$clean_url = parse_url($url, PHP_URL_PATH) ?: $url;
// Get build key from manifest
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
// Generate Redis cache key
$url_hash = sha1($clean_url);
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
// Check if cache exists
try {
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
// Cache doesn't exist - generate it
console_debug('SSR_FPC', "Cache miss for {$clean_url}, generating...");
\Illuminate\Support\Facades\Artisan::call('rsx:ssr_fpc:create', [
'url' => $clean_url
]);
// Read the newly generated cache
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
throw new \RuntimeException("Failed to generate SSR FPC cache for route: {$clean_url}");
}
}
// Parse cache data
$cache = json_decode($cache_data, true);
if (!$cache) {
throw new \RuntimeException("Invalid SSR FPC cache data for route: {$clean_url}");
}
// Check ETag for 304 response
$client_etag = $request->header('If-None-Match');
if ($client_etag && $client_etag === $cache['etag']) {
return response('', 304)
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', app()->environment('production') ? 'public, max-age=300' : 'no-cache, must-revalidate');
}
// Determine cache control header
$cache_control = app()->environment('production')
? 'public, max-age=300' // 5 min in production
: 'no-cache, must-revalidate'; // 0s in dev
// Handle redirect response
if ($cache['code'] >= 300 && $cache['code'] < 400 && $cache['redirect']) {
return response('', $cache['code'])
->header('Location', $cache['redirect'])
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', $cache_control);
}
// Serve static HTML
console_debug('SSR_FPC', "Serving cached page for {$clean_url}");
return response($cache['page_dom'], $cache['code'])
->header('Content-Type', 'text/html; charset=UTF-8')
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('X-FPC-Debug', 'served-from-cache')
->header('Cache-Control', $cache_control);
} catch (\Exception $e) {
// Log error and let request proceed normally
\Illuminate\Support\Facades\Log::error('SSR FPC error: ' . $e->getMessage());
throw new \RuntimeException('SSR FPC generation failed: ' . $e->getMessage(), 0, $e);
}
}
}