Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1456 lines
55 KiB
PHP
Executable File
1456 lines
55 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);
|
|
|
|
// 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("<?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}"
|
|
);
|
|
}
|
|
$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);
|
|
}
|
|
}
|
|
}
|