From 94aea8501ff8fe27c65dd51ed24114fd5a01a152 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Sat, 4 Jul 2026 19:54:54 -0400 Subject: [PATCH] - Fixed the subdirectory loading issue. Changed: [app/View.php](/Users/tyemeclifford/Documents/GH/blog/app/View.php): generated URLs now honor a base path like /blog. [app/Config.php](/Users/tyemeclifford/Documents/GH/blog/app/Config.php): added base_path support and derives it from base_url or SCRIPT_NAME. [app/App.php](/Users/tyemeclifford/Documents/GH/blog/app/App.php): route parsing strips the base path, and PHP can safely serve theme/upload assets for virtual subdirectory previews. [.htaccess](/Users/tyemeclifford/Documents/GH/blog/.htaccess): removed hardcoded RewriteBase /. [themes/neon/layout.php](/Users/tyemeclifford/Documents/GH/blog/themes/neon/layout.php): root-relative social links like /feed.xml now become /blog/feed.xml. [README.md](/Users/tyemeclifford/Documents/GH/blog/README.md): documented subdirectory setup. --- .htaccess | 1 - README.md | 11 ++++++++ app/App.php | 58 +++++++++++++++++++++++++++++++++++++++++- app/Config.php | 41 +++++++++++++++++++++++++++++ app/View.php | 15 ++++++++++- app/bootstrap.php | 10 +++++++- config/site.json | 1 + themes/neon/layout.php | 8 +++++- 8 files changed, 140 insertions(+), 5 deletions(-) diff --git a/.htaccess b/.htaccess index e5bd564..e1c37fb 100644 --- a/.htaccess +++ b/.htaccess @@ -2,7 +2,6 @@ Options -Indexes RewriteEngine On - RewriteBase / RewriteRule ^(app|cli|config|storage)(/|$) - [F,L] RewriteRule ^bl-content/(databases|pages|tmp)(/|$) - [F,L] diff --git a/README.md b/README.md index 1355aa4..c889543 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,14 @@ php cli/blog.php stats:summary ## Apache The included `.htaccess` enables pretty URLs and blocks direct access to app code, configuration, SQLite, CSV, JSON databases, and Markdown content files. + +For a subdirectory install such as `https://example.com/blog`, either place the app in that folder and let the CMS auto-detect the path, or set: + +```json +{ + "base_url": "https://example.com/blog", + "base_path": "/blog" +} +``` + +`base_path` controls generated links, assets, forms, RSS, and sitemap paths. diff --git a/app/App.php b/app/App.php index 6b321da..5011775 100644 --- a/app/App.php +++ b/app/App.php @@ -18,6 +18,7 @@ final class App $this->repository = $repository; $this->comments = $comments; $this->stats = $stats; + View::setBasePath($this->config->basePath()); } public function handle(): void @@ -66,6 +67,10 @@ final class App private function route(string $path): void { + if ($this->servePublicFile($path)) { + return; + } + if ($path === '' || $path === 'home') { $this->home(1); return; @@ -297,6 +302,54 @@ final class App echo $body; } + private function servePublicFile(string $path): bool + { + if (!preg_match('#^(themes/[a-z0-9_-]+/assets/.+|bl-content/uploads/.+)$#', $path)) { + return false; + } + + $file = realpath(BLOG_ROOT . '/' . $path); + $allowedRoots = [ + realpath(BLOG_ROOT . '/themes'), + realpath(BLOG_ROOT . '/bl-content/uploads'), + ]; + $insideAllowedRoot = false; + foreach ($allowedRoots as $root) { + if (is_string($root) && is_string($file) && str_starts_with($file, $root . DIRECTORY_SEPARATOR)) { + $insideAllowedRoot = true; + break; + } + } + if (!$insideAllowedRoot || !is_file((string) $file)) { + return false; + } + + $extension = strtolower(pathinfo((string) $file, PATHINFO_EXTENSION)); + $types = [ + 'css' => 'text/css; charset=utf-8', + 'js' => 'application/javascript; charset=utf-8', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + ]; + + http_response_code(200); + header('Content-Type: ' . ($types[$extension] ?? 'application/octet-stream')); + header('Content-Length: ' . filesize((string) $file)); + header('Cache-Control: public, max-age=3600'); + $this->stats->record('asset', $path, 200); + readfile((string) $file); + + return true; + } + private function path(): string { $route = (string) ($_GET['route'] ?? ''); @@ -306,7 +359,10 @@ final class App $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? ''))); $route = '/' . ltrim($route, '/'); - if ($scriptDir !== '/' && str_starts_with($route, $scriptDir . '/')) { + $basePath = $this->config->basePath(); + if ($basePath !== '' && ($route === $basePath || str_starts_with($route, $basePath . '/'))) { + $route = substr($route, strlen($basePath)); + } elseif ($scriptDir !== '/' && str_starts_with($route, $scriptDir . '/')) { $route = substr($route, strlen($scriptDir)); } diff --git a/app/Config.php b/app/Config.php index 8aeddfc..817549a 100644 --- a/app/Config.php +++ b/app/Config.php @@ -81,6 +81,12 @@ final class Config { $configured = trim((string) $this->get('base_url', '')); if ($configured !== '') { + $parts = parse_url($configured); + if (is_array($parts) && isset($parts['scheme'], $parts['host'])) { + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + return $parts['scheme'] . '://' . $parts['host'] . $port; + } + return rtrim($configured, '/'); } @@ -96,6 +102,34 @@ final class Config return $scheme . '://' . $host; } + public function basePath(): string + { + $configured = trim((string) $this->get('base_path', '')); + if ($configured !== '') { + return self::normalizeBasePath($configured); + } + + $baseUrl = trim((string) $this->get('base_url', '')); + if ($baseUrl !== '') { + $path = parse_url($baseUrl, PHP_URL_PATH); + if (is_string($path) && $path !== '') { + return self::normalizeBasePath($path); + } + } + + if (PHP_SAPI === 'cli') { + return ''; + } + + $scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? '')); + $scriptDir = dirname($scriptName); + if ($scriptDir === '.' || $scriptDir === '/') { + return ''; + } + + return self::normalizeBasePath($scriptDir); + } + /** @return array */ private static function defaults(): array { @@ -108,6 +142,7 @@ final class Config 'language' => 'en', ], 'base_url' => '', + 'base_path' => '', 'timezone' => 'UTC', 'theme' => 'neon', 'posts_per_page' => 6, @@ -124,4 +159,10 @@ final class Config 'social' => [], ]; } + + private static function normalizeBasePath(string $path): string + { + $path = '/' . trim($path, '/'); + return $path === '/' ? '' : $path; + } } diff --git a/app/View.php b/app/View.php index 88e9e30..7d3f40c 100644 --- a/app/View.php +++ b/app/View.php @@ -5,6 +5,14 @@ namespace NeonBlog; final class View { + private static string $basePath = ''; + + public static function setBasePath(string $basePath): void + { + $basePath = '/' . trim($basePath, '/'); + self::$basePath = $basePath === '/' ? '' : $basePath; + } + public static function e(mixed $value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); @@ -18,7 +26,12 @@ final class View $path = rtrim($path, '/'); } - return $query === [] ? $path : $path . '?' . http_build_query($query); + $url = self::$basePath . $path; + if ($url === '') { + $url = '/'; + } + + return $query === [] ? $url : $url . '?' . http_build_query($query); } /** @param array $item */ diff --git a/app/bootstrap.php b/app/bootstrap.php index 4efd38e..aa9da0b 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -23,7 +23,15 @@ spl_autoload_register(static function (string $class): void { } }); -if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) { +$isPublicAssetRequest = static function (): bool { + $path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); + $route = (string) ($_GET['route'] ?? ''); + + return (bool) preg_match('#/(themes/[a-z0-9_-]+/assets/.+|bl-content/uploads/.+)$#', $path) + || (bool) preg_match('#^(themes/[a-z0-9_-]+/assets/.+|bl-content/uploads/.+)$#', $route); +}; + +if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE && !$isPublicAssetRequest()) { session_start([ 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', diff --git a/config/site.json b/config/site.json index 6fd2400..c5dd670 100644 --- a/config/site.json +++ b/config/site.json @@ -7,6 +7,7 @@ "language": "en" }, "base_url": "", + "base_path": "", "timezone": "America/New_York", "theme": "neon", "posts_per_page": 4, diff --git a/themes/neon/layout.php b/themes/neon/layout.php index 3bce921..fef1c12 100644 --- a/themes/neon/layout.php +++ b/themes/neon/layout.php @@ -179,7 +179,13 @@ $renderPagination = static function (array $pagination, callable $url): void { ?

Social