Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
<?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 App\RSpade\Core\Ajax\Ajax;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
/**
* Internal API route handler
*
* Thin wrapper that routes /_ajax/:controller/:action requests to the Ajax class.
* The actual logic is consolidated in Ajax::handle_browser_request() for better organization.
*/
class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
{
/**
* Handle internal API requests
*
* Routes /_ajax/:controller/:action to Ajax::handle_browser_request()
*/
#[Auth('Permission::anybody()')]
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
public static function dispatch(Request $request, array $params = [])
{
// Delegate all logic to the consolidated Ajax class
return Ajax::handle_browser_request($request, $params);
}
/**
* Set AJAX response mode (backward compatibility)
* @deprecated Use Ajax::set_ajax_response_mode() instead
*/
public static function set_ajax_response_mode(bool $enabled): void
{
Ajax::set_ajax_response_mode($enabled);
}
/**
* Check if AJAX response mode is enabled (backward compatibility)
* @deprecated Use Ajax::is_ajax_response_mode() instead
*/
public static function is_ajax_response_mode(): bool
{
return Ajax::is_ajax_response_mode();
}
/**
* Handle model fetch requests from JavaScript ORM
*
* Routes /_fetch/:model to the model's fetch() method
* Model must have #[Ajax_Endpoint_Model_Fetch] annotation on its fetch() method
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch/:model', methods: ['POST'])]
public static function fetch_model(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
if (!$model_name) {
return response()->json(['error' => 'Model name is required'], 400);
}
// Look up the model class from manifest
try {
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
// Find the model class in manifest
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute using manifest
$model_metadata = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_metadata = $metadata;
break;
}
}
if (!$model_metadata) {
return response()->json(['error' => "Model {$model_name} metadata not found in manifest"], 500);
}
// Check if fetch method exists and has the attribute
$has_fetch_attribute = false;
if (isset($model_metadata['public_static_methods']['fetch'])) {
$fetch_method = $model_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes'])) {
// Check if Ajax_Endpoint_Model_Fetch attribute exists
// Attributes are stored as associative array with attribute name as key
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$has_fetch_attribute = true;
}
}
}
if (!$has_fetch_attribute) {
return response()->json(['error' => "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute"], 403);
}
// Get the ID parameter
$id = $request->input('id');
if ($id === null) {
return response()->json(['error' => 'ID parameter is required'], 400);
}
// Handle arrays by calling fetch() for each ID
if (is_array($id)) {
$results = [];
foreach ($id as $single_id) {
$model = $model_class::fetch($single_id);
if ($model !== false) {
// Convert to array if it's an object
if (is_object($model) && method_exists($model, 'toArray')) {
$results[] = $model->toArray();
} else {
$results[] = $model;
}
}
}
return response()->json($results);
}
// Single ID - call fetch() directly
$result = $model_class::fetch($id);
if ($result === false) {
return response()->json(false);
}
// Convert to array for JSON response
if (is_object($result) && method_exists($result, 'toArray')) {
return response()->json($result->toArray());
}
// Return as-is if not an object with toArray
return response()->json($result);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Handle model relationship fetch requests
*
* Routes /_fetch_rel/:model/:id/:relationship to fetch relationship data
* Model must have the relationship defined
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch_rel/:model/:id/:relationship', methods: ['POST'])]
public static function fetch_relationship(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
$id = $params['id'] ?? null;
$relationship = $params['relationship'] ?? null;
if (!$model_name || !$id || !$relationship) {
return response()->json(['error' => 'Model name, ID, and relationship are required'], 400);
}
try {
// Look up the model class from manifest
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Fetch the base model
$model = $model_class::find($id);
if (!$model) {
return response()->json(['error' => "Record with ID {$id} not found"], 404);
}
// Check if relationship exists using get_relationships()
$relationships = $model_class::get_relationships();
if (!in_array($relationship, $relationships)) {
return response()->json(['error' => "Relationship {$relationship} not found on model {$model_name}"], 404);
}
// Call the relationship method directly and get the results
if (!method_exists($model, $relationship)) {
return response()->json(['error' => "Relationship method {$relationship} does not exist on model {$model_name}"], 500);
}
// Load the relationship and return the data
$result = $model->$relationship;
// Handle different result types
if ($result instanceof \Illuminate\Database\Eloquent\Collection) {
return response()->json($result->map->toArray());
} elseif ($result instanceof \Illuminate\Database\Eloquent\Model) {
return response()->json($result->toArray());
} elseif ($result === null) {
return response()->json(null);
}
return response()->json(['error' => "Unable to load relationship {$relationship}"], 500);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,352 @@
<?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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\RSpade\Core\Manifest\Manifest;
/**
* ApiHandler manages API request processing for RSX dispatch system
*
* OPINIONATED API DESIGN PRINCIPLES:
* ===================================
* 1. ALL external API requests return JSON with application/json content-type
* 2. NO content negotiation - APIs always return JSON, period
* 3. Internal API calls return PHP objects/arrays directly
* 4. This is non-negotiable framework behavior
*
* INTERNAL API EXECUTION ARCHITECTURE (Future Implementation):
* =============================================================
*
* This handler will support internal API execution, allowing PHP code to call
* API endpoints directly without HTTP requests. The architecture will work as follows:
*
* 1. INVOCATION METHOD:
* API::execute("ControllerClass.method", $params)
* - Directly calls API controller methods
* - Returns PHP objects/arrays instead of JSON
* - Uses same code path as external requests
*
* 2. SESSION STACK MANAGEMENT:
* When an internal API call is made:
* a. Current user session is preserved on a stack
* b. New session context is created (starts unauthenticated)
* c. API executes with fresh session scope
* d. Session is popped and original restored after completion
*
* 3. AUTHENTICATION FLOW:
* Internal API calls authenticate via:
* - Explicit API token/key in parameters
* - Inherited from current session (if no token provided)
* - Each nested call maintains its own auth context
*
* 4. NESTED CALL SUPPORT:
* - API A can call API B which can call API C
* - Each maintains separate session scope
* - Stack ensures proper restoration on unwind
* - Prevents session pollution between calls
*
* 5. IMPLEMENTATION BENEFITS:
* - Reuse API logic for internal operations
* - Consistent behavior internal/external
* - Proper session isolation
* - Service-oriented architecture in monolith
*
* EXAMPLE USAGE (Future):
* ```php
* // Internal call with current user context
* $result = API::execute('UserApi.get_profile', ['user_id' => 123]);
*
* // Internal call with specific API key
* $result = API::execute('DataApi.export', [
* '_api_key' => 'secret_key',
* 'format' => 'csv'
* ]);
*
* // Nested internal calls work automatically
* // UserApi.get_full_profile might internally call:
* // - PostApi.get_user_posts
* // - CommentApi.get_user_comments
* // Each with proper session isolation
* ```
*
* TODO: Implement session stack when authentication system is ready
* TODO: Add API token/key authentication mechanism
* TODO: Create API::execute() static helper method
* TODO: Add request/response interceptors for internal calls
* TODO: Implement circuit breaker for recursive call protection
*/
class ApiHandler
{
/**
* API version header name
*
* @var string
*/
protected static $version_header = 'X-API-Version';
/**
* Handle API request
*
* @param mixed $handler The handler (controller/method)
* @param Request $request
* @param array $params
* @param array $attributes Route attributes
* @return \Illuminate\Http\Response
*/
public static function handle($handler, Request $request, array $params = [], array $attributes = [])
{
// Add API-specific parameters
$params = static::__prepare_api_params($params, $request);
// Check API version if specified
$version = static::__get_api_version($request, $attributes);
if ($version) {
$params['_api_version'] = $version;
}
// Execute the handler
$result = static::__execute_handler($handler, $request, $params);
// ALWAYS return JSON for external API requests
return static::__create_json_response($result, $request);
}
/**
* Execute API handler internally (for future internal API calls)
*
* @param string $endpoint Format: "ControllerClass.method"
* @param array $params
* @param array $options
* @return mixed Raw PHP response (not JSON)
*/
public static function execute_internal($endpoint, array $params = [], array $options = [])
{
// Parse endpoint
list($class, $method) = static::__parse_endpoint($endpoint);
// TODO: Push new session context onto stack
// $this->push_session_context($options);
try {
// Build handler callable
if (!class_exists($class)) {
throw new \InvalidArgumentException("API class not found: {$class}");
}
$handler = [$class, $method];
if (!is_callable($handler)) {
throw new \InvalidArgumentException("API method not callable: {$endpoint}");
}
// Add internal execution flag
$params['_internal_call'] = true;
$params['_caller_session'] = $options['session'] ?? null;
// Execute handler directly
$result = call_user_func($handler, $params);
// For internal calls, return raw data (not JSON response)
if ($result instanceof JsonResponse) {
return json_decode($result->getContent(), true);
}
return $result;
} finally {
// TODO: Pop session context from stack
// $this->pop_session_context();
}
}
/**
* Parse endpoint string into class and method
*
* @param string $endpoint
* @return array [class, method]
*/
protected static function __parse_endpoint($endpoint)
{
$parts = explode('.', $endpoint);
if (count($parts) !== 2) {
throw new \InvalidArgumentException("Invalid endpoint format. Expected: ControllerClass.method");
}
$class = $parts[0];
$method = $parts[1];
// Add namespace if not present
if (!str_contains($class, '\\')) {
// Check common API namespaces using Manifest
$namespaces = [
'App\\Http\\Controllers\\Api\\',
'App\\RSpade\\Api\\',
'App\\Api\\'
];
$found = false;
foreach ($namespaces as $namespace) {
try {
$full_class = $namespace . $class;
$metadata = Manifest::php_get_metadata_by_fqcn($full_class);
$class = $metadata['fqcn'];
$found = true;
break;
} catch (\RuntimeException $e) {
// Try next namespace
}
}
if (!$found) {
// Try without namespace prefix
try {
$metadata = Manifest::php_get_metadata_by_class($class);
$class = $metadata['fqcn'];
} catch (\RuntimeException $e) {
// Class will be used as-is, may fail later
}
}
}
return [$class, $method];
}
/**
* Execute the API handler
*
* @param mixed $handler
* @param Request $request
* @param array $params
* @return mixed
*/
protected static function __execute_handler($handler, Request $request, array $params)
{
// Handle different handler types
if (is_string($handler) && str_contains($handler, '@')) {
// Laravel style "Controller@method"
list($class, $method) = explode('@', $handler);
$handler = [new $class, $method];
}
if (!is_callable($handler)) {
throw new \RuntimeException("API handler is not callable");
}
// Call handler with request and params
$result = call_user_func($handler, $request, $params);
// Ensure we have a result
if ($result === null) {
$result = ['success' => true];
}
return $result;
}
/**
* Create JSON response for API result
*
* @param mixed $data
* @param Request $request
* @return JsonResponse
*/
protected static function __create_json_response($data, Request $request)
{
$response = new JsonResponse($data);
// Pretty print in development for debugging
if (app()->environment('local', 'development')) {
$response->setEncodingOptions(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
// Always set JSON content type explicitly
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* Prepare API-specific parameters
*
* @param array $params
* @param Request $request
* @return array
*/
protected static function __prepare_api_params(array $params, Request $request)
{
// Add pagination parameters
if ($request->has('page')) {
$params['_page'] = (int) $request->get('page', 1);
$params['_per_page'] = (int) $request->get('per_page', 25);
}
// Add sorting parameters
if ($request->has('sort')) {
$params['_sort'] = $request->get('sort');
$params['_order'] = $request->get('order', 'asc');
}
// Add field filtering
if ($request->has('fields')) {
$params['_fields'] = explode(',', $request->get('fields'));
}
// Add search parameter
if ($request->has('q') || $request->has('search')) {
$params['_search'] = $request->get('q') ?: $request->get('search');
}
// Add API key if present
if ($request->has('api_key')) {
$params['_api_key'] = $request->get('api_key');
} elseif ($request->header('X-API-Key')) {
$params['_api_key'] = $request->header('X-API-Key');
}
return $params;
}
/**
* Get API version from request or attributes
*
* @param Request $request
* @param array $attributes
* @return string|null
*/
protected static function __get_api_version(Request $request, array $attributes)
{
// Check route attribute
foreach ($attributes as $attr) {
if ($attr instanceof \App\RSpade\Core\Attributes\ApiVersion) {
return $attr->version;
}
}
// Check header
if ($request->hasHeader(static::$version_header)) {
return $request->header(static::$version_header);
}
// Check URL path (e.g., /api/v2/...)
$path = $request->path();
if (preg_match('#/v(\d+)/#', $path, $matches)) {
return $matches[1];
}
return null;
}
}

View File

@@ -0,0 +1,722 @@
<?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 Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Illuminate\Support\Facades\Log;
/**
* AssetHandler serves static files from RSX public directories
*
* Scans for /rsx/{module}/public/ directories and serves files securely
* with proper cache headers and MIME types
*
* TODO: Unit Tests for Bundle Serving Behavior
* =============================================
* When the framework is more complete, create comprehensive unit tests for:
*
* Development Mode (env('APP_ENV') !== 'production'):
* -------------------------------------------------------------
* 1. Bundle filenames are predictable: app.(hash).js where hash = substr(sha256(FQCN), 0, 32)
* - Example: FrontendBundle -> app.053cded429c46421aea774afff5fbd8b.js
* 2. Query parameters added for cache-busting: ?v=(manifest-hash)
* - Manifest hash is random on each request (bin2hex(random_bytes(16)))
* 3. Bundles compiled on-demand when requested (compile_bundle_on_demand method)
* 4. Cache headers set to no-cache: "Cache-Control: no-cache, no-store, must-revalidate"
* 5. storage/rsx directory cleared on each request EXCEPT during bundle serving
*
* Production Mode (env('APP_ENV') === 'production'):
* ----------------------------------------------------------
* 1. Bundle filenames include manifest hash: app.(hash).js where hash = substr(sha256(manifest_hash . '|' . FQCN), 0, 32)
* - Changes when manifest OR bundle content changes
* 2. No query parameters needed (hash in filename handles cache-busting)
* 3. Bundles pre-compiled via artisan rsx:bundle:compile
* 4. Aggressive caching: "Cache-Control: public, max-age=31536000, immutable"
* 5. ETag headers for cache validation
*
* Test Scenarios:
* ---------------
* - Verify filename format validation (regex: /^app\.[a-f0-9]{32}\.(js|css)$/)
* - Test 404 responses for invalid bundle names
* - Verify correct MIME types (application/javascript, text/css)
* - Test that bundles regenerate in development when files change
* - Verify production bundles remain cached between requests
* - Test security headers (X-Content-Type-Options: nosniff)
* - Verify /_compiled/ path routing works correctly
* - Test that non-existent bundles trigger on-demand compilation in debug mode
* - Verify that compilation errors are logged but don't throw exceptions
*
* Integration Tests:
* ------------------
* - Test full page load with bundle URLs in HTML
* - Verify jQuery loads before other scripts (prioritization)
* - Test that window.rsxapp data is correctly formatted (pretty in dev, compact in prod)
* - Verify manifest hash changes trigger new bundle URLs in production
* - Test that development mode allows immediate visibility of JS/CSS changes
*/
class AssetHandler
{
/**
* Cache of discovered public directories
*
* @var array
*/
protected static $public_directories = [];
/**
* Allowed file extensions for security
*
* @var array
*/
protected static $allowed_extensions = [
'css', 'js', 'json', 'xml',
'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'ico',
'woff', 'woff2', 'ttf', 'eot', 'otf',
'mp3', 'mp4', 'webm', 'ogg', 'wav',
'pdf', 'zip', 'txt', 'md',
'html', 'htm'
];
/**
* MIME type mappings
*
* @var array
*/
protected static $mime_types = [
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'xml' => 'application/xml',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'ico' => 'image/x-icon',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
'otf' => 'font/otf',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'ogg' => 'video/ogg',
'wav' => 'audio/wav',
'pdf' => 'application/pdf',
'zip' => 'application/zip',
'txt' => 'text/plain',
'md' => 'text/markdown',
'html' => 'text/html',
'htm' => 'text/html'
];
/**
* Whether directories have been discovered
* @var bool
*/
protected static $directories_discovered = false;
/**
* Check if a path is an asset request
*
* @param string $path
* @return bool
*/
public static function is_asset_request($path)
{
// Check if this is a compiled bundle request
if (str_starts_with($path, '/_compiled/')) {
// Validate filename format: BundleName__(vendor|app).(8 chars).(js|css) or BundleName__app.(16 chars).(js|css)
$filename = substr($path, 11); // Remove '/_compiled/'
return preg_match('/^[A-Za-z0-9_]+__(vendor|app)\.[a-f0-9]{8}\.(js|css)$/', $filename) ||
preg_match('/^[A-Za-z0-9_]+__app\.[a-f0-9]{16}\.(js|css)$/', $filename);
}
// Check if path has a file extension
$extension = pathinfo($path, PATHINFO_EXTENSION);
if (empty($extension)) {
return false;
}
// Check if extension is allowed
return in_array(strtolower($extension), static::$allowed_extensions);
}
/**
* Serve an asset file
*
* @param string $path The requested asset path
* @param Request $request
* @return Response
* @throws NotFoundHttpException
*/
public static function serve($path, Request $request)
{
// Handle compiled bundle requests
if (str_starts_with($path, '/_compiled/')) {
return static::__serve_compiled_bundle($path, $request);
}
// Ensure directories are discovered
static::__ensure_directories_discovered();
// Sanitize path to prevent directory traversal
$path = static::__sanitize_path($path);
// Find the file in public directories
$file_path = static::__find_asset_file($path);
if (!$file_path) {
throw new NotFoundHttpException("Asset not found: {$path}");
}
// Check if file is within allowed directories
if (!static::__is_safe_path($file_path)) {
Log::warning('Attempted directory traversal', [
'requested' => $path,
'resolved' => $file_path
]);
throw new NotFoundHttpException("Asset not found: {$path}");
}
// Create binary file response
$response = new BinaryFileResponse($file_path);
// Set MIME type
$mime_type = static::__get_mime_type($file_path);
$response->headers->set('Content-Type', $mime_type);
// Check if request has cache-busting parameter
$has_cache_buster = $request->query->has('v');
// Set cache headers based on cache-busting presence
static::__set_cache_headers($response, $file_path, $has_cache_buster);
// Handle conditional requests for non-cache-busted URLs
if (!$has_cache_buster) {
// BinaryFileResponse automatically handles If-None-Match and If-Modified-Since
// and will return 304 Not Modified when appropriate
$response->isNotModified($request);
}
// Set additional security headers
static::__set_security_headers($response, $mime_type);
// Enable gzip if supported
if (static::__should_compress($mime_type)) {
$response->headers->set('Content-Encoding', 'gzip');
}
return $response;
}
/**
* Ensure directories are discovered (lazy initialization)
*/
protected static function __ensure_directories_discovered()
{
if (!static::$directories_discovered) {
static::__discover_public_directories();
static::$directories_discovered = true;
}
}
/**
* Discover public directories in RSX modules
*
* @return void
*/
protected static function __discover_public_directories()
{
$rsx_path = base_path('rsx');
if (!File::isDirectory($rsx_path)) {
return;
}
// Scan for directories with public subdirectories
$directories = File::directories($rsx_path);
foreach ($directories as $dir) {
$public_dir = $dir . '/public';
if (File::isDirectory($public_dir)) {
$module_name = basename($dir);
static::$public_directories[$module_name] = $public_dir;
Log::debug('Discovered RSX public directory', [
'module' => $module_name,
'path' => $public_dir
]);
}
}
// Also check for a root public directory
$root_public = $rsx_path . '/public';
if (File::isDirectory($root_public)) {
static::$public_directories['_root'] = $root_public;
}
}
/**
* Sanitize path to prevent directory traversal
*
* @param string $path
* @return string
*/
protected static function __sanitize_path($path)
{
// Remove any .. or ./ sequences
$path = str_replace(['../', '..\\', './', '.\\'], '', $path);
// Remove duplicate slashes
$path = preg_replace('#/+#', '/', $path);
// Remove leading slash
$path = ltrim($path, '/');
return $path;
}
/**
* Find asset file in public directories
*
* @param string $path
* @return string|null Full file path or null if not found
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
protected static function __find_asset_file($path)
{
// NEVER serve PHP files under any circumstances
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension === 'php') {
throw new HttpException(403, 'PHP files cannot be served as static assets');
}
// Try each public directory
foreach (static::$public_directories as $module => $directory) {
$full_path = $directory . '/' . $path;
if (File::exists($full_path) && File::isFile($full_path)) {
// Check exclusion rules before returning
if (static::__is_file_excluded($full_path, $path)) {
throw new HttpException(403, 'Access to this file is forbidden');
}
return $full_path;
}
}
// Check if path includes module prefix (e.g., "admin/css/style.css")
$parts = explode('/', $path, 2);
if (count($parts) === 2) {
$module = $parts[0];
$asset_path = $parts[1];
if (isset(static::$public_directories[$module])) {
$full_path = static::$public_directories[$module] . '/' . $asset_path;
if (File::exists($full_path) && File::isFile($full_path)) {
// Check exclusion rules before returning
if (static::__is_file_excluded($full_path, $asset_path)) {
throw new HttpException(403, 'Access to this file is forbidden');
}
return $full_path;
}
}
}
return null;
}
/**
* Check if a file should be excluded from serving
*
* @param string $full_path The full filesystem path to the file
* @param string $relative_path The relative path requested
* @return bool True if file should be excluded
*/
protected static function __is_file_excluded($full_path, $relative_path)
{
// Get global exclusion patterns from config
$global_patterns = config('rsx.public.ignore_patterns', []);
// Find the public directory this file belongs to
$public_dir = static::__find_public_directory($full_path);
if (!$public_dir) {
return false;
}
// Load public_ignore.json if it exists
$local_patterns = [];
$ignore_file = $public_dir . '/public_ignore.json';
if (File::exists($ignore_file)) {
$json = json_decode(File::get($ignore_file), true);
if (is_array($json)) {
$local_patterns = $json;
}
}
// Combine all patterns
$all_patterns = array_merge($global_patterns, $local_patterns);
// Check each pattern
foreach ($all_patterns as $pattern) {
if (static::__matches_gitignore_pattern($relative_path, $pattern)) {
return true;
}
}
return false;
}
/**
* Find which public directory a file belongs to
*
* @param string $full_path
* @return string|null
*/
protected static function __find_public_directory($full_path)
{
$real_path = realpath($full_path);
if (!$real_path) {
return null;
}
// Find the public directory that contains this file
foreach (static::$public_directories as $directory) {
$real_directory = realpath($directory);
if ($real_directory && str_starts_with($real_path, $real_directory)) {
return $real_directory;
}
}
return null;
}
/**
* Check if a path matches a gitignore-style pattern
*
* @param string $path The file path to check
* @param string $pattern The gitignore pattern
* @return bool
*/
protected static function __matches_gitignore_pattern($path, $pattern)
{
// Normalize path separators
$path = str_replace('\\', '/', $path);
$pattern = str_replace('\\', '/', $pattern);
// Handle negation (not implemented - we only block, never unblock)
if (str_starts_with($pattern, '!')) {
return false;
}
// Convert gitignore pattern to regex
$regex = static::__gitignore_to_regex($pattern);
return (bool) preg_match($regex, $path);
}
/**
* Convert gitignore pattern to regex
*
* @param string $pattern
* @return string
*/
protected static function __gitignore_to_regex($pattern)
{
// Remove leading/trailing whitespace
$pattern = trim($pattern);
// Escape special regex characters except * and ?
$pattern = preg_quote($pattern, '#');
// Restore * and ? for conversion
$pattern = str_replace(['\*', '\?'], ['STAR', 'QUESTION'], $pattern);
// Convert gitignore wildcards to regex
$pattern = str_replace('STAR', '.*', $pattern); // * matches anything
$pattern = str_replace('QUESTION', '.', $pattern); // ? matches single char
// If pattern ends with /, it matches directories (any file within)
if (str_ends_with($pattern, '/')) {
$pattern = rtrim($pattern, '/');
$pattern = $pattern . '(?:/.*)?';
}
// If pattern doesn't start with /, it can match at any level
if (!str_starts_with($pattern, '/')) {
$pattern = '(?:.*/)?' . $pattern;
} else {
$pattern = ltrim($pattern, '/');
}
// Anchor pattern
return '#^' . $pattern . '$#';
}
/**
* Check if path is safe (within allowed directories)
*
* @param string $path
* @return bool
*/
protected static function __is_safe_path($path)
{
$real_path = realpath($path);
if ($real_path === false) {
return false;
}
// Check if real path is within any allowed directory
foreach (static::$public_directories as $directory) {
$real_directory = realpath($directory);
if (str_starts_with($real_path, $real_directory)) {
return true;
}
}
return false;
}
/**
* Get MIME type for file
*
* @param string $file_path
* @return string
*/
protected static function __get_mime_type($file_path)
{
$extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
if (isset(static::$mime_types[$extension])) {
return static::$mime_types[$extension];
}
// Try to detect MIME type
$mime = mime_content_type($file_path);
return $mime ?: 'application/octet-stream';
}
/**
* Set cache headers for response
*
* @param BinaryFileResponse $response
* @param string $file_path
* @param bool $has_cache_buster Whether request has ?v= cache-busting parameter
* @return void
*/
protected static function __set_cache_headers(BinaryFileResponse $response, $file_path, $has_cache_buster)
{
if ($has_cache_buster) {
// Aggressive caching - URL has version parameter
$cache_seconds = 2592000; // 30 days (1 month)
$response->setMaxAge($cache_seconds);
$response->setPublic();
$response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $cache_seconds) . ' GMT');
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', immutable');
// No revalidation headers needed - file is cache-busted via ?v= parameter
} else {
// Conservative caching with revalidation - no version parameter
$cache_seconds = 300; // 5 minutes
$response->setMaxAge($cache_seconds);
$response->setPublic();
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', must-revalidate');
// Set ETag and Last-Modified for efficient revalidation
// BinaryFileResponse automatically sets these based on file mtime and size
$response->setAutoEtag();
$response->setAutoLastModified();
}
}
/**
* Set security headers
*
* @param BinaryFileResponse $response
* @param string $mime_type
* @return void
*/
protected static function __set_security_headers(BinaryFileResponse $response, $mime_type)
{
// Prevent MIME type sniffing
$response->headers->set('X-Content-Type-Options', 'nosniff');
// Set CSP for HTML files
if (str_starts_with($mime_type, 'text/html')) {
$response->headers->set('Content-Security-Policy', "default-src 'self'");
}
// Prevent framing for HTML
if (str_starts_with($mime_type, 'text/html')) {
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
}
}
/**
* Serve a compiled bundle file
*
* @param string $path The requested path (e.g., /_compiled/Demo_Bundle__vendor.abc12345.js)
* @param Request $request
* @return Response
* @throws NotFoundHttpException
*/
protected static function __serve_compiled_bundle($path, Request $request)
{
// Extract filename from path
$filename = substr($path, 11); // Remove '/_compiled/'
// Validate filename format: BundleName__(vendor|app).(8 chars).(js|css) or BundleName__app.(16 chars).(js|css)
if (!preg_match('/^([A-Za-z0-9_]+)__(vendor|app)\.([a-f0-9]{8}|[a-f0-9]{16})\.(js|css)$/', $filename, $matches)) {
throw new NotFoundHttpException("Invalid bundle filename: {$filename}");
}
$bundle_name = $matches[1];
$type = $matches[2];
$hash = $matches[3];
$extension = $matches[4];
// Build full path to file
$file_path = storage_path("rsx-build/bundles/{$filename}");
// In development mode, compile bundle on-the-fly if it doesn't exist
if (env('APP_ENV') !== 'production' && !file_exists($file_path)) {
// Try to compile the bundle on-demand
static::__compile_bundle_on_demand($bundle_name, $type, $hash, $extension);
}
// Check if file exists
if (!file_exists($file_path)) {
throw new NotFoundHttpException("Bundle not found: {$filename}");
}
// Create binary file response
$response = new BinaryFileResponse($file_path);
// Set appropriate content type
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$mime_type = $extension === 'js' ? 'application/javascript' : 'text/css';
$response->headers->set('Content-Type', $mime_type . '; charset=utf-8');
// Set cache headers - 14 day cache for all compiled bundles (same for dev and prod)
// Since we use cache-busting in filenames/query strings, we can cache aggressively
$cache_seconds = 1209600; // 14 days
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', immutable');
$response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $cache_seconds) . ' GMT');
// No ETag or revalidation needed - files are cache-busted via filename/query string
// Security headers
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* Compile a bundle on-demand in development mode
*
* @param string $bundle_name The bundle name from the requested filename
* @param string $type The bundle type (vendor or app)
* @param string $hash The hash from the requested filename
* @param string $extension The file extension (js or css)
* @return void
*/
protected static function __compile_bundle_on_demand($bundle_name, $type, $hash, $extension)
{
// Find the bundle class matching the bundle name
try {
// Try to find by simple class name
$metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_class($bundle_name);
if (!isset($metadata['fqcn'])) {
return;
}
$fqcn = $metadata['fqcn'];
// Compile the bundle
try {
$compiler = new \App\RSpade\Core\Bundle\BundleCompiler();
$compiler->compile($fqcn);
return;
} catch (\Exception $e) {
// Log error but don't throw - let the file not found error happen
\Illuminate\Support\Facades\Log::error("Failed to compile bundle on-demand: {$fqcn}", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
} catch (\RuntimeException $e) {
// Bundle class not found in manifest
return;
}
}
/**
* Check if content should be compressed
*
* @param string $mime_type
* @return bool
*/
protected static function __should_compress($mime_type)
{
// Compress text-based content
$compressible = [
'text/',
'application/javascript',
'application/json',
'application/xml',
'image/svg+xml'
];
foreach ($compressible as $type) {
if (str_starts_with($mime_type, $type)) {
return true;
}
}
return false;
}
/**
* Get discovered public directories
*
* @return array
*/
public static function get_public_directories()
{
static::__ensure_directories_discovered();
return static::$public_directories;
}
/**
* Clear and rediscover public directories
*
* @return void
*/
public static function refresh_directories()
{
static::$public_directories = [];
static::$directories_discovered = false;
static::__discover_public_directories();
static::$directories_discovered = true;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
<?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 ReflectionClass;
use RuntimeException;
use Throwable;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* HandlerFactory determines handler types and creates appropriate response builders
*
* Handler types:
* - controller: HTML responses (extends Rsx_Controller_Abstract)
* - file: File operations (extends RSpade_FileHandler)
* - asset: Static assets from /rsx/[name]/public/ directories
* - custom: User-registered handlers
*/
class HandlerFactory
{
/**
* Handler type constants
*/
public const TYPE_CONTROLLER = 'controller';
public const TYPE_FILE = 'file';
public const TYPE_ASSET = 'asset';
public const TYPE_CUSTOM = 'custom';
public const TYPE_UNKNOWN = 'unknown';
/**
* Custom handler registry
* @var array
*/
protected static $custom_handlers = [];
/**
* Response builder instances
* @var array
*/
protected static $response_builders = [];
/**
* Detect handler type from class name
*
* @param string $class_name
* @return string Handler type constant
*/
public static function detect_handler_type($class_name)
{
// Check custom handlers first
if (isset(self::$custom_handlers[$class_name])) {
return self::TYPE_CUSTOM;
}
// Check if class exists in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
$class_name = $metadata['fqcn'];
} catch (RuntimeException $e) {
// Try with Rsx namespace
try {
$metadata = Manifest::php_get_metadata_by_fqcn('Rsx\\' . $class_name);
$class_name = $metadata['fqcn'];
} catch (RuntimeException $e2) {
return self::TYPE_UNKNOWN;
}
}
try {
$reflection = new ReflectionClass($class_name);
// Check inheritance chain
if (static::__extends_class($reflection, RSpade_FileHandler::class)) {
return self::TYPE_FILE;
}
if (static::__extends_class($reflection, Rsx_Controller_Abstract::class)) {
return self::TYPE_CONTROLLER;
}
// Check custom handler interfaces if registered
foreach (self::$custom_handlers as $interface => $type) {
if ($reflection->implementsInterface($interface)) {
return self::TYPE_CUSTOM;
}
}
return self::TYPE_UNKNOWN;
} catch (Throwable $e) {
return self::TYPE_UNKNOWN;
}
}
/**
* Check if a class extends another class
*
* @param ReflectionClass $reflection
* @param string $base_class
* @return bool
*/
protected static function __extends_class(ReflectionClass $reflection, $base_class)
{
while ($parent = $reflection->getParentClass()) {
if ($parent->getName() === $base_class) {
return true;
}
$reflection = $parent;
}
return false;
}
/**
* Get response builder for handler type
*
* @param string $handler_type
* @return ResponseBuilder|null
*/
public static function get_response_builder($handler_type)
{
// Cache response builders
if (!isset(self::$response_builders[$handler_type])) {
self::$response_builders[$handler_type] = static::__create_response_builder($handler_type);
}
return self::$response_builders[$handler_type];
}
/**
* Create response builder for handler type
*
* @param string $handler_type
* @return ResponseBuilder|null
*/
protected static function __create_response_builder($handler_type)
{
// In the next phase (2.4), we'll implement specific response builders
// For now, return null - the Dispatcher will handle responses directly
return null;
}
/**
* Register a custom handler type
*
* @param string $class_or_interface Class name or interface
* @param string $handler_type Type identifier
* @param callable|null $response_builder Optional response builder
*/
public static function register_custom_handler($class_or_interface, $handler_type, $response_builder = null)
{
self::$custom_handlers[$class_or_interface] = $handler_type;
if ($response_builder !== null) {
self::$response_builders[$handler_type] = $response_builder;
}
}
/**
* Unregister a custom handler
*
* @param string $class_or_interface
*/
public static function unregister_custom_handler($class_or_interface)
{
unset(self::$custom_handlers[$class_or_interface]);
}
/**
* Get all registered custom handlers
*
* @return array
*/
public static function get_custom_handlers()
{
return self::$custom_handlers;
}
/**
* Clear all custom handlers
*/
public static function clear_custom_handlers()
{
self::$custom_handlers = [];
self::$response_builders = [];
}
/**
* Determine if a path is an asset request
*
* @param string $path
* @return bool
*/
public static function is_asset_request($path)
{
// Check if path matches asset pattern
// Assets are in /rsx/*/public/ directories
if (preg_match('#^/rsx/[^/]+/public/#', $path)) {
return true;
}
// Check common asset extensions
$asset_extensions = ['js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot'];
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($extension, $asset_extensions)) {
// Check if it's in a known asset directory
if (preg_match('#/(assets|public|static|dist|build)/#', $path)) {
return true;
}
}
return false;
}
/**
* Get handler metadata
*
* @param string $class_name
* @return array
*/
public static function get_handler_metadata($class_name)
{
$type = static::detect_handler_type($class_name);
$metadata = [
'class' => $class_name,
'type' => $type,
'base_class' => null,
'interfaces' => [],
'traits' => [],
];
if ($type === self::TYPE_UNKNOWN) {
return $metadata;
}
try {
$reflection = new ReflectionClass($class_name);
// Get base class
$parent = $reflection->getParentClass();
if ($parent) {
$metadata['base_class'] = $parent->getName();
}
// Get interfaces
$metadata['interfaces'] = $reflection->getInterfaceNames();
// Get traits
$metadata['traits'] = $reflection->getTraitNames();
} catch (Throwable $e) {
// Keep default metadata
}
return $metadata;
}
/**
* Validate handler class
*
* @param string $class_name
* @param string $method_name
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validate_handler($class_name, $method_name)
{
$errors = [];
// Check if class exists in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
// Class exists in manifest
} catch (RuntimeException $e) {
// Also try as FQCN
try {
$metadata = Manifest::php_get_metadata_by_fqcn($class_name);
} catch (RuntimeException $e2) {
$errors[] = "Handler class not found in manifest: {$class_name}";
return ['valid' => false, 'errors' => $errors];
}
}
try {
$reflection = new ReflectionClass($class_name);
// Check if method exists
if (!$reflection->hasMethod($method_name)) {
$errors[] = "Handler method not found: {$class_name}::{$method_name}";
}
// Check if method is public and static
if ($reflection->hasMethod($method_name)) {
$method = $reflection->getMethod($method_name);
if (!$method->isPublic()) {
$errors[] = "Handler method must be public: {$class_name}::{$method_name}";
}
if (!$method->isStatic()) {
$errors[] = "Handler method must be static: {$class_name}::{$method_name}";
}
}
// Check handler type
$type = static::detect_handler_type($class_name);
if ($type === self::TYPE_UNKNOWN) {
$errors[] = "Unknown handler type for class: {$class_name}";
}
} catch (Throwable $e) {
$errors[] = 'Error validating handler: ' . $e->getMessage();
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Get priority for handler type
*
* @param string $handler_type
* @return int Lower number = higher priority
*/
public static function get_handler_priority($handler_type)
{
$priorities = [
self::TYPE_CONTROLLER => 1,
self::TYPE_FILE => 2,
self::TYPE_ASSET => 3,
self::TYPE_CUSTOM => 4,
self::TYPE_UNKNOWN => 99,
];
return $priorities[$handler_type] ?? 99;
}
/**
* Sort handlers by priority
*
* @param array $handlers Array of ['class' => string, 'type' => string, ...]
* @return array Sorted handlers
*/
public static function sort_handlers_by_priority($handlers)
{
usort($handlers, function ($a, $b) {
$priority_a = static::get_handler_priority($a['type'] ?? self::TYPE_UNKNOWN);
$priority_b = static::get_handler_priority($b['type'] ?? self::TYPE_UNKNOWN);
return $priority_a - $priority_b;
});
return $handlers;
}
}

View File

@@ -0,0 +1,476 @@
<?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 Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\RSpade\Core\Dispatch\HandlerFactory;
/**
* ResponseBuilder builds appropriate HTTP responses based on handler results
*
* Supports response types:
* - view: Render Laravel view
* - json: JSON response
* - redirect: HTTP redirect
* - file: File download/stream
* - stream: Streamed response
* - error: Error response
* - empty: Empty response
* - raw: Raw text/html response
*/
class ResponseBuilder
{
/**
* Response type constants
*/
const TYPE_VIEW = 'view';
const TYPE_JSON = 'json';
const TYPE_REDIRECT = 'redirect';
const TYPE_FILE = 'file';
const TYPE_STREAM = 'stream';
const TYPE_ERROR = 'error';
const TYPE_EMPTY = 'empty';
const TYPE_RAW = 'raw';
/**
* Default response headers by type
* @var array
*/
protected static $default_headers = [
self::TYPE_JSON => ['Content-Type' => 'application/json'],
self::TYPE_RAW => ['Content-Type' => 'text/html; charset=UTF-8'],
];
/**
* Build response from handler result
*
* @param mixed $result Handler result
* @param string $handler_type Handler type from HandlerFactory
* @param array $attributes Method attributes
* @return Response
*/
public static function build($result, $handler_type = null, $attributes = [])
{
// If already a Response object, return as-is
if ($result instanceof Response || $result instanceof JsonResponse ||
$result instanceof RedirectResponse || $result instanceof BinaryFileResponse ||
$result instanceof StreamedResponse) {
return $result;
}
// Handle array responses with type hints
if (is_array($result) && isset($result['type'])) {
return static::__build_typed_response($result);
}
// Handle based on handler type
if ($handler_type !== null) {
return static::__build_by_handler_type($result, $handler_type, $attributes);
}
// Default: return as JSON
return static::__build_json_response($result);
}
/**
* Build response from typed result array
*
* @param array $result
* @return Response
*/
protected static function __build_typed_response($result)
{
$type = $result['type'];
switch ($type) {
case self::TYPE_VIEW:
return static::__build_view_response($result);
case self::TYPE_JSON:
return static::__build_json_response(
$result['data'] ?? $result,
$result['status'] ?? 200,
$result['headers'] ?? []
);
case self::TYPE_REDIRECT:
return static::__build_redirect_response($result);
case self::TYPE_FILE:
return static::__build_file_response($result);
case self::TYPE_STREAM:
return static::__build_stream_response($result);
case self::TYPE_ERROR:
return static::__build_error_response($result);
case self::TYPE_EMPTY:
return static::__build_empty_response(
$result['status'] ?? 204,
$result['headers'] ?? []
);
case self::TYPE_RAW:
return static::__build_raw_response($result);
default:
// Unknown type, return as JSON
return static::__build_json_response($result);
}
}
/**
* Build response based on handler type
*
* @param mixed $result
* @param string $handler_type
* @param array $attributes
* @return Response
*/
protected static function __build_by_handler_type($result, $handler_type, $attributes)
{
switch ($handler_type) {
case HandlerFactory::TYPE_CONTROLLER:
// Controllers default to view responses
if (is_array($result) && !isset($result['type'])) {
$result['type'] = self::TYPE_VIEW;
return static::__build_typed_response($result);
}
break;
case HandlerFactory::TYPE_API:
// API handlers default to JSON
if (!is_array($result) || !isset($result['type'])) {
return static::__build_json_response($result);
}
break;
case HandlerFactory::TYPE_FILE:
// File handlers default to file responses
if (is_array($result) && !isset($result['type'])) {
$result['type'] = self::TYPE_FILE;
return static::__build_typed_response($result);
}
break;
}
// If we have a typed response, build it
if (is_array($result) && isset($result['type'])) {
return static::__build_typed_response($result);
}
// Default to JSON
return static::__build_json_response($result);
}
/**
* Build view response
*
* @param array $result
* @return Response
*/
protected static function __build_view_response($result)
{
$view = $result['view'] ?? 'welcome';
$data = $result['data'] ?? [];
$status = $result['status'] ?? 200;
$headers = $result['headers'] ?? [];
// Check if view exists
if (!View::exists($view)) {
// Return error response if view not found
return static::__build_error_response([
'code' => 500,
'message' => "View not found: {$view}"
]);
}
return response()->view($view, $data, $status)->withHeaders($headers);
}
/**
* Build JSON response
*
* @param mixed $data
* @param int $status
* @param array $headers
* @return JsonResponse
*/
protected static function __build_json_response($data, $status = 200, $headers = [])
{
// Merge with default JSON headers
$headers = array_merge(static::$default_headers[self::TYPE_JSON], $headers);
return response()->json($data, $status, $headers);
}
/**
* Build redirect response
*
* @param array $result
* @return Response
*/
protected static function __build_redirect_response($result)
{
$url = $result['url'] ?? '/';
$status = $result['status'] ?? 302;
$headers = $result['headers'] ?? [];
$secure = $result['secure'] ?? null;
// Use Laravel's redirect helper for proper URL generation
$response = redirect($url, $status, $headers, $secure);
// Add any additional headers
if (!empty($result['with'])) {
foreach ($result['with'] as $key => $value) {
$response->with($key, $value);
}
}
return $response;
}
/**
* Build file response
*
* @param array $result
* @return BinaryFileResponse|Response
*/
protected static function __build_file_response($result)
{
$path = $result['path'] ?? null;
if (!$path || !file_exists($path)) {
return static::__build_error_response([
'code' => 404,
'message' => 'File not found'
]);
}
$headers = $result['headers'] ?? [];
// Create file response
$response = response()->file($path, $headers);
// Set download name if provided
if (isset($result['name'])) {
$disposition = $result['disposition'] ?? 'attachment';
$response->headers->set(
'Content-Disposition',
"{$disposition}; filename=\"" . $result['name'] . "\""
);
}
// Set MIME type if provided
if (isset($result['mime'])) {
$response->headers->set('Content-Type', $result['mime']);
}
return $response;
}
/**
* Build stream response
*
* @param array $result
* @return StreamedResponse
*/
protected static function __build_stream_response($result)
{
$callback = $result['callback'] ?? function() { echo ''; };
$status = $result['status'] ?? 200;
$headers = $result['headers'] ?? [];
return response()->stream($callback, $status, $headers);
}
/**
* Build error response
*
* @param array $result
* @return Response
*/
protected static function __build_error_response($result)
{
$code = $result['code'] ?? 500;
$message = $result['message'] ?? 'Server Error';
$headers = $result['headers'] ?? [];
// Use Laravel's abort for proper error handling
abort($code, $message);
}
/**
* Build empty response
*
* @param int $status
* @param array $headers
* @return Response
*/
protected static function __build_empty_response($status = 204, $headers = [])
{
return response('', $status, $headers);
}
/**
* Build raw response
*
* @param array $result
* @return Response
*/
protected static function __build_raw_response($result)
{
$content = $result['content'] ?? '';
$status = $result['status'] ?? 200;
$headers = array_merge(
static::$default_headers[self::TYPE_RAW],
$result['headers'] ?? []
);
return response($content, $status, $headers);
}
/**
* Set CORS headers for response
*
* @param Response $response
* @param array $cors_config
* @return Response
*/
public static function set_cors_headers($response, $cors_config = [])
{
$default_cors = [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Max-Age' => '86400',
];
$cors = array_merge($default_cors, $cors_config);
foreach ($cors as $header => $value) {
$response->headers->set($header, $value);
}
return $response;
}
/**
* Set cache headers for response
*
* @param Response $response
* @param int $ttl Time to live in seconds
* @param array $options Additional cache options
* @return Response
*/
public static function set_cache_headers($response, $ttl = 3600, $options = [])
{
if ($ttl > 0) {
$response->headers->set('Cache-Control', "public, max-age={$ttl}");
// Set expires header
$expires = gmdate('D, d M Y H:i:s', time() + $ttl) . ' GMT';
$response->headers->set('Expires', $expires);
// Set ETag if provided
if (isset($options['etag'])) {
$response->headers->set('ETag', $options['etag']);
}
// Set Last-Modified if provided
if (isset($options['last_modified'])) {
$response->headers->set('Last-Modified', $options['last_modified']);
}
} else {
// No cache
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
}
return $response;
}
/**
* Apply response attributes
*
* @param Response $response
* @param array $attributes
* @return Response
*/
public static function apply_attributes($response, $attributes)
{
// Apply CORS if specified
if (isset($attributes['cors'])) {
static::set_cors_headers($response, $attributes['cors']);
}
// Apply cache if specified
if (isset($attributes['cache'])) {
$cache_config = $attributes['cache'];
$ttl = $cache_config['ttl'] ?? 3600;
static::set_cache_headers($response, $ttl, $cache_config);
}
// Apply custom headers
if (isset($attributes['headers'])) {
foreach ($attributes['headers'] as $header => $value) {
$response->headers->set($header, $value);
}
}
return $response;
}
/**
* Build response for asset file
*
* @param string $path
* @param string $base_path
* @return Response
*/
public static function build_asset_response($path, $base_path = null)
{
// Resolve full path
if ($base_path) {
$full_path = rtrim($base_path, '/') . '/' . ltrim($path, '/');
} else {
$full_path = public_path($path);
}
if (!file_exists($full_path)) {
return static::__build_error_response([
'code' => 404,
'message' => 'Asset not found'
]);
}
// Determine MIME type
$mime = mime_content_type($full_path);
// Build response
$response = response()->file($full_path, [
'Content-Type' => $mime
]);
// Set cache headers for assets (1 year for production)
if (app()->environment('production')) {
static::set_cache_headers($response, 31536000); // 1 year
} else {
static::set_cache_headers($response, 3600); // 1 hour for dev
}
return $response;
}
}

View File

@@ -0,0 +1,430 @@
<?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 App\RSpade\Core\Build_Manager;
/**
* RouteResolver handles route pattern matching and parameter extraction
*
* Supports patterns:
* - /exact/match - Exact URL match
* - /users/:id - Named parameter
* - /posts/:year/:month - Multiple parameters
* - /api/* - Wildcard
* - /files/:id.:ext - Mixed patterns
* - /optional/:id? - Optional parameters
*/
class RouteResolver
{
/**
* Cache of compiled patterns
* @var array
*/
protected static $pattern_cache = [];
/**
* Cache key prefix for file-based caching
*/
const CACHE_PREFIX = 'route_patterns/';
/**
* Match a URL against a route pattern
*
* @param string $url The URL to match (without query string)
* @param string $pattern The route pattern
* @return array|false Array with params on match, false otherwise
*/
public static function match($url, $pattern)
{
// Normalize URL and pattern
$url = static::__normalize_url($url);
$pattern = static::__normalize_pattern($pattern);
// Get compiled regex and parameter names
$compiled = static::compile_pattern($pattern);
if (!$compiled) {
return false;
}
// Match URL against regex
if (preg_match($compiled['regex'], $url, $matches)) {
// Extract parameters
$params = static::__extract_params($matches, $compiled['params']);
return $params;
}
return false;
}
/**
* Match URL with query string handling
*
* @param string $full_url Full URL including query string
* @param string $pattern Route pattern
* @return array|false
*/
public static function match_with_query($full_url, $pattern)
{
// Split URL and query string
$url_parts = parse_url($full_url);
$path = $url_parts['path'] ?? '/';
// Match the path
$params = static::match($path, $pattern);
if ($params === false) {
return false;
}
// Parse and merge query string parameters
// URL route parameters take precedence over GET parameters
if (isset($url_parts['query'])) {
parse_str($url_parts['query'], $query_params);
$params = array_merge($query_params, $params);
}
return $params;
}
/**
* Compile a route pattern into regex
*
* @param string $pattern The route pattern
* @return array|null Array with 'regex' and 'params' keys
*/
public static function compile_pattern($pattern)
{
// Check memory cache
if (isset(self::$pattern_cache[$pattern])) {
return self::$pattern_cache[$pattern];
}
// Check file cache
$cache_key = self::CACHE_PREFIX . md5($pattern);
$cached = Build_Manager::get($cache_key, 'cache');
if ($cached !== null && is_string($cached)) {
$cached = json_decode($cached, true);
if ($cached !== null) {
self::$pattern_cache[$pattern] = $cached;
return $cached;
}
}
// Extract parameter names
$param_names = [];
// First, handle our special syntax before escaping
// Replace :param and :param? with placeholders
$placeholder_index = 0;
$placeholders = [];
$temp_pattern = preg_replace_callback(
'/\/:([\\w]+)(\\?)?|:([\\w]+)(\\?)?/',
function ($matches) use (&$param_names, &$placeholder_index, &$placeholders) {
// Check if we matched with leading slash or without
$has_slash = str_starts_with($matches[0], '/');
if ($has_slash) {
// Matched /:param or /:param?
$param_name = $matches[1];
$is_optional = !empty($matches[2]);
} else {
// Matched :param or :param?
$param_name = $matches[3];
$is_optional = !empty($matches[4]);
}
$param_names[] = $param_name;
$placeholder = "__PARAM_{$placeholder_index}__";
if ($is_optional) {
// Optional parameter - the slash and parameter are both optional
// For /posts/:id? we want to match both /posts and /posts/123
$placeholders[$placeholder] = $has_slash ? '(?:/([^/]+))?' : '([^/]*)';
} else {
// Required parameter
$placeholders[$placeholder] = $has_slash ? '/([^/]+)' : '([^/]+)';
}
$placeholder_index++;
return $placeholder;
},
$pattern,
);
// Replace wildcards with placeholders
$wildcard_index = 0;
$temp_pattern = preg_replace_callback(
'/\*/',
function ($matches) use (&$param_names, &$wildcard_index, &$placeholders) {
$param_names[] = 'wildcard' . ($wildcard_index > 0 ? $wildcard_index : '');
$placeholder = "__WILDCARD_{$wildcard_index}__";
$placeholders[$placeholder] = '(.*)';
$wildcard_index++;
return $placeholder;
},
$temp_pattern,
);
// Now escape regex characters
$regex = preg_quote($temp_pattern, '#');
// Replace placeholders with regex patterns
foreach ($placeholders as $placeholder => $regex_pattern) {
$regex = str_replace($placeholder, $regex_pattern, $regex);
}
// Wrap in delimiters
$regex = '#^' . $regex . '$#';
$compiled = [
'regex' => $regex,
'params' => $param_names,
'pattern' => $pattern,
];
// Cache the compiled pattern
self::$pattern_cache[$pattern] = $compiled;
Build_Manager::put($cache_key, json_encode($compiled), 'cache');
return $compiled;
}
/**
* Extract parameters from regex matches
*
* @param array $matches Regex matches
* @param array $param_names Parameter names
* @return array Extracted parameters
*/
protected static function __extract_params($matches, $param_names)
{
$params = [];
// Skip first match (full string)
array_shift($matches);
// Map matches to parameter names
foreach ($param_names as $index => $name) {
if (isset($matches[$index])) {
$value = $matches[$index];
// URL decode the value
$value = urldecode($value);
// Don't include empty optional parameters
if ($value !== '') {
$params[$name] = $value;
}
}
}
return $params;
}
/**
* Normalize a URL for matching
*
* @param string $url
* @return string
*/
protected static function __normalize_url($url)
{
// Remove query string
$url = strtok($url, '?');
// Ensure leading slash
if (!str_starts_with($url, '/')) {
$url = '/' . $url;
}
// Remove trailing slash (except for root)
if ($url !== '/' && str_ends_with($url, '/')) {
$url = rtrim($url, '/');
}
return $url;
}
/**
* Normalize a route pattern
*
* @param string $pattern
* @return string
*/
protected static function __normalize_pattern($pattern)
{
// Ensure leading slash
if (!str_starts_with($pattern, '/')) {
$pattern = '/' . $pattern;
}
// Remove trailing slash (except for root)
if ($pattern !== '/' && str_ends_with($pattern, '/')) {
$pattern = rtrim($pattern, '/');
}
return $pattern;
}
/**
* Check if a URL matches any of the given patterns
*
* @param string $url URL to check
* @param array $patterns Array of patterns to match against
* @return array|false First matching result or false
*/
public static function match_any($url, $patterns)
{
foreach ($patterns as $pattern) {
$result = static::match($url, $pattern);
if ($result !== false) {
return ['pattern' => $pattern, 'params' => $result];
}
}
return false;
}
/**
* Clear pattern cache
*
* @param string|null $pattern Specific pattern to clear, or null for all
*/
public static function clear_cache($pattern = null)
{
if ($pattern !== null) {
unset(self::$pattern_cache[$pattern]);
// We can't delete individual files easily with BuildManager
// Just clear from memory cache
} else {
self::$pattern_cache = [];
// Clear all cached patterns by clearing the cache directory
// This is a bit aggressive but BuildManager doesn't have selective deletion
// In production, patterns are stable so this shouldn't be called often
}
}
/**
* Generate a URL from a pattern and parameters
*
* @param string $pattern Route pattern
* @param array $params Parameters to fill in
* @return string|null Generated URL or null if params don't match
*/
public static function generate($pattern, $params = [])
{
$url = $pattern;
// Replace named parameters
$url = preg_replace_callback(
'/:([\w]+)(\?)?/',
function ($matches) use (&$params) {
$param_name = $matches[1];
$is_optional = isset($matches[2]);
if (isset($params[$param_name])) {
$value = $params[$param_name];
unset($params[$param_name]);
return urlencode($value);
} elseif ($is_optional) {
return '';
} else {
// Required parameter missing
return ':' . $param_name;
}
},
$url,
);
// Check if all required params were replaced
if (strpos($url, ':') !== false) {
return null; // Still has unreplaced parameters
}
// Replace wildcards
if (strpos($url, '*') !== false) {
// Use 'wildcard' param if available
if (isset($params['wildcard'])) {
$url = str_replace('*', $params['wildcard'], $url);
unset($params['wildcard']);
} else {
$url = str_replace('*', '', $url);
}
}
// Normalize the URL first (before adding query string)
$url = static::__normalize_url($url);
// Add remaining params as query string
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $url;
}
/**
* Get priority score for a pattern (more specific = higher priority)
*
* @param string $pattern
* @return int Priority score
*/
public static function get_pattern_priority($pattern)
{
$score = 0;
// Exact matches have highest priority
if (!str_contains($pattern, ':') && !str_contains($pattern, '*')) {
$score += 1000;
}
// Count segments
$segments = explode('/', trim($pattern, '/'));
$score += count($segments) * 100;
// Penalize wildcards
$score -= substr_count($pattern, '*') * 50;
// Penalize optional parameters
$score -= substr_count($pattern, '?') * 10;
// Penalize required parameters
$score -= preg_match_all('/:([\w]+)(?!\?)/', $pattern) * 5;
return $score;
}
/**
* Sort patterns by priority (higher priority first)
*
* @param array $patterns
* @return array Sorted patterns
*/
public static function sort_by_priority($patterns)
{
$scored = [];
foreach ($patterns as $pattern) {
$scored[] = [
'pattern' => $pattern,
'score' => static::get_pattern_priority($pattern),
];
}
// Sort by score descending
usort($scored, function ($a, $b) {
return $b['score'] - $a['score'];
});
// Extract sorted patterns
return array_column($scored, 'pattern');
}
}