4.5 KiB
View unpublished trips (drafts) on the frontend when logged in
Status: 📋 Not started
Motivation
An unpublished trip (published: false) currently returns a hard 404 on its own
route, even for the logged-in owner. Example: http://localhost:8081/trips/denmark-2026
→ HTTP 404 (verified 2026-07-08, anonymous and authenticated). The owner should be
able to preview a draft trip page at its real URL before publishing, while the public
still gets a 404.
The rest of the site is already owner-aware — the trip template, the trips listing,
and the home page all render drafts to grav.user.authenticated (via .published()
filters + is-draft/Draft badges). The only missing piece is the direct route to a
draft's own page.
Current behaviour — verified mechanism
Traced through the Grav core running in the container (Grav 2.0.x):
-
Page::routable()(system/src/Grav/Common/Page/Page.php) returns:return $this->routable && $this->published();So
published: false⇒routable()isfalse, regardless of theroutableflag. -
PagesProcessor.php:67gates the request on exactly that:if (!$page->routable()) { // build 404, fire onPageNotFound... } -
PagesProcessor.php~line 80: after firingonPageNotFound, if a listener set$event->page, Grav serves that page directly with no further routable check:if (isset($event->page)) { unset($this->container['page']); $this->container['page'] = $page = $event->page; } else { throw new RuntimeException('Page Not Found', 404); }
That last hook is the clean insertion point.
Proposed approach — small custom plugin (~40 lines)
Mirror the existing user/plugins/cache-on-save/ custom-plugin pattern. Subscribe to
onPageNotFound and, for authenticated users only, resolve the requested route including
unpublished pages and hand it back:
public function onPageNotFound(Event $e) {
$user = $this->grav['user'];
if (!$user->authenticated) {
return; // owners only — public still 404s
}
$route = $this->grav['uri']->path();
$page = $this->grav['pages']->find($route, true); // include unpublished
if ($page && !$page->published()) {
$e->page = $page; // serve the draft → 200
$e->stopPropagation();
}
}
The page then renders with its normal template. Because the theme is already owner-aware, the trip page will display correctly for the logged-in owner.
Decisions to make before building (brainstorm first)
- Scope of page types. All unpublished pages, or just the trip tree
(
/trips/*)? Entries and stories already show inline as drafts on the owner's trip feed; do they also need standalone-route preview? Leaning: gate to trip/entry/story templates to avoid unintentionally exposing every draft everywhere. - Draft banner. Add a trip-level "Draft — not published" banner when viewing an unpublished trip (entry-level draft badges already exist; this is the trip equivalent).
- Non-existent vs. unpublished. Ensure a genuinely missing route still 404s — the
find(..., true)+!published()check already distinguishes them, but cover it in a test.
The one real risk — page-cache leak to the public
If Grav caches the 200 we serve to the owner and later hands it to an anonymous visitor, the "owners only" gate is defeated. Verify, don't assume:
- Grav's Login plugin disables page caching for authenticated sessions by default.
- The theme already serves owner-only draft content inline today, so this exposure is presumably mitigated somewhere already.
Add an explicit anonymous-request assertion (draft route → 404 for anon, even after an authenticated hit warmed any cache).
Testing
Playwright spec:
- Authenticated owner →
GET /trips/<draft-slug>returns 200 and renders the trip page. - Anonymous → same route returns 404.
- Anonymous after an authenticated hit → still 404 (cache-leak guard).
- Genuinely missing route → 404 for everyone.
Effort
Low — roughly half a day including the Playwright spec. Single custom plugin plus an optional small theme partial for the draft banner.
References
- Investigation session: 2026-07-08 ("hotfixes").
- Pattern to copy:
user/plugins/cache-on-save/. - Related owner-aware theme logic:
templates/trip.html.twig(owner_can_edit),templates/trips.html.twig(is_owner),templates/home.html.twig.