Add semantic token highlighting for 'that' variable and comment file references in VS Code extension Add Phone_Text_Input and Currency_Input components with formatting utilities Implement client widgets, form standardization, and soft delete functionality Add modal scroll lock and update documentation Implement comprehensive modal system with form integration and validation Fix modal component instantiation using jQuery plugin API Implement modal system with responsive sizing, queuing, and validation support Implement form submission with validation, error handling, and loading states Implement country/state selectors with dynamic data loading and Bootstrap styling Revert Rsx::Route() highlighting in Blade/PHP files Target specific PHP scopes for Rsx::Route() highlighting in Blade Expand injection selector for Rsx::Route() highlighting Add custom syntax highlighting for Rsx::Route() and Rsx.Route() calls Update jqhtml packages to v2.2.165 Add bundle path validation for common mistakes (development mode only) Create Ajax_Select_Input widget and Rsx_Reference_Data controller Create Country_Select_Input widget with default country support Initialize Tom Select on Select_Input widgets Add Tom Select bundle for enhanced select dropdowns Implement ISO 3166 geographic data system for country/region selection Implement widget-based form system with disabled state support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1420 lines
53 KiB
PHP
Executable File
1420 lines
53 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);
|
|
|
|
// Try to find the controller using manifest
|
|
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 POST data and query string
|
|
$params = array_merge($request->query->all(), $request->request->all(), $extra_params);
|
|
|
|
// 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($request->query->all(), $extra_params);
|
|
|
|
// 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', 'Controller not found in manifest:', $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. 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');
|
|
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);
|
|
}
|
|
}
|
|
}
|