Files
rspade_system/app/RSpade/Core/Dispatch/AssetHandler.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

745 lines
26 KiB
PHP

<?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 Illuminate\Support\Facades\Redis;
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);
return $response;
}
/**
* Find a public asset by relative path with Redis caching
*
* Resolves paths like "sneat/css/demo.css" to full filesystem paths like
* "rsx/public/sneat/css/demo.css" by scanning all public/ directories.
*
* Results are cached in Redis indefinitely. Cached paths are validated
* before use - if file no longer exists, cache is invalidated and re-scan occurs.
*
* @param string $relative_path Relative path like "sneat/css/demo.css"
* @return string Full filesystem path
* @throws \Symfony\Component\HttpKernel\Exception\HttpException If not found or ambiguous
*/
public static function find_public_asset(string $relative_path): string
{
// Ensure directories are discovered
static::__ensure_directories_discovered();
// Sanitize the path
$relative_path = static::__sanitize_path($relative_path);
// Check Redis cache first
$cache_key = 'rspade:public_asset:' . $relative_path;
$cached_path = Redis::get($cache_key);
if ($cached_path) {
// Verify cached file still exists
if (File::exists($cached_path) && File::isFile($cached_path)) {
return $cached_path;
}
// Stale cache - invalidate and re-scan
Redis::del($cache_key);
}
// NEVER serve PHP files under any circumstances
$extension = strtolower(pathinfo($relative_path, PATHINFO_EXTENSION));
if ($extension === 'php') {
throw new HttpException(403, 'PHP files cannot be served as static assets');
}
// Scan all public directories for matches
$matches = [];
foreach (static::$public_directories as $module => $directory) {
$full_path = $directory . '/' . $relative_path;
if (File::exists($full_path) && File::isFile($full_path)) {
// Check exclusion rules
if (static::__is_file_excluded($full_path, $relative_path)) {
throw new HttpException(403, 'Access to this file is forbidden');
}
$matches[] = $full_path;
}
}
// Check for ambiguous matches
if (count($matches) > 1) {
// Show first two matches in error
$first_two = array_slice($matches, 0, 2);
throw new HttpException(
500,
"Ambiguous public asset request: '{$relative_path}' matches multiple files: '" .
implode("', '", $first_two) . "'"
);
}
// Check for no matches
if (count($matches) === 0) {
throw new NotFoundHttpException("Public asset not found: {$relative_path}");
}
// Single match - cache and return
$resolved_path = $matches[0];
Redis::set($cache_key, $resolved_path);
return $resolved_path;
}
/**
* 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
*
* Wrapper around find_public_asset() that returns null instead of throwing
* NotFoundHttpException for backward compatibility with existing code.
*
* @param string $path
* @return string|null Full file path or null if not found
* @throws \Symfony\Component\HttpKernel\Exception\HttpException For PHP files, exclusions, or ambiguous matches
*/
protected static function __find_asset_file($path)
{
try {
return static::find_public_asset($path);
} catch (NotFoundHttpException $e) {
// Not found - return null for backward compatibility
return null;
}
// Let other exceptions (403, 500) bubble up
}
/**
* 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)
{
// @REALPATH-EXCEPTION - Security: path traversal prevention requires symlink resolution
$real_path = realpath($full_path);
if (!$real_path) {
return null;
}
// Find the public directory that contains this file
foreach (static::$public_directories as $directory) {
// @REALPATH-EXCEPTION - Security: path traversal prevention requires symlink resolution
$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)
{
// @REALPATH-EXCEPTION - Security: path traversal prevention requires symlink resolution
$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) {
// @REALPATH-EXCEPTION - Security: path traversal prevention requires symlink resolution
$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;
}
}
/**
* 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;
}
}