From 4d8f3fbcc2dba498227492547a7ae7ebf8e81d86 Mon Sep 17 00:00:00 2001 From: Juri Ehret Date: Sun, 3 May 2026 13:14:53 +0200 Subject: [PATCH] Phase 14c-2: editor pages module on iManager 2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /editor/pages admin module is back online — list, create, edit, save, delete, drag-renumber and Markdown preview, all on the iManager 2.0 stack with Csrf + Sanitizer + Request + Frontend\PageRepository. Frontend\PageRepository write surface added (still final readonly): - save(Item) persist via ItemRepository, wrap as Page - delete(int) idempotent forward - renumber(list) bulk position rewrite by id-order - slugTaken(slug, parent) uniqueness preflight, scoped to parent - nextPosition() helper for new-page position default New Editor\Pages\PagesModule covers the legacy 612-line editor/modules/pages/pages.php: - renderList: sortable table with edit/delete actions, CSRF token for inline form (renumber + delete links). - renderEdit: name/menu_title/slug/content/parent/template/published fields. Image section is read-only for 14c-2 (existing entries listed; upload widget reattaches in 14d with FilePond + UploadHandler). - saveAction: validates name+slug+content, derives slug from name when omitted, rejects reserved slugs, rejects duplicate slug under the same parent, preserves existing image data on update; flashes success and redirects to /pages/edit/?page=N. - deleteAction: refuses page id=1, refuses pages with children, flashes success/error and redirects to /pages/. - renumberAction: AJAX endpoint returning {status:0|1}; CSRF-checked. - markdownPreviewAction: AJAX endpoint returning {status:1, text:HTML} rendered via Sanitizer::markdown(). EditorRouter routes /editor/pages* to PagesModule and removes the "coming soon" placeholder for that slug; auth gating still kicks anonymous GET/POST to /editor/auth/ before reaching the module. CSRF: all write paths check tokens via Imanager\Http\Csrf — login form, edit/save form, delete link, renumber AJAX, markdown preview. Tokens are scoped per-form-name ("pages" for the module's form). Manual smoke (PHP built-in server): GET /editor/pages 200, sortable table, 8 migrated rows GET /editor/pages/edit/?page=3 200, edit form pre-filled (Get started) GET /editor/pages/edit/ 200, empty new-page form POST save (new) 302 → /pages/edit/?page=10 (DB row +1) POST render-markdown 200 JSON {status:1, text:...} GET /pages/delete/?page=10&... 302 → /pages/ (DB row gone) Anonymous GET /editor/pages 302 → /editor/auth/ Anonymous POST /editor/pages 302 → /editor/auth/ --- boot/Editor/EditorRouter.php | 26 +- boot/Editor/Pages/PagesModule.php | 475 ++++++++++++++++++++++++++++++ boot/Frontend/PageRepository.php | 90 ++++++ 3 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 boot/Editor/Pages/PagesModule.php diff --git a/boot/Editor/EditorRouter.php b/boot/Editor/EditorRouter.php index f6c5dbd..bfa7b0b 100644 --- a/boot/Editor/EditorRouter.php +++ b/boot/Editor/EditorRouter.php @@ -4,9 +4,13 @@ namespace Scriptor\Boot\Editor; +use Imanager\Storage\CategoryRepository; +use Imanager\Storage\ItemRepository; use League\Container\Container; use Scriptor\Boot\Editor\Auth\AuthModule; use Scriptor\Boot\Editor\Auth\LoginAttempts; +use Scriptor\Boot\Editor\Pages\PagesModule; +use Scriptor\Boot\Frontend\PageRepository; /** * Phase 14c-1 router: dispatches the editor URL to the right module. @@ -28,7 +32,6 @@ final class EditorRouter { private const PLACEHOLDER_MODULES = [ - 'pages' => '14c-2', 'profile' => '14c-6', 'settings' => '14c-4', 'install' => '14c-5', @@ -58,6 +61,11 @@ public function execute(): void return; } + if ($first === 'pages') { + $this->dispatchPages(); + return; + } + if (isset(self::PLACEHOLDER_MODULES[$first])) { $this->renderPlaceholder($first, self::PLACEHOLDER_MODULES[$first]); return; @@ -66,13 +74,25 @@ public function execute(): void $this->renderUnknownModule($first); } + private function dispatchPages(): void + { + $module = new PagesModule( + $this->editor, + new PageRepository( + $this->container->get(CategoryRepository::class), + $this->container->get(ItemRepository::class), + ), + ); + $module->execute(); + } + private function dispatchAuth(): void { $auth = new AuthModule( $this->editor, new UserRepository( - $this->container->get(\Imanager\Storage\CategoryRepository::class), - $this->container->get(\Imanager\Storage\ItemRepository::class), + $this->container->get(CategoryRepository::class), + $this->container->get(ItemRepository::class), ), new LoginAttempts( $this->editor->session, diff --git a/boot/Editor/Pages/PagesModule.php b/boot/Editor/Pages/PagesModule.php new file mode 100644 index 0000000..dbe7c25 --- /dev/null +++ b/boot/Editor/Pages/PagesModule.php @@ -0,0 +1,475 @@ +pageContent` + * exactly like the auth module. + */ +final class PagesModule +{ + /** @var list */ + private array $reservedSlugs; + + public function __construct( + private readonly Editor $editor, + private readonly PageRepository $pages, + ) { + $reserved = (array) ($this->editor->config['reservedSlugs'] ?? []); + /** @var list $reserved */ + $this->reservedSlugs = $reserved; + } + + public function execute(): void + { + $action = $this->editor->input->postString('action'); + if ($action !== '') { + $this->dispatchAction($action); + } + + $sub = $this->editor->urlSegments->get(1); + if ($sub === 'delete') { + $this->deleteAction(); + return; + } + + if ($sub === 'edit') { + $this->renderEdit(); + return; + } + + $this->renderList(); + } + + /* ---------------------------------------------------------------- * + * POST/GET action handlers + * ---------------------------------------------------------------- */ + + private function dispatchAction(string $action): void + { + match ($action) { + 'save-page' => $this->saveAction(), + 'renumber-pages' => $this->renumberAction(), + 'render-markdown' => $this->markdownPreviewAction(), + default => null, + }; + } + + private function saveAction(): void + { + if (! $this->csrfPasses($this->editor->input->postString('tokenName'), $this->editor->input->postString('tokenValue'))) { + $this->editor->addMsg('error', $this->t('error_csrf_token_mismatch')); + return; + } + + $name = $this->editor->sanitizer->text(str_replace('"', '', $this->editor->input->postString('name'))); + if ($name === '') { + $this->editor->addMsg('error', $this->t('error_page_title') ?: 'A page name is required.'); + return; + } + + $rawSlug = $this->editor->input->postString('slug'); + $slugSource = $rawSlug !== '' ? $rawSlug : $name; + $slug = preg_replace('/(-)\1+/', '$1', $this->editor->sanitizer->slug($slugSource)) ?? ''; + if ($slug === '') { + $this->editor->addMsg('error', $this->t('error_page_name') ?: 'Page slug could not be derived.'); + return; + } + if (\in_array($slug, $this->reservedSlugs, true)) { + $this->editor->addMsg('error', $this->t('error_slug_reserved') ?: 'Slug is reserved.'); + return; + } + + $editingId = $this->editor->input->getInt('page', 0); + $existing = $editingId > 0 ? $this->pages->find($editingId) : null; + + $parentId = $this->editor->input->postInt('parent', 0); + if ($existing !== null && $parentId === ($existing->id() ?? 0)) { + $parentId = 0; + } elseif ($parentId !== 0 && $this->pages->find($parentId) === null) { + $parentId = 0; + } + + if ($this->pages->slugTaken($slug, $parentId, $existing?->id())) { + $this->editor->addMsg('error', $this->t('error_page_title_exists') ?: 'A page with that slug already exists under this parent.'); + return; + } + + $menuTitleRaw = $this->editor->input->postString('menu_title'); + $menuTitle = $menuTitleRaw !== '' + ? $this->editor->sanitizer->text(str_replace('"', '', $menuTitleRaw)) + : $name; + + $contentRaw = $this->editor->input->postString('content'); + if ($contentRaw === '') { + $this->editor->addMsg('error', $this->t('error_page_content') ?: 'Page content is required.'); + return; + } + $content = htmlentities($contentRaw); + + $template = $this->editor->sanitizer->templateName($this->editor->input->postString('template')); + $active = $this->editor->input->postString('published') !== ''; + + // Rebuild the data bag from the existing page so we don't drop + // image entries the legacy widget previously stored. + $data = $existing !== null ? $this->existingDataMap($existing) : []; + $data['slug'] = $slug; + $data['parent'] = $parentId; + $data['menu_title'] = $menuTitle; + $data['content'] = $content; + $data['template'] = $template; + $data['pagetype'] = $data['pagetype'] ?? '1'; + + $now = time(); + $item = new Item( + id: $existing?->id(), + categoryId: $this->pages->categoryId, + name: $name, + label: $existing?->item->label, + position: $existing?->item->position ?? $this->pages->nextPosition(), + active: $active, + data: $data, + created: $existing?->item->created ?? $now, + updated: $now, + ); + + try { + $saved = $this->pages->save($item); + } catch (\Throwable $e) { + $this->editor->addMsg('error', $this->t('error_saving_page') ?: 'Saving failed: ' . $e->getMessage()); + return; + } + + $this->editor->flashMsg('success', $this->t('successful_saved_page') ?: 'Page saved.'); + $this->redirect($this->editor->siteUrl . '/pages/edit/?page=' . $saved->id()); + } + + private function deleteAction(): void + { + $id = $this->editor->input->getInt('page', 0); + 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 . '/pages/'); + } + if ($id <= 1) { + $this->editor->flashMsg('error', $this->t('error_deleting_first_page') ?: 'The home page cannot be deleted.'); + $this->redirect($this->editor->siteUrl . '/pages/'); + } + $page = $this->pages->find($id); + if ($page === null) { + $this->editor->flashMsg('error', $this->t('error_deleting_page') ?: 'Page not found.'); + $this->redirect($this->editor->siteUrl . '/pages/'); + } + if ($this->pages->findByParent($id) !== []) { + $this->editor->flashMsg('error', $this->t('error_remove_parent_page') ?: 'Cannot delete a page with child pages.'); + $this->redirect($this->editor->siteUrl . '/pages/'); + } + $this->pages->delete($id); + $this->editor->flashMsg('success', $this->t('page_successful_removed') ?: 'Page deleted.'); + $this->redirect($this->editor->siteUrl . '/pages/'); + } + + private function renumberAction(): void + { + if (! $this->csrfPasses($this->editor->input->postString('tokenName'), $this->editor->input->postString('tokenValue'))) { + $this->jsonResponse(['status' => 0, 'error' => 'csrf']); + } + $positions = $this->editor->input->post('position'); + if (! \is_array($positions)) { + $this->jsonResponse(['status' => 0]); + } + $ids = []; + foreach ($positions as $value) { + $int = (int) $value; + if ($int > 0) { + $ids[] = $int; + } + } + $this->pages->renumber($ids); + $this->jsonResponse(['status' => 1]); + } + + private function markdownPreviewAction(): void + { + $rendered = $this->editor->sanitizer->markdown( + htmlspecialchars_decode($this->editor->input->postString('content')), + ); + $this->jsonResponse(['status' => 1, 'text' => $rendered]); + } + + /* ---------------------------------------------------------------- * + * Render + * ---------------------------------------------------------------- */ + + private function renderList(): void + { + $pages = $this->pages->findAll(); + usort( + $pages, + static fn(Page $a, Page $b): int => $a->item->position <=> $b->item->position, + ); + + $token = $this->editor->csrf->token('pages'); + $rows = ''; + foreach ($pages as $page) { + $rows .= sprintf( + '' + . '' + . '%d%s' + . '%s' + . '' + . '', + $page->id(), + $page->id(), + $page->parent !== 0 ? (string) $page->parent : '', + htmlspecialchars($this->truncate($page->name, 80), \ENT_QUOTES), + htmlspecialchars($this->t('pre_delete_msg'), \ENT_QUOTES), + rawurlencode($token), + ); + } + if ($rows === '') { + $rows = '' . htmlspecialchars($this->t('no_page'), \ENT_QUOTES) . ''; + } + + $this->editor->pageTitle = 'Page list - Scriptor'; + $this->editor->breadcrumbs = sprintf('
  • %s
  • ', htmlspecialchars($this->t('pages_menu'), \ENT_QUOTES)); + $this->editor->pageContent = $this->wrapList($rows, $token); + } + + private function renderEdit(): void + { + $editingId = $this->editor->input->getInt('page', 0); + $page = $editingId > 0 ? $this->pages->find($editingId) : null; + + $isEdit = $page !== null; + $headerKey = $isEdit ? 'page_edit_header' : 'page_create_header'; + $crumbCurrent = $isEdit ? $this->t('pages_edit_menu') : $this->t('pages_create_menu'); + $this->editor->pageTitle = ($isEdit ? 'Page editor' : 'New page') . ' - Scriptor'; + $this->editor->breadcrumbs = sprintf( + '
  • %s
  • %s
  • ', + htmlspecialchars($this->t('pages_menu'), \ENT_QUOTES), + htmlspecialchars($crumbCurrent, \ENT_QUOTES), + ); + + $token = $this->editor->csrf->token('pages'); + $action = $isEdit ? './?page=' . (int) $page->id() : './'; + $parentOptions = $this->renderParentOptions($page); + + $html = '

    ' . htmlspecialchars($this->t($headerKey), \ENT_QUOTES) . '

    '; + $html .= '
    '; + $html .= $this->fieldText('pagename', 'name', $this->t('title_label'), $page?->name ?? '', required: true); + $html .= $this->fieldText('menu-title', 'menu_title', $this->t('menu_title_label'), $page?->menu_title ?? '', infoText: $this->t('menu_title_field_infotext')); + $html .= $this->fieldText('slug', 'slug', $this->t('name_label'), $page?->slug ?? '', infoText: $this->t('name_field_infotext')); + $html .= $this->fieldTextarea('markdown', 'content', $this->t('content_label'), $page?->content ?? '', required: true); + $html .= $this->renderImagesSection($page); + $html .= $this->fieldSelect('parent', 'parent', $this->t('parent_label'), $parentOptions); + $html .= $this->fieldText('template', 'template', $this->t('template_label'), $page?->template ?? '', infoText: $this->t('template_field_infotext')); + $html .= $this->fieldCheckbox('publish', 'published', $this->t('published_label'), $page?->active() ?? true); + $html .= ''; + $html .= sprintf('', htmlspecialchars('pages', \ENT_QUOTES)); + $html .= sprintf('', htmlspecialchars($token, \ENT_QUOTES)); + $html .= ''; + $html .= '
    '; + $this->editor->pageContent = $html; + } + + private function renderParentOptions(?Page $current): string + { + $opts = ''; + foreach ($this->pages->findAll() as $candidate) { + if ($current !== null && $candidate->id() === $current->id()) { + continue; + } + $selected = $current !== null && $candidate->id() === $current->parent ? ' selected' : ''; + $opts .= sprintf( + '', + $candidate->id(), + $selected, + htmlspecialchars($this->truncate($candidate->name, 80), \ENT_QUOTES), + ); + } + return $opts; + } + + private function renderImagesSection(?Page $page): string + { + $images = $page?->images ?? []; + if ($images === []) { + return '
    ' + . '

    Image upload reattaches in phase 14d (FilePond + UploadHandler). Existing images render here when present.

    '; + } + $rows = ''; + foreach ($images as $img) { + if (! \is_array($img)) { + continue; + } + $name = (string) ($img['name'] ?? ''); + $title = (string) ($img['title'] ?? ''); + $rows .= sprintf( + '
  • %s%s
  • ', + htmlspecialchars($name, \ENT_QUOTES), + $title !== '' ? ' — ' . htmlspecialchars($title, \ENT_QUOTES) : '', + ); + } + return '
    ' + . '

    Read-only until phase 14d wires uploads.

    ' + . '
      ' . $rows . '
    '; + } + + private function fieldText(string $id, string $name, string $label, string $value, bool $required = false, string $infoText = ''): string + { + $cls = $required ? ' class="required"' : ''; + $info = $infoText !== '' + ? '

    ' . htmlspecialchars($infoText, \ENT_QUOTES) . '

    ' + : ''; + return sprintf( + '
    %s%s
    ', + $cls, + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($label, \ENT_QUOTES), + $info, + htmlspecialchars($name, \ENT_QUOTES), + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($value, \ENT_QUOTES), + ); + } + + private function fieldTextarea(string $id, string $name, string $label, string $value, bool $required = false): string + { + $cls = $required ? ' class="required"' : ''; + return sprintf( + '
    %s
    ', + $cls, + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($label, \ENT_QUOTES), + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($name, \ENT_QUOTES), + htmlspecialchars($value, \ENT_QUOTES), + ); + } + + private function fieldSelect(string $id, string $name, string $label, string $optionsHtml): string + { + return sprintf( + '
    ', + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($label, \ENT_QUOTES), + htmlspecialchars($name, \ENT_QUOTES), + htmlspecialchars($id, \ENT_QUOTES), + $optionsHtml, + ); + } + + private function fieldCheckbox(string $id, string $name, string $label, bool $checked): string + { + return sprintf( + '
    ', + htmlspecialchars($id, \ENT_QUOTES), + htmlspecialchars($name, \ENT_QUOTES), + htmlspecialchars($id, \ENT_QUOTES), + $checked ? ' checked' : '', + htmlspecialchars($label, \ENT_QUOTES), + ); + } + + private function wrapList(string $rows, string $token): string + { + $i = static fn(string $key): string => htmlspecialchars($key, \ENT_QUOTES); + return '
    ' + . '

    ' . $i($this->t('pages_header')) . '

    ' + . '
    ' + . '' + . '' + . '' + . '' + . '' + . '' + . '' . $rows . '
    ' . $i($this->t('position_table_header')) . '' . $i($this->t('id_table_header')) . '' . $i($this->t('parent_table_header')) . '' . $i($this->t('title_table_header')) . '' . $i($this->t('delete_table_header')) . '
    ' + . '' + . sprintf('', $i('pages')) + . sprintf('', $i($token)) + . '
    ' + . ''; + } + + /* ---------------------------------------------------------------- * + * Helpers + * ---------------------------------------------------------------- */ + + /** + * @return array + */ + private function existingDataMap(Page $page): array + { + $out = []; + foreach (['slug', 'parent', 'pagetype', 'menu_title', 'content', 'template', 'images'] as $key) { + if ($page->item->data->has($key)) { + $out[$key] = $page->item->data->get($key); + } + } + return $out; + } + + 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 truncate(string $text, int $length): string + { + return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '…' : $text; + } + + /** + * @param array $vars + */ + private function t(string $key, array $vars = []): string + { + $template = $this->editor->i18n[$key] ?? ''; + if ($template === '' || $vars === []) { + return $template; + } + foreach ($vars as $name => $value) { + $template = str_replace('[[' . $name . ']]', $value, $template); + } + return $template; + } + + /** + * @param array $payload + */ + private function jsonResponse(array $payload): never + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + exit; + } + + private function redirect(string $url): never + { + header('Location: ' . $url, true, 302); + exit; + } +} diff --git a/boot/Frontend/PageRepository.php b/boot/Frontend/PageRepository.php index b12df8b..c7b62fa 100644 --- a/boot/Frontend/PageRepository.php +++ b/boot/Frontend/PageRepository.php @@ -151,6 +151,96 @@ public function countByParent(int $parentId, bool $activeOnly = false): int return \count($this->items->query($query)); } + /* ---------------------------------------------------------------- * + * Write operations + * ---------------------------------------------------------------- */ + + public function save(Item $item): Page + { + if ($item->categoryId !== $this->categoryId) { + throw new \InvalidArgumentException(\sprintf( + 'Item belongs to category %d, expected %d (Pages)', + $item->categoryId, + $this->categoryId, + )); + } + return new Page($this->items->save($item)); + } + + public function delete(int $id): void + { + $page = $this->find($id); + if ($page === null) { + return; // already gone — idempotent + } + $this->items->delete($id); + } + + /** + * Bulk-renumber pages in the given order. Each id's `position` + * becomes its (1-indexed) slot in the array. Pages absent from the + * id list keep their current position. + * + * @param list $idsInOrder + */ + public function renumber(array $idsInOrder): void + { + $position = 1; + foreach ($idsInOrder as $id) { + $item = $this->items->find($id); + if ($item === null || $item->categoryId !== $this->categoryId) { + continue; + } + if ($item->position === $position) { + $position++; + continue; + } + $this->items->save(new Item( + id: $item->id, + categoryId: $item->categoryId, + name: $item->name, + label: $item->label, + position: $position, + active: $item->active, + data: $item->data, + created: $item->created, + updated: $item->updated, + )); + $position++; + } + } + + /** + * Returns true when another page already uses `$slug` under the same + * `$parentId`, ignoring `$exceptId` (so a page can keep its own slug + * on update). Lets PagesModule preflight a save before committing. + */ + public function slugTaken(string $slug, int $parentId, ?int $exceptId = null): bool + { + $query = (new Query($this->categoryId)) + ->where('slug', Operator::Eq, $slug) + ->where('parent', Operator::Eq, $parentId); + foreach ($this->items->query($query) as $item) { + if ($exceptId !== null && $item->id === $exceptId) { + continue; + } + return true; + } + return false; + } + + public function nextPosition(): int + { + $items = $this->items->findByCategory($this->categoryId); + $max = 0; + foreach ($items as $item) { + if ($item->position > $max) { + $max = $item->position; + } + } + return $max + 1; + } + /** * Recursive walk that materialises a tree of pages keyed by parent id. * Replaces 1.x `Pages::getPageLevels()` for nav-style traversals.