Always include params in window.rsxapp to reduce state variations Add request params to window.rsxapp global Enhance module creation commands with clear nomenclature guidance Add module/submodule/feature nomenclature clarification to docs 🤖 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, $params);
|
|
|
|
// 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, $params);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|