Files
intotheeast-com/docs/future/2026-07-08-view-unpublished-trips-when-logged-in.md
T

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-2026HTTP 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: falseroutable() is false, regardless of the routable flag.

  • PagesProcessor.php:67 gates the request on exactly that:

    if (!$page->routable()) {
        // build 404, fire onPageNotFound...
    }
    
  • PagesProcessor.php ~line 80: after firing onPageNotFound, 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)

  1. 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.
  2. 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).
  3. 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.