- 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.
This commit is contained in:
Ty Clifford
2026-07-04 19:54:54 -04:00
parent 782b983bf4
commit 94aea8501f
8 changed files with 140 additions and 5 deletions
-1
View File
@@ -2,7 +2,6 @@ Options -Indexes
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
RewriteBase /
RewriteRule ^(app|cli|config|storage)(/|$) - [F,L] RewriteRule ^(app|cli|config|storage)(/|$) - [F,L]
RewriteRule ^bl-content/(databases|pages|tmp)(/|$) - [F,L] RewriteRule ^bl-content/(databases|pages|tmp)(/|$) - [F,L]
+11
View File
@@ -53,3 +53,14 @@ php cli/blog.php stats:summary
## Apache ## Apache
The included `.htaccess` enables pretty URLs and blocks direct access to app code, configuration, SQLite, CSV, JSON databases, and Markdown content files. 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.
+57 -1
View File
@@ -18,6 +18,7 @@ final class App
$this->repository = $repository; $this->repository = $repository;
$this->comments = $comments; $this->comments = $comments;
$this->stats = $stats; $this->stats = $stats;
View::setBasePath($this->config->basePath());
} }
public function handle(): void public function handle(): void
@@ -66,6 +67,10 @@ final class App
private function route(string $path): void private function route(string $path): void
{ {
if ($this->servePublicFile($path)) {
return;
}
if ($path === '' || $path === 'home') { if ($path === '' || $path === 'home') {
$this->home(1); $this->home(1);
return; return;
@@ -297,6 +302,54 @@ final class App
echo $body; 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 private function path(): string
{ {
$route = (string) ($_GET['route'] ?? ''); $route = (string) ($_GET['route'] ?? '');
@@ -306,7 +359,10 @@ final class App
$scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? ''))); $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '')));
$route = '/' . ltrim($route, '/'); $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)); $route = substr($route, strlen($scriptDir));
} }
+41
View File
@@ -81,6 +81,12 @@ final class Config
{ {
$configured = trim((string) $this->get('base_url', '')); $configured = trim((string) $this->get('base_url', ''));
if ($configured !== '') { 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, '/'); return rtrim($configured, '/');
} }
@@ -96,6 +102,34 @@ final class Config
return $scheme . '://' . $host; 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<string, mixed> */ /** @return array<string, mixed> */
private static function defaults(): array private static function defaults(): array
{ {
@@ -108,6 +142,7 @@ final class Config
'language' => 'en', 'language' => 'en',
], ],
'base_url' => '', 'base_url' => '',
'base_path' => '',
'timezone' => 'UTC', 'timezone' => 'UTC',
'theme' => 'neon', 'theme' => 'neon',
'posts_per_page' => 6, 'posts_per_page' => 6,
@@ -124,4 +159,10 @@ final class Config
'social' => [], 'social' => [],
]; ];
} }
private static function normalizeBasePath(string $path): string
{
$path = '/' . trim($path, '/');
return $path === '/' ? '' : $path;
}
} }
+14 -1
View File
@@ -5,6 +5,14 @@ namespace NeonBlog;
final class View 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 public static function e(mixed $value): string
{ {
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
@@ -18,7 +26,12 @@ final class View
$path = rtrim($path, '/'); $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<string, mixed> $item */ /** @param array<string, mixed> $item */
+9 -1
View File
@@ -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([ session_start([
'cookie_httponly' => true, 'cookie_httponly' => true,
'cookie_samesite' => 'Lax', 'cookie_samesite' => 'Lax',
+1
View File
@@ -7,6 +7,7 @@
"language": "en" "language": "en"
}, },
"base_url": "", "base_url": "",
"base_path": "",
"timezone": "America/New_York", "timezone": "America/New_York",
"theme": "neon", "theme": "neon",
"posts_per_page": 4, "posts_per_page": 4,
+7 -1
View File
@@ -179,7 +179,13 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<h2>Social</h2> <h2>Social</h2>
<div class="social-list"> <div class="social-list">
<?php foreach ($social as $network => $entry): ?> <?php foreach ($social as $network => $entry): ?>
<a href="<?= View::e($entry['url'] ?? '#') ?>" rel="me noopener" target="_blank"><?= View::e($entry['label'] ?? $network) ?></a> <?php
$socialUrl = (string) ($entry['url'] ?? '#');
if (str_starts_with($socialUrl, '/') && !str_starts_with($socialUrl, '//')) {
$socialUrl = View::url(ltrim($socialUrl, '/'));
}
?>
<a href="<?= View::e($socialUrl) ?>" rel="me noopener" target="_blank"><?= View::e($entry['label'] ?? $network) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</section> </section>