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); // Check if this matches the default route pattern: /_/{controller}/{action} if (preg_match('#^/_/([A-Za-z_][A-Za-z0-9_]*)/([A-Za-z_][A-Za-z0-9_]*)/?$#', $url, $matches)) { $controller_name = $matches[1]; $action_name = $matches[2]; console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name); // First try to find as PHP controller try { $metadata = Manifest::php_get_metadata_by_class($controller_name); $controller_fqcn = $metadata['fqcn']; // Verify it extends Rsx_Controller_Abstract if (!Manifest::php_is_subclass_of($controller_name, 'Rsx_Controller_Abstract')) { console_debug('DISPATCH', 'Class does not extend Rsx_Controller_Abstract:', $controller_name); return null; } // Verify the method exists and has a Route attribute if (!isset($metadata['public_static_methods'][$action_name])) { console_debug('DISPATCH', 'Method not found:', $action_name); return null; } $method_data = $metadata['public_static_methods'][$action_name]; if (!isset($method_data['attributes']['Route'])) { console_debug('DISPATCH', 'Method does not have Route attribute:', $action_name); return null; } // For POST requests: execute the action if ($route_method === 'POST') { // Collect parameters from GET query string only (not POST data) $params = array_merge($extra_params, $request->query->all()); // Create synthetic route match $route_match = [ 'class' => $controller_fqcn, 'method' => $action_name, 'params' => $params, 'pattern' => "/_/{$controller_name}/{$action_name}", 'require' => $method_data['attributes']['Auth'] ?? [] ]; // Continue with normal dispatch (will handle auth, pre_dispatch, etc.) // Fall through to normal route handling below } else { // For GET requests: redirect to the proper route $params = array_merge($extra_params, $request->query->all()); // Generate proper URL using Rsx::Route $proper_url = Rsx::Route($controller_name, $action_name, $params); console_debug('DISPATCH', 'Redirecting GET to proper route:', $proper_url); return redirect($proper_url, 302); } } catch (\RuntimeException $e) { console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name); // Not found as PHP controller - check if it's a SPA action try { $is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action'); if ($is_spa_action) { console_debug('DISPATCH', 'Found SPA action class:', $controller_name); // Get the file path for this JS class $file_path = Manifest::js_find_class($controller_name); // Get file metadata which contains decorator information $file_data = Manifest::get_file($file_path); if (!$file_data) { console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name); return null; } // Extract route pattern from @route() decorator // Format: [[0 => 'route', 1 => ['/contacts']], ...] $route_pattern = null; if (isset($file_data['decorators']) && is_array($file_data['decorators'])) { foreach ($file_data['decorators'] as $decorator) { if (isset($decorator[0]) && $decorator[0] === 'route') { if (isset($decorator[1][0])) { $route_pattern = $decorator[1][0]; break; } } } } if ($route_pattern) { // Generate proper URL for the SPA action $params = array_merge($extra_params, $request->query->all()); $proper_url = Rsx::Route($controller_name, $action_name, $params); console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url); return redirect($proper_url, 302); } else { console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name); } } } catch (\RuntimeException $spa_e) { console_debug('DISPATCH', 'Not a SPA action either:', $controller_name); } return null; } } if (!$route_match) { // 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. Extra parameters (usually empty, lowest priority) // 2. GET parameters (from query string) // 3. URL route parameters (extracted from route pattern like :id) // Note: POST parameters are NOT included - controller $params only contains GET and route params $get_params = $request->query->all(); $params = array_merge($extra_params, $get_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 $route_type = $route_match['type'] ?? 'standard'; \App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type); // 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()'); return null; } \Log::debug('Manifest has ' . count($routes) . ' routes'); // Get all patterns and sort by priority $patterns = array_keys($routes); $patterns = RouteResolver::sort_by_priority($patterns); // Try to match each pattern foreach ($patterns as $pattern) { $route = $routes[$pattern]; // Check if HTTP method is supported if (!in_array($method, $route['methods'])) { continue; } // Try to match the URL $params = RouteResolver::match_with_query($url, $pattern); if ($params !== false) { // Found a match - verify the method has the required attribute $class_fqcn = $route['class']; $method_name = $route['method']; // Get method metadata from manifest $class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn); $method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null; if (!$method_metadata) { throw new \RuntimeException( "Route method not found in manifest: {$class_fqcn}::{$method_name}\n" . "Pattern: {$pattern}" ); } // Check for Route or SPA attribute $attributes = $method_metadata['attributes'] ?? []; $has_route = false; foreach ($attributes as $attr_name => $attr_instances) { if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' || str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') { $has_route = true; break; } } if (!$has_route) { throw new \RuntimeException( "Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" . "Pattern: {$pattern}\n" . "File: {$route['file']}" ); } // Return route with params return [ 'type' => $route['type'], 'pattern' => $pattern, 'class' => $route['class'], 'method' => $route['method'], 'params' => $params, 'file' => $route['file'] ?? null, 'require' => $route['require'] ?? [], ]; } } // No match found return 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) { $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}"); } // NOTE: Do NOT call _set_current_controller_action here - it's already been set // earlier in the dispatch flow with the correct route type. Calling it again // would overwrite the route type with null. // 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; } /** * 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 === 'fatal') { $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("<{$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}" ); } $action = $redirect_to[0]; if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') { $action .= '::' . $redirect_to[1]; } $url = Rsx::Route($action); 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); } } }