Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 60 additions & 32 deletions library/Tiger/Controller/Plugin/PageDispatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,42 +39,66 @@ 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')
->setParam('cms_page_id', $page->page_id);
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)));
}
}
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Loading