Three robustness improvements to the request entry point: P1a: Module runtime stale-detection via fingerprint - ModuleRuntimePageBuilder gains writeFingerprint/readFingerprint/expectedFingerprint - module-build.php writes storage/runtime/.module-fingerprint after each build - web/index.php validates fingerprint on every request; throws RuntimeException with actionable message if stale (replaces silent auto-build in request path) - Eliminates P2 (concurrency) by removing build from hot path entirely P1b: API requests skip session/auth/cookie flows - Channel detection (isApiRequest) moved before Session::start() - Session::start(), remember-me, session timeout, tenant refresh and locale resolution wrapped in if-not-API guard - API endpoints no longer set cookies or trigger login redirects - Default locale set separately for API so t() works in error responses P3: extract(Router::getParameters(), EXTR_SKIP) - Prevents URL parameters from overwriting existing scope variables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
5.6 KiB
PHP
174 lines
5.6 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\App\Module;
|
|
|
|
use FilesystemIterator;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
|
*/
|
|
final class ModuleRuntimePageBuilder
|
|
{
|
|
/**
|
|
* @param array<string, ModuleManifest> $modules
|
|
* @return array{core_entries: int, module_entries: int}
|
|
*/
|
|
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
|
|
{
|
|
$corePages = realpath($corePagesDir);
|
|
if ($corePages === false) {
|
|
throw new RuntimeException('Core pages/ directory not found.');
|
|
}
|
|
|
|
if (count($modules) === 0) {
|
|
$this->pointRuntimeToCore($runtimeDir, $corePages);
|
|
return ['core_entries' => 0, 'module_entries' => 0];
|
|
}
|
|
|
|
if (is_link($runtimeDir)) {
|
|
unlink($runtimeDir);
|
|
}
|
|
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
|
|
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
|
|
}
|
|
|
|
$this->removeDirectoryContents($runtimeDir);
|
|
|
|
$coreEntries = scandir($corePages);
|
|
if ($coreEntries === false) {
|
|
throw new RuntimeException('Cannot scan core pages directory.');
|
|
}
|
|
|
|
$coreCount = 0;
|
|
foreach ($coreEntries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
$source = $corePages . '/' . $entry;
|
|
$target = $runtimeDir . '/' . $entry;
|
|
if (!@symlink($source, $target)) {
|
|
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
|
}
|
|
$coreCount++;
|
|
}
|
|
|
|
$modulePageCount = 0;
|
|
foreach ($modules as $manifest) {
|
|
$modulePagesDir = $manifest->basePath . '/pages';
|
|
if (!is_dir($modulePagesDir)) {
|
|
continue;
|
|
}
|
|
$entries = scandir($modulePagesDir);
|
|
if ($entries === false) {
|
|
continue;
|
|
}
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
$source = $modulePagesDir . '/' . $entry;
|
|
$target = $runtimeDir . '/' . $entry;
|
|
if (file_exists($target) || is_link($target)) {
|
|
throw new RuntimeException(
|
|
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
|
);
|
|
}
|
|
if (!@symlink($source, $target)) {
|
|
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
|
}
|
|
$modulePageCount++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'core_entries' => $coreCount,
|
|
'module_entries' => $modulePageCount,
|
|
];
|
|
}
|
|
|
|
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
|
{
|
|
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
|
$this->removeDirectoryContents($runtimeDir);
|
|
rmdir($runtimeDir);
|
|
}
|
|
if (is_link($runtimeDir)) {
|
|
unlink($runtimeDir);
|
|
}
|
|
if (!@symlink($corePages, $runtimeDir)) {
|
|
throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
|
|
}
|
|
}
|
|
|
|
// ── Fingerprint: detect stale runtime state ─────────────────────
|
|
|
|
/**
|
|
* Compute the expected fingerprint for a set of modules.
|
|
*
|
|
* @param array<string, ModuleManifest> $modules
|
|
*/
|
|
public static function expectedFingerprint(array $modules): string
|
|
{
|
|
$ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules);
|
|
sort($ids);
|
|
|
|
return implode(',', $ids);
|
|
}
|
|
|
|
/**
|
|
* Read the persisted fingerprint from disk.
|
|
*/
|
|
public static function readFingerprint(string $runtimeDir): string
|
|
{
|
|
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
|
|
|
return is_file($file) ? trim((string) file_get_contents($file)) : '';
|
|
}
|
|
|
|
/**
|
|
* Write a fingerprint after a successful build so web/index.php can
|
|
* detect stale runtime state without rebuilding on every request.
|
|
*
|
|
* @param array<string, ModuleManifest> $modules
|
|
*/
|
|
public static function writeFingerprint(string $runtimeDir, array $modules): void
|
|
{
|
|
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
|
|
|
file_put_contents($file, self::expectedFingerprint($modules));
|
|
}
|
|
|
|
private function removeDirectoryContents(string $dir): void
|
|
{
|
|
$entries = scandir($dir);
|
|
if ($entries === false) {
|
|
return;
|
|
}
|
|
foreach ($entries as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
$path = $dir . '/' . $entry;
|
|
if (is_link($path)) {
|
|
unlink($path);
|
|
continue;
|
|
}
|
|
if (is_dir($path)) {
|
|
$items = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
|
RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
foreach ($items as $item) {
|
|
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
|
}
|
|
rmdir($path);
|
|
continue;
|
|
}
|
|
unlink($path);
|
|
}
|
|
}
|
|
}
|