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 . '
'
+ . ''
+ . '| ' . $i($this->t('install_table_column_name') ?: 'Name') . ' | '
+ . '' . $i($this->t('install_table_column_description') ?: 'Description') . ' | '
+ . '' . $i($this->t('install_table_column_action') ?: 'Action') . ' | '
+ . '
' . $rows . '
';
+ }
+
+ /**
+ * @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;
+ }
+}