From a2f8fca8f1b78ae7338fe79a45031a20eccdcad8 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 22 Jul 2026 09:01:00 -0400 Subject: [PATCH] feat(routing): CMS pages override shipped routes (reserved-slug blacklist) (v0.37.0-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageDispatch was a pure 404-fallback: a published page only rendered when no controller claimed the URL, so shipped routes like the /vibe marketing alias could never be replaced by CMS content. Now a published `page` whose slug matches the request WINS — overriding a matched static route — so an admin can swap any built-in landing page for real, editable, SEO'd CMS content just by giving a page that slug. The guard is a reserved blacklist built per request from the live module list: every registered module namespace (/admin, /api, /docs, /pay, …) plus the core default-module system controllers stay off-limits, so a page can never shadow application routes. Root `/` unchanged (IndexController serves the admin-chosen home page); unmatched slugs still 301 via page_redirect or fall to a clean 404; fail-open throughout. Verified on dev against the live DB: a page at slug `features` served over the static /features route; /vibe /agency (no page) still rendered the shipped pages; /docs /api /admin untouched; /nonexistent 404. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 +++ .../Tiger/Controller/Plugin/PageDispatch.php | 92 ++++++++++++------- library/Tiger/Version.php | 2 +- 3 files changed, 75 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efa64d..ae56c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [0.37.0-beta] — 2026-07-22 + +### Changed +- **CMS pages can now override shipped routes.** `Tiger_Controller_Plugin_PageDispatch` no longer only + fills 404s — a published `page` whose slug matches a request now *wins*, even over a static route such + as the `/vibe` marketing alias. This lets an admin replace any built-in landing page with real, + editable, SEO'd CMS content just by giving a page that slug — no code, no route edit. The one guard is + a reserved blacklist: every registered **module** namespace (`/admin`, `/api`, `/docs`, `/pay`, …) plus + the core default-module system controllers stay off-limits, so a page can never shadow application + routes. The reserved set is built per request from the live module list, so newly installed modules are + protected automatically. The root `/` is unchanged (IndexController still serves the admin-chosen home + page); unmatched slugs still 301 via `page_redirect` or fall through to a clean 404. Fail-open: a broken + lookup never takes down routing. + ## [0.36.0-beta] — 2026-07-21 ### Added diff --git a/library/Tiger/Controller/Plugin/PageDispatch.php b/library/Tiger/Controller/Plugin/PageDispatch.php index d6a89dc..80a3c22 100644 --- a/library/Tiger/Controller/Plugin/PageDispatch.php +++ b/library/Tiger/Controller/Plugin/PageDispatch.php @@ -2,29 +2,33 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. /** - * Tiger_Controller_Plugin_PageDispatch — route unmatched URLs to CMS pages. + * Tiger_Controller_Plugin_PageDispatch — let CMS content own the site's public URLs. * - * At routeShutdown (after routing, before dispatch) this checks ONLY requests that - * did not resolve to a real controller — i.e. ones that would otherwise 404. If the - * path matches a published `page` row it hands off to PageController::viewAction; if - * it matches a `page_redirect` it 301s; otherwise it leaves the request untouched so - * ZF's ErrorHandler renders a clean 404. + * At routeShutdown (after routing, before dispatch) this checks whether a published `page` + * row claims the requested slug. If one does it hands off to PageController::viewAction — + * even when a shipped route already matched (e.g. the `/vibe` marketing alias), so admins + * can REPLACE any built-in landing page with real, editable, SEO'd CMS content just by + * giving a page that slug. No code, no route edit. * - * Real controllers (`/admin`, `/api`, `/auth`, the `/` landing) are dispatchable, so - * they're never intercepted. This is the WordPress-style "if nothing else claims the - * URL, ask the content store" fallback — but explicit and non-greedy. + * The one guard is a reserved-slug blacklist: every registered MODULE namespace (`/admin`, + * `/api`, `/docs`, `/pay`, …) plus the core default-module system controllers are off-limits, + * so a page can never shadow real application routes. Everything else is CMS-claimable. * - * Runs after LocalePrefix (so the path is locale-stripped and LANG is set) and before - * the authorization plugin (which then gates PageController — public in acl.ini). - * Public pages resolve at global scope for now; per-tenant public sites (host -> org) - * are a later addition. + * If no page claims the slug and nothing else will dispatch it, a `page_redirect` 301s; + * otherwise the request is left untouched so ZF's ErrorHandler renders a clean 404. + * + * Runs after LocalePrefix (so the path is locale-stripped and LANG is set) and before the + * authorization plugin (which gates PageController — public in acl.ini). Public pages resolve + * at global scope for now; per-tenant public sites (host -> org) are a later addition. The + * root `/` is intentionally left to IndexController, which serves the admin-chosen home page. * * @api */ class Tiger_Controller_Plugin_PageDispatch extends Zend_Controller_Plugin_Abstract { /** - * Route an otherwise-unmatched URL to a published CMS page, or 301 a moved slug. + * Route a URL to a published CMS page (overriding shipped routes for non-reserved slugs), + * or 301 a moved slug. * * @param Zend_Controller_Request_Abstract $request the current request * @return void @@ -35,25 +39,30 @@ public function routeShutdown(Zend_Controller_Request_Abstract $request) return; } - // Only step in when nothing real will handle this request. - $front = Zend_Controller_Front::getInstance(); - if ($front->getDispatcher()->isDispatchable($request)) { - return; - } - $slug = trim($request->getPathInfo(), '/'); if ($slug === '') { - return; // the root belongs to IndexController (the landing) + return; // the root belongs to IndexController (it serves the admin-chosen home page) } + + // Reserved namespaces a CMS page may never shadow — leave routing exactly as matched. + $first = strtolower((string) strtok($slug, '/')); + if (in_array($first, $this->_reserved(), true)) { + return; + } + + $front = Zend_Controller_Front::getInstance(); $locale = defined('LANG') ? LANG : 'en'; - $orgId = Tiger_Model_Org::siteOrgId(); // the org owning this public site (root org on a stock - // install; a multi-site module resolves host->org). The - // read scope is [org, ''], so shared '' content still shows. try { + $orgId = Tiger_Model_Org::siteOrgId(); // the org owning this public site (root org on a + // stock install; a multi-site module resolves + // host->org). Read scope [org, ''], so shared '' + // content still shows. + // Only real pages answer at the site root — articles/posts route under /blog. $page = (new Tiger_Model_Page())->resolveBySlug($slug, $locale, $orgId, Tiger_Model_Page::TYPE_PAGE); if ($page) { + // A published page claims this slug — it WINS, even over a matched shipped route. $request->setModuleName($front->getDispatcher()->getDefaultModule()) ->setControllerName('page') ->setActionName('view') @@ -61,16 +70,35 @@ public function routeShutdown(Zend_Controller_Request_Abstract $request) return; } - // A moved slug? 301 to its current location. - $redirect = (new Tiger_Model_PageRedirect())->findFrom($slug, $locale, $orgId); - if ($redirect) { - Zend_Controller_Action_HelperBroker::getStaticHelper('redirector') - ->setCode((int) $redirect->code) - ->gotoUrlAndExit('/' . ltrim($redirect->to_slug, '/')); + // No page claims it. If no real controller will dispatch it either, honour a moved slug. + if (!$front->getDispatcher()->isDispatchable($request)) { + $redirect = (new Tiger_Model_PageRedirect())->findFrom($slug, $locale, $orgId); + if ($redirect) { + Zend_Controller_Action_HelperBroker::getStaticHelper('redirector') + ->setCode((int) $redirect->code) + ->gotoUrlAndExit('/' . ltrim($redirect->to_slug, '/')); + } } } catch (Throwable $e) { - // no DB / no page table yet — leave it to the 404 path + // fail-open — a broken page/redirect lookup must never take down routing } - // no page, no redirect -> untouched -> ErrorHandler 404 + // no page, no redirect -> untouched -> matched controller dispatches (or ErrorHandler 404) + } + + /** + * Slugs a CMS page may never shadow: every registered module namespace (so /docs, /pay, /api… + * always reach their module) plus the core default-module system controllers. Built fresh each + * request from the live module list, so a newly installed module is protected automatically. + * + * @return string[] lowercase reserved first path-segments + */ + protected function _reserved() + { + $mods = array_map('strtolower', array_keys( + Zend_Controller_Front::getInstance()->getControllerDirectory() + )); + // Default-module system controllers (not modules, so not in the list above). + $sys = ['admin', 'auth', 'error', 'install', 'login', 'logout', 'page']; + return array_values(array_unique(array_merge($mods, $sys))); } } diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 26dcfbc..366529c 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '0.36.0-beta'; + const VERSION = '0.37.0-beta'; }