Files
rspade_system/app/RSpade/Core/Dispatch/AssetHandler.php
root 37a6183eb4 Fix code quality violations and add VS Code extension features
Fix VS Code extension storage paths for new directory structure
Fix jqhtml compiled files missing from bundle
Fix bundle babel transformation and add rsxrealpath() function

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 00:43:05 +00:00

726 lines
24 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 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)
{
// @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;
}
}
/**
* 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;
}
}