diff --git a/docs/future/2026-07-08-view-unpublished-trips-when-logged-in.md b/docs/future/2026-07-08-view-unpublished-trips-when-logged-in.md new file mode 100644 index 0000000..8ba01d8 --- /dev/null +++ b/docs/future/2026-07-08-view-unpublished-trips-when-logged-in.md @@ -0,0 +1,113 @@ +# 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: + ```php + return $this->routable && $this->published(); + ``` + So `published: false` ⇒ `routable()` is `false`, regardless of the `routable` flag. + +- `PagesProcessor.php:67` gates the request on exactly that: + ```php + 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: + ```php + 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: + +```php +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/` 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`.