diff --git a/boot/Editor/EditorRouter.php b/boot/Editor/EditorRouter.php index 4ab749e..acad2dc 100644 --- a/boot/Editor/EditorRouter.php +++ b/boot/Editor/EditorRouter.php @@ -9,6 +9,7 @@ use League\Container\Container; use Scriptor\Boot\Editor\Auth\AuthModule; use Scriptor\Boot\Editor\Auth\LoginAttempts; +use Scriptor\Boot\Editor\Install\InstallModule; use Scriptor\Boot\Editor\Pages\PagesModule; use Scriptor\Boot\Editor\Profile\ProfileModule; use Scriptor\Boot\Editor\Settings\SettingsModule; @@ -33,9 +34,8 @@ */ final class EditorRouter { - private const PLACEHOLDER_MODULES = [ - 'install' => '14c-5', - ]; + /** @var array Module slug → "phase pending" placeholder map. */ + private const PLACEHOLDER_MODULES = []; public function __construct( private readonly Editor $editor, @@ -75,6 +75,11 @@ public function execute(): void return; } + if ($first === 'install') { + (new InstallModule($this->editor, dirname(__DIR__, 2)))->execute(); + return; + } + if (isset(self::PLACEHOLDER_MODULES[$first])) { $this->renderPlaceholder($first, self::PLACEHOLDER_MODULES[$first]); return; @@ -129,8 +134,7 @@ private function renderDashboard(): void $this->editor->pageTitle = 'Dashboard - Scriptor'; $this->editor->pageContent = '

' . htmlspecialchars($this->editor->i18n['dashboard_menu'] ?? 'Dashboard', \ENT_QUOTES) . '

' - . '

iManager 2.0 editor — Phase 14c-1 (auth) is live. ' - . 'Other admin modules come back online with their own sub-phase.

' + . '

iManager 2.0 editor — pick a module from the sidebar.

' . $this->placeholderModuleList(); } @@ -140,8 +144,7 @@ private function renderPlaceholder(string $module, string $phase): void $this->editor->pageContent = '

' . htmlspecialchars(ucfirst($module), \ENT_QUOTES) . '

' . '

The ' . htmlspecialchars($module, \ENT_QUOTES) . ' module ' - . 'will be reattached in phase ' . htmlspecialchars($phase, \ENT_QUOTES) . '.

' - . $this->placeholderModuleList(); + . 'will be reattached in phase ' . htmlspecialchars($phase, \ENT_QUOTES) . '.

'; } private function renderUnknownModule(string $module): void @@ -155,6 +158,9 @@ private function renderUnknownModule(string $module): void private function placeholderModuleList(): string { + if (self::PLACEHOLDER_MODULES === []) { + return ''; + } $items = ''; foreach (self::PLACEHOLDER_MODULES as $slug => $phase) { $items .= \sprintf( @@ -163,7 +169,7 @@ private function placeholderModuleList(): string htmlspecialchars($phase, \ENT_QUOTES), ); } - return '

Phase 14c roadmap

'; + return '

Pending sub-phases

'; } private function redirect(string $url): never diff --git a/boot/Editor/Install/InstallModule.php b/boot/Editor/Install/InstallModule.php new file mode 100644 index 0000000..1f922d8 --- /dev/null +++ b/boot/Editor/Install/InstallModule.php @@ -0,0 +1,351 @@ +/.php` is expected to + * expose `public static function moduleInfo(): array`. Modules that + * don't are skipped silently. + */ +final class InstallModule +{ + private const DEFAULT_CUSTOM_CONFIG = [ + 'modules' => [], + 'hooks' => [], + ]; + + /** @var list Keys merged into the persisted module entry. */ + private const ENTRY_KEYS = [ + 'name', + 'position', + 'menu', + 'display_type', + 'icon', + 'active', + 'auth', + 'autoinit', + 'path', + 'class', + 'version', + 'description', + 'author', + 'author_website', + 'author_email_address', + ]; + + private string $customConfigPath; + private string $backupDir; + + public function __construct( + private readonly Editor $editor, + private readonly string $scriptorRoot, + ) { + $this->customConfigPath = $this->scriptorRoot . '/data/settings/custom.scriptor-config.php'; + $this->backupDir = $this->scriptorRoot . '/data/backups/configs'; + } + + public function execute(): void + { + $sub = $this->editor->urlSegments->get(1); + $action = $this->editor->input->getString('action'); + + if ($sub !== null && \in_array($action, ['install', 'uninstall'], true)) { + $this->writeAction($sub, $action); + return; + } + + $this->renderList(); + } + + private function writeAction(string $moduleName, string $action): void + { + if (! $this->csrfPasses($this->editor->input->getString('tokenName'), $this->editor->input->getString('tokenValue'))) { + $this->editor->flashMsg('error', $this->t('error_csrf_token_mismatch')); + $this->redirect($this->editor->siteUrl . '/install/'); + } + + $module = $this->findModule($moduleName); + if ($module === null) { + $this->editor->flashMsg('error', $this->t('install_module_name_not_found')); + $this->redirect($this->editor->siteUrl . '/install/'); + } + + $config = $this->getCustomConfig(); + $this->createBackup(); + + if ($action === 'install') { + $config['modules'][$moduleName] = $this->prepareEntry($module); + $this->writeConfig($config); + $this->editor->flashMsg('success', $this->fillVars( + $this->t('install_backup_message'), + ['module_name' => $moduleName, 'custom_config_path' => $this->customConfigPath], + )); + } else { + unset($config['modules'][$moduleName]); + $this->writeConfig($config); + $this->editor->flashMsg('success', $this->fillVars( + $this->t('uninstall_module_successful'), + ['module_name' => $moduleName], + )); + } + + $this->redirect($this->editor->siteUrl . '/install/'); + } + + private function renderList(): void + { + $modules = $this->getModuleList(); + $config = $this->getCustomConfig(); + $token = $this->editor->csrf->token('install'); + + $this->editor->pageTitle = 'Module Installation - Scriptor'; + $this->editor->breadcrumbs = sprintf( + '
  • %s
  • ', + htmlspecialchars($this->t('install_menu') ?: 'Modules', \ENT_QUOTES), + ); + + $i = static fn(string $s): string => htmlspecialchars($s, \ENT_QUOTES); + $rows = ''; + foreach ($modules as $module) { + $name = (string) ($module['name'] ?? ''); + if ($name === '') { + continue; + } + $installed = isset($config['modules'][$name]); + $cls = $installed ? 'active' : 'inactive'; + $href = $this->editor->siteUrl . '/install/' . rawurlencode($name) + . '?action=' . ($installed ? 'uninstall' : 'install') + . '&tokenName=install&tokenValue=' . rawurlencode($token); + $btn = $installed + ? ' ' + . $i($this->t('uninstall_button') ?: 'Uninstall') . '' + : ' ' + . $i($this->t('install_button') ?: 'Install') . ''; + $rows .= sprintf( + '%s (%s)%s%s', + $cls, + $i($name), + $i((string) ($module['version'] ?? '')), + $i((string) ($module['description'] ?? '')), + $btn, + ); + } + + $body = $rows !== '' + ? $this->t('install_info_text') + : $this->t('install_no_modules_found'); + + $this->editor->pageContent = + '

    ' . $i($this->t('install_module_list_header') ?: 'Module Manager') . '

    ' + . '

    ' . $body . '

    ' + . '' + . '' + . '' + . '' + . '' . $rows . '
    ' . $i($this->t('install_table_column_name') ?: 'Name') . '' . $i($this->t('install_table_column_description') ?: 'Description') . '' . $i($this->t('install_table_column_action') ?: 'Action') . '
    '; + } + + /** + * @return list> + */ + private function getModuleList(): array + { + $base = $this->scriptorRoot . '/site/modules'; + if (! is_dir($base)) { + return []; + } + $entries = []; + $dirs = glob($base . '/*', \GLOB_ONLYDIR) ?: []; + foreach ($dirs as $dir) { + $name = basename($dir); + $file = $dir . '/' . $name . '.php'; + if (! is_file($file)) { + continue; + } + $info = self::loadModuleInfo($file); + if ($info === null) { + continue; + } + $entries[] = $info; + } + usort( + $entries, + static fn(array $a, array $b): int => ((int) ($a['position'] ?? 0)) <=> ((int) ($b['position'] ?? 0)), + ); + return $entries; + } + + private function findModule(string $name): ?array + { + foreach ($this->getModuleList() as $module) { + if (($module['name'] ?? null) === $name) { + return $module; + } + } + return null; + } + + /** + * @param array $module + * @return array + */ + private function prepareEntry(array $module): array + { + $entry = []; + foreach (self::ENTRY_KEYS as $key) { + if (\array_key_exists($key, $module)) { + $entry[$key] = $module[$key]; + } + } + return $entry; + } + + /** + * Loads `.php`, looks up the namespaced class, calls its + * static `moduleInfo()` method. Returns null when the file doesn't + * declare the expected class or `moduleInfo()`. + * + * @return array|null + */ + private static function loadModuleInfo(string $file): ?array + { + $namespace = self::extractNamespace($file); + $class = basename($file, '.php'); + $fqn = $namespace !== '' ? $namespace . '\\' . $class : $class; + + require_once $file; + + if (! class_exists($fqn) || ! method_exists($fqn, 'moduleInfo')) { + return null; + } + $info = $fqn::moduleInfo(); + if (! \is_array($info)) { + return null; + } + // Default `name` and `class` so callers don't need to repeat them. + $info['name'] = (string) ($info['name'] ?? $class); + $info['class'] = (string) ($info['class'] ?? $fqn); + return $info; + } + + private static function extractNamespace(string $file): string + { + $src = (string) @file_get_contents($file); + if (preg_match('/^\s*namespace\s+([^;{\s]+)\s*[;{]/m', $src, $m) === 1) { + return trim($m[1], '\\'); + } + return ''; + } + + /** + * @return array{modules: array, hooks: array} + */ + private function getCustomConfig(): array + { + if (! is_file($this->customConfigPath)) { + return self::DEFAULT_CUSTOM_CONFIG; + } + $loaded = include $this->customConfigPath; + if (! \is_array($loaded)) { + return self::DEFAULT_CUSTOM_CONFIG; + } + /** @var array{modules?: array, hooks?: array} $loaded */ + return [ + 'modules' => $loaded['modules'] ?? [], + 'hooks' => $loaded['hooks'] ?? [], + ]; + } + + /** + * @param array $config + */ + private function writeConfig(array $config): void + { + $body = "customConfigPath, $body); + } + + private function createBackup(): bool + { + if (! is_file($this->customConfigPath)) { + return false; + } + $maxFiles = (int) ($this->editor->config['maxConfigBackupFiles'] ?? 0); + if ($maxFiles === 0) { + return false; + } + if (! is_dir($this->backupDir) && ! @mkdir($this->backupDir, 0o755, true) && ! is_dir($this->backupDir)) { + return false; + } + $existing = glob($this->backupDir . '/*_custom.scriptor-config.php.backup') ?: []; + if (\count($existing) >= $maxFiles) { + usort($existing, static fn(string $a, string $b): int => filemtime($a) <=> filemtime($b)); + foreach (\array_slice($existing, 0, \count($existing) - ($maxFiles - 1)) as $stale) { + @unlink($stale); + } + } + return @copy( + $this->customConfigPath, + $this->backupDir . '/' . time() . '_custom.scriptor-config.php.backup', + ); + } + + private function csrfPasses(string $name, string $value): bool + { + if (! ($this->editor->config['protectCSRF'] ?? true)) { + return true; + } + if ($name === '' || $value === '') { + return false; + } + return $this->editor->csrf->validate($name, $value); + } + + private function t(string $key): string + { + return $this->editor->i18n[$key] ?? ''; + } + + /** + * @param array $vars + */ + private function fillVars(string $template, array $vars): string + { + if ($template === '') { + return ''; + } + foreach ($vars as $name => $value) { + $template = str_replace('[[' . $name . ']]', $value, $template); + } + return $template; + } + + private function redirect(string $url): never + { + header('Location: ' . $url, true, 302); + exit; + } +}