From aed1b3402ab3e34263ac2534c2c0e3f27cb40746 Mon Sep 17 00:00:00 2001 From: FusionPBX Date: Thu, 28 May 2026 00:30:37 -0600 Subject: [PATCH] Add the router for routing requests --- public/index.php | 571 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 public/index.php diff --git a/public/index.php b/public/index.php new file mode 100644 index 000000000..ed1de87b7 --- /dev/null +++ b/public/index.php @@ -0,0 +1,571 @@ + MAX_PATH_LENGTH) { + error_log('[SECURITY] Request path exceeds maximum length: ' . strlen($path)); + return '/'; + } + + // Remove script name prefix if present + $script_name = $_SERVER['SCRIPT_NAME'] ?? ''; + if (strpos($path, $script_name) === 0 && $script_name !== '/') { + $path = substr($path, strlen($script_name)); + } + + // Normalize path + $path = trim($path, '/') === '' ? '/' : '/' . trim($path, '/'); + + return $path; + } + + /** + * Validate HTTP method + * + * @return bool True if method is allowed + */ + private function validate_method(): bool { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + if (!in_array($method, ALLOWED_METHODS)) { + error_log("[SECURITY] Invalid HTTP method attempted: $method"); + return false; + } + return true; + } + + /** + * Sanitize and validate path components + * + * @param string $input The input string to validate + * @return bool True if safe, false otherwise + */ + private function is_safe_component(string $input): bool { + // Check for path traversal attempts + if (strpos($input, '..') !== false) { + return false; + } + + // Check for null bytes + if (strpos($input, "\0") !== false) { + return false; + } + return true; + } + + /** + * Route resolver for app, core, or modules requests + * + * @param string $request_path The request path (e.g., "/core/domains" or "/core/domains/domains.php") + * @return array|null Route result or null if not found + */ + private function resolve_route(string $request_path): ?array { + // Decode URL-encoded characters before validation + $decoded_path = rawurldecode($request_path); + + // Remove trailing slash (except for root) to handle URLs like /core/dashboard/ + $decoded_path = rtrim($decoded_path, '/'); + + // Set the script name + $script_name = $decoded_path; + + // Extract path details + $path_array = explode('/', $decoded_path); + $path_count = count($path_array); + $prefix_name = $path_array[1]; + $app_name = $path_array[2]; + $file_name = array_pop($path_array) ?? 'index.php'; + + // Initialize the variable + $target_file = null; + $file_path = null; + + // Prevent using an unsafe script_name (after URL decoding) + if (!$this->is_safe_component($script_name)) { + error_log("[SECURITY] Unsafe path detected: " . htmlspecialchars($script_name)); + return null; + } + + // Validate file_name if present + if ($file_name !== null && !$this->is_safe_component($file_name)) { + error_log("[SECURITY] Unsafe file_name detected: " . htmlspecialchars($file_name)); + return null; + } + + // Calculate singular form for app_name + $app_name_singular = database::singular($app_name ?? ''); + + // Determine the target file based on routing rules + if (empty($app_name)) { + // Fallback for missing app_name + $file_path = $script_name; + } + if (!empty($file_name) && $app_name == 'provision') { + // App name equals file name (e.g., /app/extensions/extensions -> extensions_list.php) + $action_name = 'list'; + $file_path = $prefix_name . '/' . $app_name . '/' . $app_name . '.php'; + view_array($path_array); + } + elseif (!empty($file_name) && $app_name == $file_name) { + // App name equals file name (e.g., /app/extensions/extensions -> extensions_list.php) + $action_name = 'list'; + $file_path = $prefix_name . '/' . $app_name . '/' . $app_name . '.php'; + } + elseif ($path_count <= 3) { + // Set the default index in 2 directories - core/dashboard and others + $action_name = 'list'; + $file_path = $prefix_name . '/' . $app_name . '/index.php'; + } + elseif (!empty($file_name) && ($file_name == 'edit' || $file_name == $app_name_singular . '_edit')) { + // Edit action (e.g., /app/extensions/edit -> extension_edit.php + $action_name = 'edit'; + $file_path = $prefix_name . '/' . $app_name . '/' . $app_name_singular . '_edit.php'; + } + elseif (!empty($file_name) && ($file_name == 'delete' || $file_name == $app_name_singular . '_delete')) { + // Delete action (e.g., /app/extensions/extension_delete -> extension_delete.php) + $action_name = 'delete'; + $file_path = $prefix_name . '/' . $app_name . '/' . $app_name_singular . '_delete.php'; + } + elseif (!empty($file_name) && $file_name == 'index.php') { + // Index file + $action_name = 'index'; + $file_path = $prefix_name . '/' . $app_name . '/' . 'index.php'; + } + else { + // Other files (images, videos, css, etc.) + $file_path = $script_name; + } + + // Verify and set the target file with strict path validation + if (!empty($file_path)) { + // Double-check for path traversal + if (strpos($file_path, '..') === false) { + $full_path = PROJECT_ROOT . '/' . $file_path; + if (file_exists($full_path) && is_file($full_path)) { + $resolved_path = realpath($full_path); + // Ensure the resolved path is within the app directory + if ($resolved_path !== null && strpos($resolved_path, realpath(PROJECT_ROOT)) === 0) { + $target_file = $resolved_path; + } + } + } + } + + // No target_file found - return null for 404 + if ($target_file === null || !file_exists($target_file)) { + http_response_code(405); + return null; + } + + // Return an array of values + return [ + 'target' => $target_file, + 'app_name' => $app_name, + 'action' => $action_name, + 'file' => $file_path ?? 'index.php', + ]; + } + + /** + * Main router - resolves any route + * + * @param string $request_path The request path + * @return array|null Route result or null (triggers 404 or default) + */ + public function route(string $request_path): ?array { + // Validate HTTP method first + if (!$this->validate_method()) { + http_response_code(405); + echo '405 - Method Not Allowed'; + exit; + } + + // Check for routes starting with /app/, /core/, or /modules/ + try { + return $this->resolve_route($request_path); + } catch (\Exception $e) { + error_log("Routing error: " . $e->getMessage()); + return null; + } + + return null; + } + + /** + * Follow the resolved route + * + * @param array $route The route array from route() + */ + public function follow_route(array $route): void { + global $config, $database, $settings; + + // Set the target file + $target_file = $route['target']; + + // Send 404 not found if the file doesn't exist + if (!file_exists($target_file)) { + http_response_code(404); + echo '404 - Not Found ' . __line__ . ' ' . $target_file; + exit; + } + + // Set the file extension + $file_ext = strtolower(pathinfo($target_file, PATHINFO_EXTENSION) ?? 'php'); + + // Define MIME types for common files + $mime_types = [ + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/png', + 'js' => 'application/javascript', + 'css' => 'text/css', + 'json' => 'application/json', + 'txt' => 'text/plain', + 'csv' => 'text/csv', + 'pdf' => 'application/pdf', + 'xml' => 'application/xml', + 'html' => 'text/html', + 'map' => 'application/json', + 'txt' => 'text/plain', + 'woff2' => 'font/woff2', + 'ttf' => 'font/sfnt', + 'htm' => 'text/html', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogg' => 'video/ogg', + ]; + + // Detect the MIME type using finfo + $finfo = new finfo(FILEINFO_MIME_TYPE); + $detected_mime = $finfo->file($target_file); + + // Override detected mime type for javascript + if ($detected_mime == 'text/plain' && $file_ext == 'js') { + $detected_mime = 'application/javascript'; + } + if ($detected_mime == 'text/html' && $file_ext == 'js') { + $detected_mime = 'application/javascript'; + } + if ($detected_mime == 'text/x-c++' && $file_ext == 'js') { + $detected_mime = 'application/javascript'; + } + + // Validate static asset MIME types (PHP files are processed, not served) + if ($file_ext !== 'php' && !isset($mime_types[$file_ext])) { + error_log("[SECURITY] Unsupported file type attempted: file_ext $file_ext ($detected_mime)"); + http_response_code(403); + echo '403 - Forbidden'; + exit; + } + + // Additional MIME type verification for static assets + // if ($file_ext !== 'php' && $detected_mime !== $mime_types[$file_ext]) { + // error_log("[SECURITY] MIME type mismatch: target_file $target_file file_ext $file_ext expected {$mime_types[$file_ext]}, got $detected_mime"); + // http_response_code(404); + // echo '404 - Not Found ' . __line__ . ' ' . $detected_mime; + // exit; + // } + + // Set SCRIPT_FILENAME for proper app context detection + // This allows the text class to auto-detect the app path + if (!isset($_SERVER['SCRIPT_FILENAME'])) { + $_SERVER['SCRIPT_FILENAME'] = $target_file; + } + + // Set security headers + //$this->set_security_headers($file_ext); + + // Set the headers and stream the file (for static assets) + if ($file_ext !== 'php') { + header("Content-Type: {$mime_types[$file_ext]}"); + header('Content-Length: ' . filesize($target_file)); + + // Cache headers for static assets + header('Cache-Control: public, max-age=31536000, immutable'); + header('Expires: ' . gmdate('D, d M Y H:i:s', strtotime('+1 year')) . ' GMT'); + + readfile($target_file); + exit; + } + + // Load the PHP target file + if ($file_ext === 'php') { + // Include the target file + include $target_file; + } + } + + /** + * Set security headers + * + * @param string $file_ext The file extension + */ + private function set_security_headers(string $file_ext): void { + // X-Frame-Options: Prevent clickjacking + header('X-Frame-Options: SAMEORIGIN'); + + // X-Content-Type-Options: Prevent MIME type sniffing + header('X-Content-Type-Options: nosniff'); + + // X-XSS-Protection: Legacy XSS filter (deprecated but still useful for older browsers) + header('X-XSS-Protection: 1; mode=block'); + + // Referrer-Policy: Control referrer information + header('Referrer-Policy: strict-origin-when-cross-origin'); + + // Permissions-Policy (formerly Feature-Policy) + header('Permissions-Policy: geolocation=(), microphone=(), camera=()'); + + /** + * Content Security Policy + * + * Note: This is a restrictive default. Adjust based on your application's needs. + * Common adjustments: + * - Add 'unsafe-inline' for inline scripts/styles (not recommended) + * - Add external domains for CDNs, analytics, etc. + * - Add nonce or hash values for specific inline scripts + */ + $csp = [ + 'default-src' => "'self'", + 'script-src' => "'self' 'unsafe-inline'", + 'style-src' => "'self' 'unsafe-inline'", + 'img-src' => "'self' data: https:", + 'font-src' => "'self' data:", + 'connect-src' => "'self'", + 'frame-src' => "'self'", + 'object-src' => "'none'", + 'base-uri' => "'self'", + 'form-action' => "'self'", + ]; + + // Build CSP header string + $csp_header = implode('; ', array_map(function ($key, $value) { + return "$key $value"; + }, array_keys($csp), $csp)); + + header("Content-Security-Policy: $csp_header"); + + // Cache-Control for dynamic content + if ($file_ext === 'php') { + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + } + } + + /** + * Handle 404 Not Found + */ + public function handle_404(): void { + http_response_code(404); + header('Content-Type: text/html; charset=utf-8'); + + echo ' + + + + + + 404 - Not Found | FusionPBX + + + + +
+

404

+

Not Found

+

The requested resource was not found on this server.

+ Return to Home +
+ + + '; + } + + /** + * Handle health check endpoint + * + * @return void + */ + public function handle_health_check(): string { + http_response_code(200); + header('Content-Type: application/json'); + + $health_data = [ + 'status' => 'healthy', + 'timestamp' => time(), + //'uptime' => uptime() ?? 0, + //'memory_usage' => memory_get_usage(true), + //'php_version' => PHP_VERSION, + ]; + + // Check database connectivity if possible + if (isset($database) && $database instanceof database) { + try { + $health_data['database'] = 'connected'; + } catch (\Exception $e) { + $health_data['database'] = 'disconnected'; + $health_data['status'] = 'degraded'; + } + } + + return json_encode($health_data, JSON_PRETTY_PRINT); + } +} + +/** + * Main routing logic with error handling + */ +try { + // Create router instance + $router = new router(); + + // Get the clean request path + $request_path = $router->get_request_path(); + + // Health check endpoint (for load balancers and monitoring) + if ($request_path === '/health' || $request_path === '/healthz') { + echo $router->handle_health_check(); + } else { + // Attempt to resolve the route + $resolved_route = $router->route($request_path); + if ($resolved_route !== null) { + // Route found - follow + $router->follow_route($resolved_route); + } elseif ($request_path === '/' || $request_path === '/public' || $request_path === '/public/') { + // Root path - load main index + include PROJECT_ROOT . '/index.php'; + } else { + // No route found - handle 404 + $router->handle_404(); + } + } +} catch (\Throwable $e) { + // Catch-all error handler + error_log("Routing error: " . $e->getMessage()); + error_log("Stack trace: " . $e->getTraceAsString()); + + // In production, show generic error; in debug mode, show details + if (defined('DEBUG') && DEBUG) { + http_response_code(500); + echo '
';
+		echo 'Fatal Error: ' . htmlspecialchars($e->getMessage()) . "\n";
+		echo 'File: ' . htmlspecialchars($e->getFile()) . ':' . $e->getLine() . "\n\n";
+		echo 'Stack Trace:' . "\n";
+		echo htmlspecialchars($e->getTraceAsString());
+		echo '
'; + } else { + $router->handle_404(); + } +}