80) { throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); } if (!in_array($type, ['page', 'post'], true)) { throw new InvalidPage('Unbekannter Inhaltstyp.'); } $document = $this->richText->normaliseDocument($document); $this->validator->validate($document, writing: true); return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string { $path = $slug; if (null !== $parentId) { $parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $locale]); if (false === $parent) { throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.'); } if ('page' !== $parent['type']) { throw new InvalidPage('Beiträge können keine Unterseiten enthalten.'); } if (substr_count($parent['path'], '/') + 2 > self::MAX_DEPTH) { throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen.', self::MAX_DEPTH)); } $path = $parent['path'].'/'.$slug; } elseif (in_array($slug, self::RESERVED_SLUGS, true)) { throw new InvalidPage('Diese Adresse ist für das CMS reserviert.'); } if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) { throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path)); } $pageId = bin2hex(random_bytes(16)); $revisionId = bin2hex(random_bytes(16)); $this->db->insert('page', [ 'id' => $pageId, 'website_id' => 'default', 'locale' => $locale, 'slug' => $slug, 'path' => $path, 'parent_id' => $parentId, 'type' => $type, 'draft_revision_id' => $revisionId, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s'), ]); $this->db->insert('revision', [ 'id' => $revisionId, 'page_id' => $pageId, 'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'created_at' => gmdate('Y-m-d H:i:s'), 'seq' => 1, ]); return $pageId; }); } /** * Renames or moves a page and carries its whole subtree along. * * Every affected address gets a 301 from its old form, so a migrated or long lived site keeps * every external link working. All of it happens in one transaction: a half moved subtree * would leave pages unreachable. * * @return int the number of pages whose address changed */ public function movePage(string $pageId, string $slug, ?string $parentId, int $expectedVersion, RedirectRepository $redirects): int { $slug = strtolower(trim($slug)); if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) { throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); } return $this->db->transactional(function () use ($pageId, $slug, $parentId, $expectedVersion, $redirects): int { $page = $this->db->fetchAssociative('SELECT id, path, slug, parent_id, type, locale, version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']); if (false === $page) { throw new InvalidPage('Diese Seite gibt es nicht mehr.'); } if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } if ($page['path'] === $this->settings->homePath()) { // The root answers at "/". Renaming it would move the whole site to a new address, // which is a settings decision, not an editing one. throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } $newPath = $slug; if (null !== $parentId) { if ($parentId === $pageId) { throw new InvalidPage('Eine Seite kann nicht ihre eigene übergeordnete Seite sein.'); } $parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $page['locale']]); if (false === $parent) { throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.'); } if ('page' !== $parent['type']) { throw new InvalidPage('Beiträge können keine Unterseiten enthalten.'); } // Moving a page under one of its own descendants would detach the whole subtree. if ($parent['path'] === $page['path'] || str_starts_with($parent['path'].'/', $page['path'].'/')) { throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.'); } $newPath = $parent['path'].'/'.$slug; } if ($newPath === $page['path']) { return 0; } // Collect the subtree before anything changes, so the old addresses are still known. $affected = $this->db->fetchAllAssociative( 'SELECT id, path FROM page WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) ORDER BY path', ['default', $page['locale'], $page['path'], $page['path'].'/%'], ); $depth = substr_count($newPath, '/') + 1; foreach ($affected as $row) { $moved = $newPath.substr($row['path'], strlen($page['path'])); if (substr_count($moved, '/') + 1 > self::MAX_DEPTH) { throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen; die Unterseiten würden zu tief liegen.', self::MAX_DEPTH)); } $taken = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ? AND id <> ?', ['default', $page['locale'], $moved, $row['id']]); if (false !== $taken) { throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $moved)); } } unset($depth); foreach ($affected as $row) { $moved = $newPath.substr($row['path'], strlen($page['path'])); $this->db->executeStatement('UPDATE page SET path = ? WHERE id = ?', [$moved, $row['id']]); $redirects->remember($row['path'], $moved); } $this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]); return count($affected); }); } /** * Moves a page into the bin. Its revisions stay; publicly it answers 404 and it disappears * from menus, archives and the sitemap. Subpages go along, because leaving them behind would * make them unreachable without their parent in the address. * * @return int the number of pages moved into the bin */ public function trashPage(string $pageId, int $expectedVersion): int { return $this->db->transactional(function () use ($pageId, $expectedVersion): int { $page = $this->db->fetchAssociative('SELECT path, locale, version, deleted_at FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']); if (false === $page || null !== $page['deleted_at']) { throw new InvalidPage('Diese Seite ist nicht mehr vorhanden.'); } if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } if ($page['path'] === $this->settings->homePath()) { throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } return $this->db->executeStatement( 'UPDATE page SET deleted_at = ?, version = version + 1 WHERE website_id = ? AND locale = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL', [gmdate('Y-m-d H:i:s'), 'default', $page['locale'], $page['path'], $page['path'].'/%'], ); }); } /** Takes a page out of the bin. Its ancestors have to be present, or the address would break. */ public function restorePage(string $pageId): void { $this->db->transactional(function () use ($pageId): void { $page = $this->db->fetchAssociative('SELECT path, parent_id, locale FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NOT NULL', [$pageId, 'default']); if (false === $page) { throw new InvalidPage('Diese Seite liegt nicht im Papierkorb.'); } if (null !== $page['parent_id']) { $parent = $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND deleted_at IS NULL', [$page['parent_id']]); if (false === $parent) { throw new InvalidPage('Stelle zuerst die übergeordnete Seite wieder her.'); } } $this->db->executeStatement('UPDATE page SET deleted_at = NULL, version = version + 1 WHERE id = ?', [$pageId]); }); } /** @return list */ public function trash(string $locale = 'de'): array { return array_map(static fn (array $row): array => ['id' => $row['id'], 'path' => $row['path'], 'deletedAt' => $row['deleted_at']], $this->db->fetchAllAssociative( 'SELECT id, path, deleted_at FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC', ['default', $locale], )); } /** @return list */ /** * One page of the history, newest first. * * It used to stop at thirty without saying so - which looks like "there are thirty" and is * the one thing a history must not do. * * @return array{items: list, page: int, pages: int, total: int} */ public function revisions(string $pageId, int $number = 1, int $perPage = 25): array { $slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$pageId]), $perPage); $page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]); $items = array_map(static function (array $row) use ($page): array { $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); return [ 'id' => $row['id'], 'createdAt' => $row['created_at'], 'title' => $document['title'] ?? '', 'published' => $row['id'] === ($page['published_revision_id'] ?? null), 'draft' => $row['id'] === ($page['draft_revision_id'] ?? null), ]; }, $this->db->fetchAllAssociative( 'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'], [$pageId], )); return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']]; } /** * Makes an earlier revision the current draft by storing its document again. Nothing is * overwritten in place: the restored state becomes a new revision, so the history stays whole. */ public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string { return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion); } /** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */ public function revisionDocument(string $pageId, string $revisionId): array { $raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]); if (false === $raw) { throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.'); } return json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } /** * Publishes every page whose scheduled moment has passed. Run from cron; without it a * scheduled page simply stays a draft, which is the safe direction. * * @return list the paths that went public */ public function publishDue(): array { $published = []; foreach ($this->db->fetchAllAssociative( 'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)', ['default', gmdate('Y-m-d H:i:s')], ) as $row) { try { $this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']); } catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) { // Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die // anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin. error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage())); continue; } $this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]); $published[] = $row['path']; } return $published; } public function schedule(string $pageId, string $when, int $expectedVersion): void { $when = trim($when); if ('' !== $when && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/D', $when)) { throw new InvalidPage('Der Zeitpunkt braucht die Form JJJJ-MM-TT HH:MM.'); } $changed = $this->db->executeStatement( 'UPDATE page SET publish_at = ?, version = version + 1 WHERE id = ? AND version = ?', ['' === $when ? null : str_replace('T', ' ', $when).':00', $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } } /** * Removes a page and its revisions. Pages with children stay untouched so no content * disappears silently; the editor has to deal with the children first. */ public function deletePage(string $pageId, int $expectedVersion): void { $this->db->transactional(function () use ($pageId, $expectedVersion): void { $page = $this->db->fetchAssociative('SELECT version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']); if (false === $page) { throw new InvalidPage('Diese Seite gibt es nicht mehr.'); } if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } if (0 !== (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE parent_id = ?', [$pageId])) { throw new InvalidPage('Diese Seite hat Unterseiten. Verschiebe oder lösche zuerst die Unterseiten.'); } // Detach both pointers first: the revision rows are referenced by the page row. $this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]); }); } public function saveDraft(string $pageId, array $document, int $expectedVersion): string { // Sanitising happens before validating and before storing, so the database never holds // markup the allowlist would reject and no renderer has to remember to filter. $document = $this->richText->normaliseDocument($document); $this->validator->validate($document, writing: true); return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string { $id = bin2hex(random_bytes(16)); $changed = $this->db->executeStatement( 'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?', [$id, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before saving.'); } $this->db->insert('revision', [ 'id' => $id, 'page_id' => $pageId, 'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'created_at' => gmdate('Y-m-d H:i:s'), // Strictly increasing per page, because several saves share one second. 'seq' => 1 + (int) $this->db->fetchOne('SELECT COALESCE(MAX(seq), 0) FROM revision WHERE page_id = ?', [$pageId]), ]); return $id; }); } public function publish(string $pageId, string $revisionId, int $expectedVersion): void { $this->db->transactional(function () use ($pageId, $revisionId, $expectedVersion): void { $raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]); if (false === $raw) { throw new \InvalidArgumentException('Revision does not belong to this page.'); } $this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR)); $changed = $this->db->executeStatement( 'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?', [$revisionId, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before publishing.'); } // A post that goes public without its own date gets one, so the archive can order it. $this->db->executeStatement( "UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL", [gmdate('Y-m-d H:i:s'), $pageId], ); }); } public function findPublished(string $path, string $locale = 'de'): ?array { $page = $this->db->fetchAssociative( 'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$path, $locale, 'default'], ); if (false === $page) { return null; } $page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($page['document']); return $page; } /** * Which header and footer a page wants: null for the standard one, 'none' for no area at all, * or the address of another one. */ public function saveAreas(string $pageId, ?string $header, ?string $footer, int $expectedVersion): void { foreach ([$header, $footer] as $choice) { if (null !== $choice && 'none' !== $choice && 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $choice)) { throw new InvalidPage('Die Adresse eines Bereichs darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); } } $changed = $this->db->executeStatement( 'UPDATE page SET header_area = ?, footer_area = ?, version = version + 1 WHERE id = ? AND version = ?', [$header, $footer, $pageId, $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Seite neu.'); } } /** A published page by ID, used to serve its own script file. */ public function findPublishedById(string $id, string $locale = 'de'): ?array { $row = $this->db->fetchAssociative( 'SELECT p.id, p.path, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, $locale, 'default'], ); if (false === $row) { return null; } $row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); return $row; } public function listForEditor(): array { return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']); } /** Only the requested screenful is hydrated; filters and counts run in the database. */ public function editorPage(string $type, int $page = 1, int $limit = 20, string $query = '', string $status = 'all'): array { $limit = in_array($limit, [20, 50], true) ? $limit : 20; $type = 'post' === $type ? 'post' : 'page'; $qb = $this->db->createQueryBuilder()->from('page', 'p') ->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id') ->where('p.website_id = :website')->andWhere('p.type = :type') ->setParameter('website', 'default')->setParameter('type', $type); $qb->andWhere('trash' === $status ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'); if ('draft' === $status) { $qb->andWhere('p.published_revision_id IS NULL'); } elseif ('published' === $status) { $qb->andWhere('p.published_revision_id IS NOT NULL'); } elseif ('changed' === $status) { $qb->andWhere('p.published_revision_id IS NOT NULL AND p.published_revision_id <> p.draft_revision_id'); } if ('' !== trim($query)) { $qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')') ->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%'); } $total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne(); $pages = max(1, (int) ceil($total / $limit)); $page = max(1, min($pages, $page)); $items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC') // Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“). ->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative(); return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit]; } /** Bounded suggestions for menus and parent selectors, including a selected ID lookup. */ public function choices(string $query = '', string $type = '', bool $parentsOnly = false, ?string $id = null): array { $qb = $this->db->createQueryBuilder()->select('p.id', 'p.path', 'p.type', 'p.published_revision_id', 'r.document') ->from('page', 'p')->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id') ->where('p.website_id = :website')->andWhere('p.deleted_at IS NULL')->setParameter('website', 'default'); if (null !== $id) { $qb->andWhere('p.id = :id')->setParameter('id', $id); } if ($parentsOnly || in_array($type, ['page', 'post'], true)) { $qb->andWhere('p.type = :type')->setParameter('type', $parentsOnly ? 'page' : $type); } if ($parentsOnly) { $qb->andWhere("LENGTH(p.path) - LENGTH(REPLACE(p.path, '/', '')) < ".(self::MAX_DEPTH - 1)); } if ('' !== trim($query)) { $qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')') ->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%'); } $rows = $qb->orderBy('p.path')->addOrderBy('p.id')->setMaxResults(20)->executeQuery()->fetchAllAssociative(); return array_map(static fn (array $row): array => [ 'id' => $row['id'], 'path' => $row['path'], 'type' => $row['type'], 'title' => json_decode($row['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $row['path'], 'published' => null !== $row['published_revision_id'], ], $rows); } public function findForEditor(string $id): ?array { $row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']); if (false === $row) { return null; } $row['version'] = (int) $row['version']; $row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($row['document']); return $row; } }