Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e381f2cc0f | |||
| 070da81907 | |||
| ee5ab31492 | |||
| b53892c316 | |||
| 7c5b8a8cb7 | |||
| 89611eaaf9 | |||
| 4f1cda33d7 | |||
| a03302e8fe | |||
| 02936b15bc | |||
| 64d21c8caf | |||
| 143eca5a1f | |||
| 5ba1a4ae1d | |||
| 56f3d9c59f | |||
| f9324470bf | |||
| edf11f967c | |||
| 6d36ffaef0 | |||
| 960de8f1df | |||
| 460da612f1 | |||
| 4117a6fce3 | |||
| 78b1d91568 |
@@ -959,10 +959,23 @@ Plans:
|
||||
3. Public pages that PHP Journal + the current theme rendered have a named successor on Go; sitemap is in if the site still needs it.
|
||||
4. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 2/5 plans executed
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 16 to break down)
|
||||
**Wave 1**
|
||||
- [x] 16-01-PLAN.md — vue-journal-layer: prerequisite gate (repos, D-20/D-21), git-tag install spike, Journal views, typed client, routes config, tokens, pl/en, SEO/sitemap/RSS; tag v0.1.0
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 16-02-PLAN.md — vue-grzybyfunkcjonalne-app: SSR site on the layer with the PHP URL map, homepage/menu/FAQ/kontakt content port, GA via runtime config; submodule in the host
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16-03-PLAN.md — sm-grzybyfunkcjonalne-app: /gfadmin, app plugin with featured-image and category-content commands, migration runbook, two-process deploy files, urls.txt and local rehearsal
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 16-04-PLAN.md — Staging deploy on the PHP site's server (human checkpoint) and smoke over every old URL
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 16-05-PLAN.md — Unit tests: layer and app node --test (incl. SSR), Go command and boot tests, phase gate
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
@@ -1091,7 +1104,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 14.2. Local Fonoteka frontend on local SummerCMS backend (INSERTED) | 0/TBD | Not started | - |
|
||||
| 14.3. One-command app scaffolding (INSERTED) | 4/4 | In Progress| |
|
||||
| 15. Journal plugin | 4/4 | Complete | 2026-10-06 |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 0/TBD | Not started | - |
|
||||
| 16. grzybyfunkcjonalne.pl on reusable blog views | 2/5 | In Progress| |
|
||||
| 17. Blog companions | 0/TBD | Not started | - |
|
||||
| 18. Payment Gateway | 0/TBD | Not started | - |
|
||||
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "14.3"
|
||||
current_phase_name: "One-command app scaffolding: summer new and summercms.io/new bootstrap script"
|
||||
status: verifying
|
||||
stopped_at: Completed 14.3-04-PLAN.md
|
||||
last_updated: "2026-10-08T14:24:54.530Z"
|
||||
last_activity: 2026-10-08
|
||||
last_activity_desc: Phase 14.3 execution started
|
||||
state_head: 50c8cee54129d8cf639e0893a1dca0b3dabd89bd
|
||||
current_phase: 16
|
||||
current_phase_name: grzybyfunkcjonalne.pl on reusable blog views
|
||||
status: executing
|
||||
stopped_at: Completed 16-02-PLAN.md
|
||||
last_updated: "2026-10-09T16:30:06.798Z"
|
||||
last_activity: 2026-10-09
|
||||
last_activity_desc: Phase 16 execution started
|
||||
state_head: 070da81907bdde9d5c855a37db2538f6eb4232e4
|
||||
progress:
|
||||
total_phases: 31
|
||||
completed_phases: 13
|
||||
total_plans: 143
|
||||
completed_plans: 139
|
||||
total_plans: 148
|
||||
completed_plans: 141
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
|
||||
@@ -24,14 +25,14 @@ milestone_name: milestone
|
||||
See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
|
||||
**Current focus:** Phase 14.3 — One-command app scaffolding: summer new and summercms.io/new bootstrap script
|
||||
**Current focus:** Phase 16 — grzybyfunkcjonalne.pl on reusable blog views
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING
|
||||
Plan: 4 of 4
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-10-08 - Completed quick task 261008-o6k: summercms.io/update updates one application in place
|
||||
Phase: 16 (grzybyfunkcjonalne.pl on reusable blog views) — EXECUTING
|
||||
Plan: 3 of 5
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-09 - Completed quick task 261009-mys: subtitle, avatar and My account page for backend users
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -193,6 +194,8 @@ Progress: [██████████] 100%
|
||||
| Phase 14.3 P02 | 11 min | 3 tasks | 11 files |
|
||||
| Phase 14.3 P03 | 9min | 3 tasks | 13 files |
|
||||
| Phase 14.3 P04 | 19min | 3 tasks | 32 files |
|
||||
| Phase 16 P01 | 1h 34m | 4 tasks | 55 files |
|
||||
| Phase 16 P02 | 16 min | 3 tasks | 44 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -618,6 +621,13 @@ Recent decisions affecting current work:
|
||||
- [Phase 14.3]: Phase 14.3 gate is scripts/check-phase14.3.sh --all: one process per stage (unit, generator, bootstrap, terminal, acceptance, security), a named PASS line per required test, goldens unchanged by the run
|
||||
- [Phase 14.3]: summer new goldens live in internal/build/testdata/new and new-no-plugin and are rewritten only with go test -update; the golden app key is base64 of bytes 0x00..0x1f
|
||||
- [Phase 14.3]: Tests that commit in temp git repos disable detached auto maintenance (maintenance.auto=false, gc.auto=0) to avoid cleanup races with git 2.55
|
||||
- [Phase 16]: vue-journal-layer pages gate and slim API rows in useAsyncData transforms (toPostView/toListPost) so the SSR payload never carries unsafe HTML, raw content or translations
|
||||
- [Phase 16]: Journal SSR, sitemap and RSS fetches forward x-forwarded-for with the Nuxt peer address appended
|
||||
- [Phase 16]: vue-grzybyfunkcjonalne-app is a private repo checked over SSH; only vue-journal-layer must be public (D-04)
|
||||
- [Phase 16]: Journal author links use author.login (only key on post rows); the author page canonicalises to the endpoint slug
|
||||
- [Phase 16]: Under routeRules swr the site app records the visitor address in event.context (Nitro request hook) and overrides useJournalApi, because Nitro's cached handler strips request headers; the proper fix belongs in vue-journal-layer
|
||||
- [Phase 16]: Site page URLs answer errors with the HTML error page even for fetch-like requests (server/plugins/html-errors.ts); /api/, /_ and .json paths keep Nuxt JSON errors
|
||||
- [Phase 16]: Site rich-text copy stays in pl.json (i18n compilation.strictMessage false); contact data lives in app/data/contact.ts
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -673,6 +683,9 @@ Recent decisions affecting current work:
|
||||
| 261008-kdf | summer delegates pass flags through, admin/route/key delegates, signal forwarding | 2026-10-08 | 50c8cee | [261008-kdf-delegated-summer-commands-pass-flags-thr](./quick/261008-kdf-delegated-summer-commands-pass-flags-thr/) |
|
||||
| 261008-kdh | serve prints URL and admin status; summer new apps admin-ready | 2026-10-08 | f70d80e | [261008-kdh-serve-startup-and-admin-feedback-admin-r](./quick/261008-kdh-serve-startup-and-admin-feedback-admin-r/) |
|
||||
| 261008-o6k | summercms.io/update: update one application in place (framework checkout, CLI, .env admin secret, build) | 2026-10-08 | 9b3803c | [261008-o6k-bootstrap-upgrade-mode-for-existing-chec](./quick/261008-o6k-bootstrap-upgrade-mode-for-existing-chec/) |
|
||||
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
|
||||
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
|
||||
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -684,6 +697,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-08T10:32:00.180Z
|
||||
Stopped at: Completed 14.3-04-PLAN.md
|
||||
Last session: 2026-10-09T16:30:06.282Z
|
||||
Stopped at: Completed 16-02-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/pnpm-lock.yaml
|
||||
- ../vue-journal-layer/pnpm-workspace.yaml
|
||||
- ../vue-journal-layer/.nvmrc
|
||||
- ../vue-journal-layer/.gitignore
|
||||
- ../vue-journal-layer/tsconfig.json
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/lib/journal-module.ts
|
||||
- ../vue-journal-layer/app/app.vue
|
||||
- ../vue-journal-layer/app/layouts/default.vue
|
||||
- ../vue-journal-layer/app/utils/types.ts
|
||||
- ../vue-journal-layer/app/utils/client.ts
|
||||
- ../vue-journal-layer/app/utils/routes.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/utils/pagination.ts
|
||||
- ../vue-journal-layer/app/utils/sitemap.ts
|
||||
- ../vue-journal-layer/app/utils/seo.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalApi.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalLinks.ts
|
||||
- ../vue-journal-layer/app/journal-pages/PostIndexPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/CategoryPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/TagPage.vue
|
||||
- ../vue-journal-layer/app/journal-pages/AuthorPage.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostCard.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostList.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostMeta.vue
|
||||
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
|
||||
- ../vue-journal-layer/app/components/Journal/Pagination.vue
|
||||
- ../vue-journal-layer/app/components/Journal/RelatedPosts.vue
|
||||
- ../vue-journal-layer/app/components/Journal/IndexHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/AuthorCard.vue
|
||||
- ../vue-journal-layer/app/components/Journal/RssLink.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-tokens.css
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/i18n/locales/pl.json
|
||||
- ../vue-journal-layer/i18n/locales/en.json
|
||||
- ../vue-journal-layer/server/routes/rss.xml.ts
|
||||
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/testing/nuxt-server.mjs
|
||||
- ../vue-journal-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/categories.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/tags.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/authors.json
|
||||
- ../vue-journal-layer/testing/fixtures/acme/rss.xml
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/scripts/check-git-install.sh
|
||||
- ../vue-journal-layer/.playground/nuxt.config.ts
|
||||
- ../vue-journal-layer/.playground/app/app.vue
|
||||
- ../vue-journal-layer/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-2, SC-3]
|
||||
user_setup:
|
||||
- service: git.golem15.com (Gitea 1.25.4)
|
||||
why: "D-02/D-04/D-08: the layer and the site app live in two new public repos; creating a repo needs a Gitea account"
|
||||
dashboard_config:
|
||||
- task: "Create public empty repo golem15/vue-journal-layer (default branch main)"
|
||||
location: "https://git.golem15.com/repo/create (owner golem15)"
|
||||
- task: "Create public empty repo golem15/vue-grzybyfunkcjonalne-app (default branch main)"
|
||||
location: "https://git.golem15.com/repo/create (owner golem15)"
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-02: the Journal views live in the new repo vue-journal-layer, a Nuxt layer that a site app consumes with extends: ['vue-journal-layer']."
|
||||
- "D-04: a scratch consumer installs the layer from git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag> anonymously, pnpm-lock.yaml records the tag commit, and nuxt build of that consumer succeeds (the Wave 0 spike, RESEARCH A1/Pitfall 10)."
|
||||
- "D-05: the layer renders the post list with pagination (page size and sort from journal config), the single post (content_html, featured image, meta, related posts with a latest-posts fallback), category and tag listings, the author page and an RSS link, all through a typed client that calls only anonymous GET routes of the Journal API."
|
||||
- "D-06: every Journal route path comes from the journal.routes config key; false disables a route; route names journal-index, journal-post, journal-category, journal-tag, journal-author stay stable so links never hard-code a path."
|
||||
- "D-03: the layer looks clean on its own with --journal-* CSS custom-property tokens only; no site colours or site copy are in the layer."
|
||||
- "D-16: the layer ships pl and en UI strings, registers them only for locales the consuming app enables, and declares no locale itself, so an app that enables only pl gets no /en routes."
|
||||
- "D-13: @nuxtjs/seo runs in the layer: per-post title, description, og:image and canonical (canonical keeps ?page=N), robots, and a sitemap source that pages the Journal API and builds every loc from journal.routes."
|
||||
- "D-24: the client always requests categories with include_empty=1, so an empty category page answers 200 with its content and an empty list; an unknown category slug answers 404."
|
||||
- "D-20: /autor-style author pages consume the anonymous author endpoint added by the pre-execution quick task; the plan stops at a checkpoint when that endpoint is absent and does not implement it."
|
||||
- "D-21: category pages render the category rich content field (added by the pre-execution quick task) above the post list, through the same HTML gate as post bodies."
|
||||
- "D-14 and Phase 15 D-14: the layer adds no Go endpoint and never calls a Journal write route."
|
||||
- "Every v-html sink (post body, category content, author bio) passes the Go FormatHTML three-pattern gate first; an unsafe value renders a neutral notice, never the raw HTML."
|
||||
- "SSR fetches use the server-only internal base and forward the visitor x-forwarded-for, so Go's journal-public-api bucket stays per visitor (Pitfall 5)."
|
||||
- "The layer repo never names a consuming application; README examples use blog and acme (CLAUDE.md documentation rule)."
|
||||
artifacts:
|
||||
- path: "../vue-journal-layer/lib/journal-module.ts"
|
||||
provides: "configKey journal; extendPages from journal.routes; public runtime config; i18n:registerModule for enabled locales"
|
||||
contains: "extendPages"
|
||||
- path: "../vue-journal-layer/app/utils/client.ts"
|
||||
provides: "createJournalClient over an injected fetcher; include_empty=1; per_page clamp 1..30"
|
||||
contains: "include_empty"
|
||||
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
provides: "isSafeJournalHtml and gateJournalHtml with the three Go regex sources"
|
||||
contains: "vbscript"
|
||||
- path: "../vue-journal-layer/app/utils/routes.ts"
|
||||
provides: "DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes, fillJournalPath"
|
||||
contains: "journal-post"
|
||||
- path: "../vue-journal-layer/server/api/__sitemap__/journal.ts"
|
||||
provides: "sitemap source paging posts per_page=30 plus categories include_empty=1"
|
||||
contains: "defineSitemapEventHandler"
|
||||
- path: "../vue-journal-layer/scripts/check-git-install.sh"
|
||||
provides: "the D-04 spike: install from a git tag, nuxt build, SSR smoke against the fixture API"
|
||||
contains: "git+https://git.golem15.com/golem15/vue-journal-layer.git"
|
||||
- path: "../vue-journal-layer/testing/smoke-ssr.mjs"
|
||||
provides: "boots a built .output against the fixture API and checks status/contains/type lines"
|
||||
contains: "smoke-ssr: ok"
|
||||
- path: "../vue-journal-layer/README.md"
|
||||
provides: "neutral layer README (blog/acme), config keys, tokens, release by git tag"
|
||||
contains: "extends"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/lib/journal-module.ts"
|
||||
to: "../vue-journal-layer/app/journal-pages/PostPage.vue"
|
||||
via: "extendPages pushes { name: journal-post, path: journal.routes.post, file } for every non-false route"
|
||||
pattern: "journal-pages"
|
||||
- from: "../vue-journal-layer/app/composables/useJournalApi.ts"
|
||||
to: "../vue-journal-layer/app/utils/client.ts"
|
||||
via: "server uses runtimeConfig.journal.apiBase and forwards x-forwarded-for; browser uses runtimeConfig.public.journal.apiBase"
|
||||
pattern: "x-forwarded-for"
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-journal-layer/app/utils/htmlGate.ts"
|
||||
via: "v-html only receives gateJournalHtml(...).html when safe"
|
||||
pattern: "gateJournalHtml"
|
||||
- from: "../vue-journal-layer/server/api/__sitemap__/journal.ts"
|
||||
to: "../vue-journal-layer/app/utils/sitemap.ts"
|
||||
via: "buildJournalSitemapEntries(posts, categories, routes) fills loc from journal.routes"
|
||||
pattern: "buildJournalSitemapEntries"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: the ROADMAP `**Goal:**` line is not an "As a / I want to / so that" story. No story is invented here; run `/msd-mvp-phase 16` to reframe it if a story is wanted.
|
||||
|
||||
This plan's slice: a scratch site installs `vue-journal-layer` from a git tag and serves one Journal post as full SSR HTML from the Journal API, then the remaining Journal views, SEO, sitemap and RSS grow out of that path and ship as tag `v0.1.0`.
|
||||
|
||||
<objective>
|
||||
Create the reusable Journal views layer (D-02) with configurable routes (D-06), a typed anonymous-GET Journal API client (D-05), token-driven neutral design (D-03), pl/en strings (D-16), @nuxtjs/seo and sitemap (D-13), and prove the D-04 git-tag distribution end to end before any site work starts.
|
||||
|
||||
Purpose: SC-2 (the blog views live in a reusable place) and the layer half of SC-3 (every PHP Journal page has a named successor).
|
||||
Output: the `vue-journal-layer` repo pushed to git.golem15.com with tags `v0.0.1` (spike) and `v0.1.0` (release consumed by 16-02).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/package.json
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts
|
||||
@../sm-journal-plugin/routes.go
|
||||
@../sm-journal-plugin/controllers/api/posts.go
|
||||
@../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
@../sm-journal-plugin/classes/format_html.go
|
||||
|
||||
<interfaces>
|
||||
Journal API facts the layer builds against (read from sm-journal-plugin at d09edde or newer; do not re-derive):
|
||||
- Anonymous GET group under /_journal/api/v1 with middleware throttle:journal-public-api (120/min keyed on surf.ClientIP; 127.0.0.1/32 is a trusted proxy in the host config/http.yaml).
|
||||
- GET posts: query page, per_page (default 9, max 30), sort ("<column> <asc|desc>", columns published_at, created_at, updated_at, title, id; default "published_at desc"), category (slug or id, includes nested children), tag (slug), search (min 3 chars). Envelope: data (array) and meta { current_page, last_page, per_page, total }.
|
||||
- GET posts/{slug} (slug route regex [a-z0-9][a-z0-9\-/]*): { data: post + previous_post, next_post, related_posts }. Unknown, draft or future post: 404 with body { error: "Post not found" } and no data key.
|
||||
- Post fields: id, title, slug, excerpt, content, content_html, reading_time, published, published_at ("2006-01-02T15:04:05+00:00" UTC), is_pinned, metadata, sources, author ({ id, first_name, last_name, login } or null), categories [{ id, name, slug }], tags [{ id, name, slug }], featured_images [{ id, url, file_name, file_size }], featured_image_url (string or null), translations, created_at, updated_at.
|
||||
- related_posts: latest 3 in the same categories; empty when the post has no category.
|
||||
- GET categories[?include_empty=1]: { data: tree of { id, name, slug, description, parent_id, nest_depth, post_count, children? } }; categories with zero published posts are omitted unless include_empty=1.
|
||||
- GET tags: { data: [{ id, name, slug, post_count }] }.
|
||||
- GET rss: RSS 2.0, Content-Type application/rss+xml; item links are config url + "/blog/" + slug.
|
||||
- Pre-execution additions (assumed shapes, confirmed by Task 1): D-20 anonymous author endpoint (assumed GET /_journal/api/v1/authors/{slug} answering { data: { id, slug, first_name, last_name, plus public profile fields } } with the author's posts as a paginated list, assumed GET /_journal/api/v1/authors/{slug}/posts or posts?author=<slug>); D-21 category rich content field (assumed key content, an HTML string or null, on every categories row).
|
||||
- FormatHTML gate regex sources (classes/format_html.go): (?i)<(?:script|iframe|object|embed)\b, (?i)\son[a-z]+\s*=, (?i)(?:javascript|vbscript|data):
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP success criteria SC-1..SC-4 and CONTEXT decisions D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Repo `git.golem15.com/golem15/vue-journal-layer` (local checkout `../vue-journal-layer`), package name `vue-journal-layer`, `"main": "./nuxt.config.ts"`, tags `v0.0.1` and `v0.1.0`.
|
||||
- Nuxt module `lib/journal-module.ts` (meta.name `vue-journal-layer`, configKey `journal`) with options `journal.routes` (`index`, `post`, `category`, `tag`, `author`: path string or `false`), `journal.perPage` (`index`, `category`, `tag`, `author`), `journal.sort`, `journal.relatedCount`, `journal.rss`.
|
||||
- Route names `journal-index`, `journal-post`, `journal-category`, `journal-tag`, `journal-author`; neutral default paths `/blog`, `/blog/:slug`, `/blog/category/:category`, `/blog/tag/:tag`, `/blog/author/:author`.
|
||||
- Runtime config keys `runtimeConfig.journal.apiBase` (env `NUXT_JOURNAL_API_BASE`, default `http://127.0.0.1:8096/_journal/api/v1`), `runtimeConfig.public.journal.apiBase` (env `NUXT_PUBLIC_JOURNAL_API_BASE`, default `/_journal/api/v1`), `runtimeConfig.public.journal.routes`, `.perPage`, `.sort`, `.relatedCount`, `.rss`.
|
||||
- Pure TS utils (auto-imported in Nuxt, importable by node --test): `types.ts` (`JournalPost`, `JournalPostDetail`, `JournalCategory`, `JournalTag`, `JournalAuthor`, `JournalAuthorRef`, `JournalFeaturedImage`, `JournalPage<T>`, `JournalItem<T>`, `JournalRoutes`, `JournalModuleOptions`), `client.ts` (`JOURNAL_MAX_PER_PAGE`, `clampPerPage`, `buildPostListQuery`, `encodeSlugPath`, `flattenCategories`, `createJournalClient`), `routes.ts` (`DEFAULT_JOURNAL_ROUTES`, `JOURNAL_ROUTE_NAMES`, `normalizeJournalRoutes`, `fillJournalPath`), `htmlGate.ts` (`UNSAFE_TAG`, `EVENT_HANDLER`, `DANGEROUS_URL`, `isSafeJournalHtml`, `gateJournalHtml`), `pagination.ts` (`parsePageParam`, `pageOutOfRange`, `paginationWindow`), `sitemap.ts` (`buildJournalSitemapEntries`), `seo.ts` (`postSeoMeta`, `listTitle`).
|
||||
- Composables `useJournalApi()` and `useJournalLinks()`.
|
||||
- Page components `app/journal-pages/{PostIndexPage,PostPage,CategoryPage,TagPage,AuthorPage}.vue`.
|
||||
- Overridable components `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink`.
|
||||
- CSS tokens `--journal-color-*`, `--journal-font-*`, `--journal-space-*`, `--journal-radius-*` in `app/assets/css/journal-tokens.css`; neutral styles in `journal-base.css`.
|
||||
- i18n message files `i18n/locales/{pl,en}.json` under the `journal.*` key tree.
|
||||
- Nitro routes `/rss.xml` (proxy of the Journal API rss with x-forwarded-for) and `/api/__sitemap__/journal` (sitemap source).
|
||||
- Test tooling `testing/fixture-api.mjs` (`startFixtureApi`), `testing/nuxt-server.mjs` (`startNuxtServer`), `testing/smoke-ssr.mjs` (CLI), fixtures `testing/fixtures/acme/*`, expectations `testing/expect-acme.txt`, spike script `scripts/check-git-install.sh <tag>`.
|
||||
|
||||
<!-- planner-discipline-allow: grzyb -->
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Prerequisite gate — two Gitea repos exist and the D-20/D-21 Journal additions are in sm-journal-plugin</name>
|
||||
<precondition>The sibling checkout ../sm-journal-plugin exists and builds (go -C ../sm-journal-plugin vet ./... exits 0).</precondition>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-02, D-04, D-08, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 11, Open Question 1, Journal API table), ../sm-journal-plugin/routes.go, ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/controllers/api/posts_helpers.go (serializeCategoryNode), ../sm-journal-plugin/controllers/api/posts.go (serializeAuthor), ../sm-journal-plugin/README.md</read_first>
|
||||
<action>Before stopping, run every check in the verify block and report which pass. Then stop for the human: (a) create the public empty repos golem15/vue-journal-layer and golem15/vue-grzybyfunkcjonalne-app on git.golem15.com if the ls-remote checks fail (D-02, D-08; Pitfall 11); (b) run the pre-execution quick tasks in ../sm-journal-plugin if the D-20 author endpoint or the D-21 category content field is missing. Do not implement either Journal addition in this phase (D-20, D-21 are prerequisites, not plan work) and create no Journal route or handler in this phase.
|
||||
|
||||
After the human resumes and every check passes, read the landed code and record a "Prerequisite contract" section in the plan SUMMARY: the exact D-20 route path or paths, the author object field names (slug and every public profile field, for example bio or avatar if present), how the author's posts are listed (path, query keys, envelope), the 404 body; and the exact D-21 key on the categories rows (assumed content) plus whether it is HTML or markdown. Tasks 2–4 use these recorded names; where they differ from the assumptions in this plan's interfaces block, the plugin wins (it is the contract) and the deviation is noted in the SUMMARY.</action>
|
||||
<instructions>
|
||||
Claude ran the checks below and reports the result of each.
|
||||
The human: create the two public repos on git.golem15.com (owner golem15, empty, default branch main) and make sure the D-20 author endpoint and the D-21 category content field quick tasks have landed in ../sm-journal-plugin.
|
||||
</instructions>
|
||||
<verification>All four checks in the verify block exit 0.</verification>
|
||||
<resume-signal>Type "prerequisites ready" once the repos exist and both quick tasks have landed.</resume-signal>
|
||||
<verify>
|
||||
<automated>GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git >/dev/null && GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git >/dev/null && grep -qi 'author' ../sm-journal-plugin/routes.go && grep -qi 'content' ../sm-journal-plugin/models/category.go && awk '/^func serializeCategoryNode/,/^}/' ../sm-journal-plugin/controllers/api/posts_helpers.go | grep -qi 'content' && go -C ../sm-journal-plugin vet ./... && echo gate-ok</automated>
|
||||
<fails_when>Non-zero exit, or the final line is not "gate-ok": a repo ls-remote fails (repo missing or not public), routes.go has no author route, models/category.go has no content field, or serializeCategoryNode does not emit it.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote` succeeds anonymously for both https://git.golem15.com/golem15/vue-journal-layer.git and https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
- ../sm-journal-plugin/routes.go registers an anonymous author route (D-20) and models/category.go declares a content field that serializeCategoryNode emits (D-21).
|
||||
- The SUMMARY records the confirmed D-20 route paths and author fields and the D-21 categories key.
|
||||
- No file in ../sm-journal-plugin was modified by this plan.
|
||||
</acceptance_criteria>
|
||||
<done>Both repos exist and the author endpoint and category content field are confirmed, with their exact names recorded for Tasks 2–4.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer — a scratch site installs the layer from git tag v0.0.1 and serves one Journal post as SSR HTML</name>
|
||||
<reversibility rating="costly">D-02: every other blog builds on this layer's module, route names and component names; changing them means touching every consuming app.</reversibility>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/pnpm-lock.yaml, ../vue-journal-layer/pnpm-workspace.yaml, ../vue-journal-layer/.nvmrc, ../vue-journal-layer/.gitignore, ../vue-journal-layer/tsconfig.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/app.vue, ../vue-journal-layer/app/layouts/default.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/composables/useJournalApi.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/testing/fixtures/acme/tags.json, ../vue-journal-layer/testing/fixtures/acme/authors.json, ../vue-journal-layer/testing/fixtures/acme/rss.xml, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/.playground/app/app.vue</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 1, Pattern 2, Anti-Patterns, Pitfalls 4, 5, 9, 10, Standard Stack, Package Legitimacy Audit), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (package.json, nuxt.config.ts, modules/journal.ts, app/utils), ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/pnpm-workspace.yaml, ../sm-summercmsio-app/vue-summercmsio-app/.gitignore, ../sm-summercmsio-app/vue-summercmsio-app/app/utils/scrollSpy.ts, ../sm-journal-plugin/controllers/api/posts.go (serializePost, Show), ../sm-journal-plugin/classes/format_html.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY</read_first>
|
||||
<action>Clone the empty repo into ../vue-journal-layer (git clone git@git.golem15.com:golem15/vue-journal-layer.git), branch main. Copy .nvmrc (22.22.0), tsconfig.json (extends ./.nuxt/tsconfig.json) and pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) from ../sm-summercmsio-app/vue-summercmsio-app; .gitignore adds node_modules/, .nuxt/, .output/, .data/, .playground/.nuxt/, .playground/.output/.
|
||||
|
||||
package.json: name vue-journal-layer, version 0.0.1, private true, type module, main ./nuxt.config.ts, engines node >=22.18. dependencies exactly @nuxt/fonts 0.14.0, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1 (versions already locked in vue-summercmsio-app/pnpm-lock.yaml; Package Legitimacy Audit approved). devDependencies exactly nuxt 4.4.8, vue 3.5.35, typescript 5.9.3; peerDependencies nuxt 4.4.8 so the consumer's nuxt resolves for imports from nuxt/kit. Scripts: dev = nuxi dev .playground, build:playground = nuxt build .playground, dev:prepare = nuxt prepare .playground, check:git-install = bash scripts/check-git-install.sh. Do not add an npm lifecycle script named prepare or any install-time build: a git-hosted dependency that needs preparation breaks pnpm install (Pitfall 10). No sanitize-html (RESEARCH A4: the Go three-pattern gate is the sanitizer). Run pnpm install to write pnpm-lock.yaml.
|
||||
|
||||
nuxt.config.ts (layer): compute currentDir from fileURLToPath(new URL('.', import.meta.url)) and build every path from it, never relative or alias paths (Pitfall 9). Set pages: true (the consumer may have no app/pages directory). modules: '@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo' and the absolute path of lib/journal-module.ts, declared once here so the app never re-lists them (Pitfall 4). The module sits in lib/ rather than modules/ so Nuxt's local-modules auto-scan cannot register it a second time. runtimeConfig.journal.apiBase = http://127.0.0.1:8096/_journal/api/v1 (server only); runtimeConfig.public.journal.apiBase = /_journal/api/v1. ogImage enabled false. i18n: no locales key in the layer (D-16, see Task 3).
|
||||
|
||||
lib/journal-module.ts: defineNuxtModule from nuxt/kit with meta.name vue-journal-layer and configKey journal. Defaults: routes = DEFAULT_JOURNAL_ROUTES (index /blog, post /blog/:slug, category /blog/category/:category, tag /blog/tag/:tag, author /blog/author/:author), perPage { index 9, category 9, tag 9, author 9 }, sort published_at desc, relatedCount 3, rss true. In setup call normalizeJournalRoutes(options.routes), then extendPages pushing { name: JOURNAL_ROUTE_NAMES[key], path, file: absolute path of app/journal-pages/<Page>.vue } for each key whose path is not false (D-06). Copy routes, perPage, sort, relatedCount and rss into nuxt.options.runtimeConfig.public.journal next to apiBase. Import helpers from ../app/utils/routes.ts with a relative path; never use the tilde or at-sign alias in layer source (they resolve to the consuming project).
|
||||
|
||||
app/utils (pure TS, no Nuxt auto-imports, type-only imports between utils, so node --test can import them with explicit .ts paths like scrollSpy.ts): types.ts with the interfaces listed under Artifacts, mirroring the Go serializer field names exactly (snake_case, nullable where Go can emit null) plus JournalAuthor from the Task 1 contract. routes.ts: DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes (each value is false or a string starting with /; post must contain :slug, category :category, tag :tag, author :author; otherwise throw an Error whose message names journal.routes.<key>), fillJournalPath(pattern, params) replacing :name segments with encodeURIComponent values per segment. client.ts: JOURNAL_MAX_PER_PAGE = 30, clampPerPage (1..30, non-numeric to the default 9), encodeSlugPath (encode each / separated segment), createJournalClient(fetcher) where fetcher is (path, query?) => Promise<unknown>; methods getPost(slug) returning the data object or null on a 404 (detect status or statusCode 404 on the thrown error), listPosts, listCategories, findCategory, listTags, getAuthor, listAuthorPosts (bodies may land in Task 3/4, but signatures and types are final now); categories always carry include_empty=1 (D-24). htmlGate.ts: UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL as the three verbatim Go regex sources with the i flag, isSafeJournalHtml(html), gateJournalHtml(html) returning { safe, html } with html set to the empty string when unsafe.
|
||||
|
||||
app/composables/useJournalApi.ts: on the server use useRuntimeConfig().journal.apiBase and pass useRequestHeaders(['x-forwarded-for']) as request headers (Pitfall 5); in the browser use runtimeConfig.public.journal.apiBase; wrap $fetch as the fetcher and return createJournalClient(fetcher). The base never comes from request input (SSRF).
|
||||
|
||||
app/journal-pages/PostPage.vue: read route.params.slug, useAsyncData keyed by journal-post-<slug> calling getPost; when null throw createError({ statusCode: 404, fatal: true }); render the title in an h1 and <JournalPostBody :html="post.content_html" />. app/components/Journal/PostBody.vue: v-html only receives gateJournalHtml(html).html when safe; otherwise render a paragraph with class journal-post-body--blocked and the fixed English text "This content cannot be displayed." (Task 3 replaces it with the i18n key journal.post.contentUnavailable). app/app.vue renders NuxtLayout around NuxtPage; app/layouts/default.vue wraps the slot in a main element with class journal-layout.
|
||||
|
||||
testing (plain .mjs with JSDoc types, because Node does not strip types under node_modules and the site app imports these from there): fixture-api.mjs exports startFixtureApi({ dir, port = 0 }) resolving { url, requests, close }: a node:http server answering the anonymous Journal API routes under /_journal/api/v1 from the JSON files in dir (posts.json array of full post objects, categories.json tree, tags.json, authors.json, rss.xml), applying page, per_page (clamped like Go), sort, category (matches post.categories[].slug), tag, include_empty, and the D-20 author routes from the Task 1 contract; show answers the post plus previous_post, next_post and related_posts (same category, latest 3) or 404 { error: "Post not found" }; every request's method, url and x-forwarded-for header is pushed onto requests. nuxt-server.mjs exports startNuxtServer({ outputDir, apiBase, port, env }) spawning node <outputDir>/server/index.mjs with NITRO_HOST=127.0.0.1, NITRO_PORT (a free port when 0), NUXT_JOURNAL_API_BASE=apiBase and the extra env, waiting up to 30 s for any HTTP answer on /, resolving { url, close }. smoke-ssr.mjs is a CLI taking --output, --fixtures, --expect and repeatable --env KEY=VALUE; it boots both servers, then for each non-comment line of the expect file (grammar: STATUS PATH, then optional contains=TEXT, notcontains=TEXT, type=PREFIX; values run to the end of the line) issues a GET without following redirects, with the header X-Forwarded-For: 203.0.113.7, and compares; after the lines it asserts that at least one fixture API request recorded an x-forwarded-for value containing 203.0.113.7 (Pitfall 5 forwarding), else it fails with reason "x-forwarded-for not forwarded"; it prints "smoke-ssr: ok (N checks)" and exits 0, or prints "smoke-ssr: FAIL <line>: <reason>" and exits 1. Fixtures (acme, neutral English): 13 published posts so a 9-per-page index has 2 pages, including hello-acme (title Hello Acme, content_html with a heading and a paragraph) and unsafe-sample whose content_html contains a script tag; categories news (content HTML present, post_count 12) and quiet (post_count 0); one tag; one author per the Task 1 contract. expect-acme.txt starts with: 200 /blog/hello-acme contains=Hello Acme; 404 /blog/missing-post; 200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(.
|
||||
|
||||
scripts/check-git-install.sh TAG: set -euo pipefail, die() to stderr, tool loop for git, pnpm, node; create a temp dir with mktemp -d and trap cleanup; write a consumer package.json (name acme-blog, private, type module, dependencies nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#TAG), the same pnpm-workspace.yaml allowBuilds, and a nuxt.config.ts with extends ['vue-journal-layer'], compatibilityDate 2025-07-15 and i18n { strategy prefix_except_default, defaultLocale en, locales [{ code en, language en-US }, { code pl, language pl-PL }] }; run pnpm install; compare the commit recorded for vue-journal-layer in pnpm-lock.yaml with git ls-remote of refs/tags/TAG (peeled ^{} when annotated) and die on mismatch; run pnpm exec nuxt build; run node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures node_modules/vue-journal-layer/testing/fixtures/acme --expect node_modules/vue-journal-layer/testing/expect-acme.txt; print "check-git-install: ok TAG <sha>". .playground/nuxt.config.ts extends '..' with the same i18n block; .playground/app/app.vue renders NuxtLayout and NuxtPage.
|
||||
|
||||
Commit (feat: scaffold Journal layer with configurable post route and git-install check), push main, create annotated tag v0.0.1, push the tag. Pushing the layer and its tags to git.golem15.com is part of this plan (D-04, D-19: the spike installs from the tag). If anything in the spike fails because of pnpm or Gitea mechanics (A1), stop and report the exact error rather than switching distribution (D-04 is locked); the fallback options in RESEARCH A1 need the user.</action>
|
||||
<verify>
|
||||
<automated>bash ../vue-journal-layer/scripts/check-git-install.sh v0.0.1</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL", "check-git-install:" followed by an error, or "ERR_PNPM"; the line "check-git-install: ok v0.0.1" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git lists refs/heads/main and refs/tags/v0.0.1.
|
||||
- The scratch consumer's pnpm-lock.yaml pins vue-journal-layer to the commit of tag v0.0.1 (checked inside the script).
|
||||
- GET /blog/hello-acme on the built scratch consumer answers 200 and contains "Hello Acme"; GET /blog/missing-post answers 404; GET /blog/unsafe-sample answers 200, contains journal-post-body--blocked and does not contain alert(.
|
||||
- ../vue-journal-layer/package.json has no lifecycle script named prepare and lists nuxt only under devDependencies and peerDependencies.
|
||||
- ../vue-journal-layer has no app/pages directory (layer pages live in app/journal-pages, RESEARCH anti-pattern).
|
||||
- `grep -rnE "from '(~|@)/" ../vue-journal-layer/app ../vue-journal-layer/lib` prints nothing.
|
||||
- smoke-ssr's forwarding assertion passes: the fixture API recorded x-forwarded-for containing 203.0.113.7 on SSR requests.
|
||||
</acceptance_criteria>
|
||||
<done>A site that only extends the layer from a git tag renders a Journal post as full SSR HTML, blocks unsafe HTML, and 404s unknown posts.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Post list, category pages, pagination, related posts, tokens and pl/en strings</name>
|
||||
<files>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/composables/useJournalLinks.ts, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/PostList.vue, ../vue-journal-layer/app/components/Journal/PostMeta.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../vue-journal-layer/testing/expect-acme.txt</files>
|
||||
<read_first>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 3, Pattern 4, URL Inventory routing rules, Pitfall 8, A5, A8, A9), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (CSS tokens, pages and components, i18n locale files), ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts, serializeCategoryNode)</read_first>
|
||||
<action>pagination.ts: parsePageParam(raw) returns 1 for an absent value, the integer for a string of digits greater than 0, and null for anything else (non-numeric, 0, negative, decimal, array); pageOutOfRange(page, lastPage) is true when page > 1 and page > lastPage (page 1 with zero posts is in range and renders the empty state); paginationWindow(current, last, size = 5) returns the page numbers to render. A null page or an out-of-range page throws createError({ statusCode: 404, fatal: true }) in the pages (RESEARCH A5: live answers a soft 200; the successor answers a real 404 — recorded as an assumption, covered by the smoke).
|
||||
|
||||
client.ts bodies: listPosts({ page, perPage, sort, category, tag }) builds the query through buildPostListQuery (per_page clamped, sort passed verbatim as "<column> <dir>"), returning JournalPage<JournalPost>; listCategories() always sends include_empty=1 (D-24, Pitfall 8) and returns the tree; findCategory(slug) flattens the tree with flattenCategories and returns the row or null; listTags().
|
||||
|
||||
useJournalLinks(): returns postLink(slug), categoryLink(slug), tagLink(slug), authorLink(slug), indexLink(page) built with useLocalePath() over the route names in JOURNAL_ROUTE_NAMES and params (never string-concatenated paths; RESEARCH A9: names gain a locale suffix, so useLocalePath is mandatory); a link to a disabled route (false) returns null and the component renders plain text.
|
||||
|
||||
PostIndexPage.vue (journal-index): page from parsePageParam(route.query.page); listPosts with perPage.index and sort from runtimeConfig.public.journal; 404 when null or out of range; renders <JournalIndexHeader :page>, <JournalPostList :posts>, <JournalPagination :current :last>. Page links use ?page=N query form (D-15; never a path segment). CategoryPage.vue (journal-category): findCategory(route.params.category); null throws 404 (an unknown root path must not soft-200, RESEARCH Pattern 3); then listPosts({ category: slug, perPage: perPage.category, page }); renders <JournalCategoryHeader :category> which shows name, description and the D-21 rich content (key from the Task 1 contract) through gateJournalHtml above the list (D-21); an empty category renders 200 with its header and the empty-state string (D-24). PostPage.vue: add <JournalPostMeta> (published date formatted with Intl.DateTimeFormat for the active i18n locale, reading_time, category links, author link when the post has an author and the author route is enabled), <JournalFeaturedImage> (featured_image_url, alt from the post title, loading lazy, width and height attributes from CSS tokens), previous and next links, and <JournalRelatedPosts> showing related_posts, or when related_posts is empty, the latest relatedCount posts excluding the current slug (RESEARCH: related_posts is empty for a post without a category). PostBody.vue uses the i18n key journal.post.contentUnavailable for the blocked notice.
|
||||
|
||||
Components (app/components/Journal/*, auto-registered with the Journal prefix so an app overrides any of them by creating the same file): PostCard (title link, excerpt, date, featured image), PostList (cards, empty state), Pagination (previous/next and window, rel prev/next), IndexHeader (h1 from journal.index.title plus journal.pagination.pageTitle when page > 1), CategoryHeader, RelatedPosts, PostMeta, FeaturedImage. Styles use only var(--journal-*) tokens; no hard-coded colours (D-03).
|
||||
|
||||
journal-tokens.css: one :root block, grouped, a trailing comment per variable naming its use (summercms.io tokens.css style): --journal-color-text, -muted, -bg, -surface, -border, -link, -link-hover, -accent; --journal-font-body, -heading, -mono; --journal-space-1..6; --journal-radius-sm, -md, -lg; --journal-content-width. Neutral greys and one blue accent so the layer looks clean alone. journal-base.css: layout, typography for .journal-post-body (headings, lists, tables, blockquote, images max-width 100%), cards, pagination. Add both files to the layer nuxt.config css array as absolute paths built from currentDir (Pitfall 9).
|
||||
|
||||
i18n (D-16): i18n/locales/pl.json and en.json with identical key trees under journal (index.title, index.empty, pagination.previous, pagination.next, pagination.pageTitle with {page}, post.publishedOn with {date}, post.readingTime with {minutes}, post.related, post.previous, post.next, post.contentUnavailable, category.empty, tag.title with {name}, author.title with {name}, author.posts, readMore, rss.title). In journal-module setup register nuxt.hook('i18n:registerModule', register => register({ langDir: absolute i18n/locales dir, locales })) where locales is the subset of pl and en whose code appears in the project's nuxt.options.i18n.locales (strings or objects), each with file <code>.json; register nothing when the project enables neither. @nuxtjs/i18n 10.4.0 calls this hook in modules:done and merges every registered locale into the app (module.mjs applyLayerOptions), so declaring locales in the layer config would add /en routes to a pl-only app — the layer must never declare them.
|
||||
|
||||
Append to expect-acme.txt: 200 /blog contains=Hello Acme; 200 /blog?page=2; 404 /blog?page=3; 404 /blog?page=abc; 404 /blog?page=0; 200 /blog/category/news contains=<the news content marker text from categories.json>; 200 /blog/category/quiet contains=<the en journal.category.empty string>; 404 /blog/category/nope; 200 /pl/blog contains=<the pl journal.index.title string>. Verify against the local playground build (no tag needed for this task).
|
||||
|
||||
Commit (feat: Journal list, category pages, pagination, tokens and pl/en strings) and push main.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt</automated>
|
||||
<fails_when>Non-zero exit; the nuxt build prints "ERROR" or exits non-zero; output contains "smoke-ssr: FAIL"; the line starting "smoke-ssr: ok" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- /blog answers 200, /blog?page=2 answers 200, /blog?page=3, /blog?page=abc and /blog?page=0 answer 404 on the playground build.
|
||||
- /blog/category/news answers 200 and contains the D-21 content marker; /blog/category/quiet answers 200 with the empty-state string; /blog/category/nope answers 404.
|
||||
- The fixture API log shows every categories request carried include_empty=1.
|
||||
- /pl/blog renders the pl journal.index.title string (the hook registered the pl messages for a project that enables pl).
|
||||
- pl.json and en.json have the same set of leaf keys under journal.
|
||||
- `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../vue-journal-layer/app/components` prints nothing (components use tokens only).
|
||||
</acceptance_criteria>
|
||||
<done>Index, category and post pages paginate, 404 correctly, render category content and related posts, and speak pl or en through tokens-only styling.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Author and tag pages, SEO meta, sitemap source, /rss.xml, neutral README and release tag v0.1.0</name>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/AuthorCard.vue, ../vue-journal-layer/app/components/Journal/RssLink.vue, ../vue-journal-layer/server/routes/rss.xml.ts, ../vue-journal-layer/server/api/__sitemap__/journal.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
|
||||
<read_first>../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 5, Don't Hand-Roll, URL Inventory non-page URLs, A6, A7, Security Domain), ../sm-summercmsio-app/vue-summercmsio-app/README.md, ../sm-journal-plugin/README.md (neutral wording precedent), ../sm-journal-plugin/controllers/api/rss.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY</read_first>
|
||||
<action>client.ts: getAuthor(slug) and listAuthorPosts(slug, { page, perPage }) against the D-20 routes recorded in the Task 1 contract (404 returns null). AuthorPage.vue (journal-author): getAuthor(route.params.author); null throws 404; renders <JournalAuthorCard :author> (name, and every public profile field the endpoint returns; an HTML bio goes through gateJournalHtml) and the paginated author posts with perPage.author; same page validation as the index. TagPage.vue (journal-tag): resolves the tag from listTags() by slug (404 when absent), lists posts with tag=slug and perPage.tag. Both pages are registered by the module only when their route is not false (D-06).
|
||||
|
||||
seo.ts: postSeoMeta(post, siteUrl) returns { title: post.title, description: excerpt (or the first 160 characters of the content_html text), ogType article, ogImage: absolute featured_image_url when present, articlePublishedTime: published_at, articleModifiedTime: updated_at }; listTitle(base, page, pageTitle) appends the page title string when page > 1. Every journal page calls useSeoMeta with these; the site name suffix comes from nuxt-seo-utils' title template and the app's site.name (RESEARCH A6). In the layer nuxt.config set seo.canonicalQueryWhitelist to ['page'] explicitly so paginated canonicals keep ?page=N (RESEARCH A7; nuxt-seo-utils already defaults to a list containing page, set it anyway to lock the behaviour), and sitemap { sources: ['/api/__sitemap__/journal'], autoI18n: false }.
|
||||
|
||||
sitemap.ts: buildJournalSitemapEntries(posts, categories, routes) returns { loc, lastmod? } entries: each published post at fillJournalPath(routes.post, { slug }) with lastmod updated_at, each category of the flattened include_empty=1 tree at fillJournalPath(routes.category, { category: slug }), nothing for a disabled route; never hard-code /blog/. server/api/__sitemap__/journal.ts: defineSitemapEventHandler that reads runtimeConfig.journal.apiBase and runtimeConfig.public.journal.routes, pages GET posts with per_page=30 until meta.last_page, fetches categories with include_empty=1, and returns buildJournalSitemapEntries(...) (D-13, D-24, Pitfall 5: per_page 30 keeps the request count low).
|
||||
|
||||
server/routes/rss.xml.ts: GET handler that fetches <runtimeConfig.journal.apiBase>/rss as text with the incoming x-forwarded-for forwarded, answers with Content-Type application/rss+xml; charset=utf-8 and the upstream body, and answers 502 with a plain-text body when the upstream fails; when runtimeConfig.public.journal.rss is false answer 404. RssLink.vue adds a link rel alternate type application/rss+xml href /rss.xml to the head (useHead) and a visible link with journal.rss.title; PostIndexPage and PostPage include it when rss is true. Robots: no per-page noindex on journal pages (every PHP page has robots_noindex = 0); error responses rely on @nuxtjs/robots' default handling, and staging turns indexing off through NUXT_SITE_INDEXABLE (16-03/16-04).
|
||||
|
||||
README.md (neutral: the words used are blog and acme; never name a consuming site or company app): H1 vue-journal-layer, one-sentence summary, install line (pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0), then Overview, Features, Usage (extends: ['vue-journal-layer'], an acme example of journal.routes moving category pages to /:category and disabling tags), Configuration (every journal.* key with default, runtime config keys and their NUXT_ env names, the x-forwarded-for forwarding note), Theming (every --journal-* token, how an app overrides tokens on :root:root and overrides a component by creating app/components/Journal/<Name>.vue), i18n (strings are registered only for locales the app enables), SEO and sitemap, Local development (pnpm link or a link:../vue-journal-layer override, never committed), Releasing (a release is an annotated git tag; consumers bump the #tag and pnpm-lock.yaml), Testing (check-git-install.sh, smoke-ssr.mjs, fixture API).
|
||||
|
||||
Append to expect-acme.txt: 200 /blog/author/<fixture author slug> contains=<fixture author name>; 404 /blog/author/nobody; 200 /blog/tag/<fixture tag slug>; 404 /blog/tag/nope; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/hello-acme; 200 /sitemap.xml contains=/blog/category/quiet; 200 /blog?page=2 contains=?page=2 (canonical link keeps the query); 200 /blog/hello-acme contains=og:title.
|
||||
|
||||
Set package.json version 0.1.0, commit (feat: Journal author and tag pages, SEO, sitemap source and RSS route), push main, create and push annotated tag v0.1.0.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt && bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL" or "ERR_PNPM"; the line "check-git-install: ok v0.1.0" is absent.</fails_when>
|
||||
<automated>! grep -rniE 'grzyb|fonoteka|plytarium' --exclude-dir=node_modules --exclude-dir=.nuxt --exclude-dir=.output --exclude=pnpm-lock.yaml ../vue-journal-layer</automated>
|
||||
<fails_when>Exit status 1 with any matching line printed: the layer names a consuming application.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The scratch consumer installed from tag v0.1.0 passes every line of expect-acme.txt, including author 200/404, tag 200/404, /rss.xml with type application/rss+xml, /sitemap.xml listing /blog/hello-acme and /blog/category/quiet, and the canonical of /blog?page=2 keeping ?page=2.
|
||||
- git ls-remote of the layer lists refs/tags/v0.1.0 and package.json version is 0.1.0.
|
||||
- README.md contains the sections Overview, Features, Usage, Configuration, Theming, i18n, Releasing and Testing, and every journal.* key the module accepts.
|
||||
- No file in the layer repo (excluding node_modules, .nuxt, .output, pnpm-lock.yaml) matches the consuming-application name scan.
|
||||
- The layer source contains no call to a Journal write route: `grep -rnE "method: *'(POST|PUT|DELETE)'" ../vue-journal-layer/app ../vue-journal-layer/server` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The layer ships every Journal view, SEO, sitemap and RSS as tag v0.1.0, installable from git by any blog.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Journal API JSON → v-html | Post bodies, category content and author bios are HTML that editors or imports wrote; they cross into the DOM |
|
||||
| Browser request → SSR fetch | Visitor route params and headers shape the server-side API request |
|
||||
| Nuxt server → Go API | All SSR traffic leaves from 127.0.0.1, which Go trusts as a proxy |
|
||||
| npm/git installs | The layer pulls three Nuxt modules and is itself pulled from git |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-01 | Tampering | JournalPostBody, JournalCategoryHeader, JournalAuthorCard (v-html) | high | mitigate | gateJournalHtml applies the three verbatim Go FormatHTML regexes before any v-html; unsafe values render journal.post.contentUnavailable; acme fixture unsafe-sample proves it in the smoke (Task 2, Task 3) |
|
||||
| T-16-02 | Tampering (SSRF) | useJournalApi, server/routes/rss.xml.ts, sitemap source | high | mitigate | apiBase comes only from runtimeConfig (server key journal.apiBase); route params are encoded per segment with encodeURIComponent; no request value selects the host |
|
||||
| T-16-03 | Denial of Service | SSR fetches against journal-public-api (120/min) | medium | mitigate | useJournalApi and the RSS route forward x-forwarded-for so Go buckets per visitor; sitemap source pages per_page=30; site app adds swr caching (16-02) |
|
||||
| T-16-04 | Tampering | ?page, :slug, :category, :author route input | medium | mitigate | parsePageParam accepts digits only and 404s the rest; unknown slugs 404 through the API; no input is interpolated into HTML |
|
||||
| T-16-05 | Information Disclosure | Journal API drafts | low | accept | The layer calls only anonymous GET routes; Go already 404s drafts (Phase 15 JOURNAL-005) |
|
||||
| T-16-SC | Tampering | npm/pnpm installs and the git dependency | high | mitigate | Only packages already locked in vue-summercmsio-app (nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0, typescript 5.9.3) at exact versions; RESEARCH Package Legitimacy Audit approved them; sanitize-html is not installed; the layer has no install-time scripts; consumers pin the layer by tag and lockfile commit |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 gate prints gate-ok.
|
||||
- `bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0` prints "check-git-install: ok v0.1.0".
|
||||
- The playground smoke passes every line of testing/expect-acme.txt.
|
||||
- The consuming-application name scan over the layer prints nothing.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- vue-journal-layer v0.1.0 is on git.golem15.com and installs into a fresh consumer from the tag (D-04).
|
||||
- Post list, post, category, tag and author views, RSS link, SEO meta and sitemap work from journal.routes (D-05, D-06, D-13).
|
||||
- Categories always use include_empty=1; unknown slugs and out-of-range pages answer 404 (D-24).
|
||||
- pl/en strings register only for enabled locales (D-16); tokens drive every visual value (D-03).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md` when done, including the "Prerequisite contract" section from Task 1.
|
||||
</output>
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 01
|
||||
subsystem: frontend (Nuxt layer)
|
||||
tags: [nuxt, nuxt-layer, vue, i18n, nuxtjs-seo, sitemap, rss, pnpm-git-dependency, journal]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 15-journal-plugin
|
||||
provides: "anonymous Journal API /_journal/api/v1 (posts, posts/{slug}, categories, tags, rss)"
|
||||
- phase: quick-261009-i6e
|
||||
provides: "D-20 anonymous author endpoints /authors/{author} and /authors/{author}/posts"
|
||||
- phase: quick-261009-mgk
|
||||
provides: "D-21 category content field on GET /categories rows"
|
||||
provides:
|
||||
- "vue-journal-layer repo on git.golem15.com, tags v0.0.1 (spike) and v0.1.0 (release for 16-02)"
|
||||
- "Nuxt module configKey journal: journal.routes, perPage, sort, relatedCount, rss; route names journal-index/post/category/tag/author"
|
||||
- "typed anonymous-GET Journal client (createJournalClient) with include_empty=1 on categories"
|
||||
- "gateJournalHtml: TS port of the Go FormatHTML three-pattern gate for every v-html sink"
|
||||
- "--journal-* CSS tokens, overridable Journal* components, pl/en strings registered per enabled locale"
|
||||
- "@nuxtjs/seo meta, canonicals keeping ?page=N, /api/__sitemap__/journal source, /rss.xml proxy"
|
||||
- "testing/fixture-api.mjs, testing/nuxt-server.mjs, testing/smoke-ssr.mjs, scripts/check-git-install.sh"
|
||||
affects: [16-02, 16-03, 16-04, 16-05]
|
||||
|
||||
actuals:
|
||||
tokens: 27751 # chars/4 over the 54 tracked layer files, pnpm-lock.yaml excluded
|
||||
tasks: 4
|
||||
commits: 3 # git rev-list --count HEAD in vue-journal-layer (the repo started empty)
|
||||
commits: 3
|
||||
plan_head_before: null # vue-journal-layer was an empty repository; no base commit exists
|
||||
plan_head_after: 783426c5d84c5d7c088f1e74b01c5240811c88d2
|
||||
|
||||
tech-stack:
|
||||
added: ["vue-journal-layer (new repo): nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0, typescript 5.9.3 (all already locked in vue-summercmsio-app)"]
|
||||
patterns:
|
||||
- "Layer pages live in app/journal-pages and are registered by extendPages from journal.routes; links go through useLocalePath over stable route names"
|
||||
- "SSR fetches use the server-only apiBase and forward x-forwarded-for plus the peer address"
|
||||
- "useAsyncData transforms gate and slim API rows (toPostView, toListPost) so unsafe HTML, raw content and translations never reach the payload"
|
||||
- "Layer strings registered through the i18n:registerModule hook only for locales the app enables; the layer config declares no locales"
|
||||
- "Built-server SSR smoke against a node:http fixture API with a line-based expectations file"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-journal-layer/lib/journal-module.ts
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/app/utils/client.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/app/utils/routes.ts
|
||||
- ../vue-journal-layer/app/utils/view.ts
|
||||
- ../vue-journal-layer/app/utils/seo.ts
|
||||
- ../vue-journal-layer/app/utils/sitemap.ts
|
||||
- ../vue-journal-layer/app/composables/useJournalApi.ts
|
||||
- ../vue-journal-layer/app/journal-pages/PostPage.vue
|
||||
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
|
||||
- ../vue-journal-layer/server/routes/rss.xml.ts
|
||||
- ../vue-journal-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-journal-layer/testing/fixture-api.mjs
|
||||
- ../vue-journal-layer/scripts/check-git-install.sh
|
||||
- ../vue-journal-layer/README.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "vue-journal-layer pages gate and slim API data in useAsyncData transforms (toPostView/toListPost), so the SSR payload never carries an unsafe body, raw content or translations"
|
||||
- "SSR, sitemap and RSS fetches forward x-forwarded-for with the Nuxt peer address appended, so the Go limiter's rightmost-untrusted-hop rule picks the real visitor"
|
||||
- "vue-journal-layer tsconfig.json is self-contained (no extends of a generated .nuxt/tsconfig.json), because Vite reads it from a consumer's node_modules where no .nuxt exists"
|
||||
- "The layer author page links posts by author.login (the only author key on post rows) and sets the canonical to the endpoint's slug URL"
|
||||
- "The vue-grzybyfunkcjonalne-app site repo is private by the user's choice and is checked over SSH; only vue-journal-layer must be public (D-04)"
|
||||
|
||||
patterns-established:
|
||||
- "Nuxt layer distribution: annotated git tag on git.golem15.com, consumer pins git+https://...#tag, lockfile pins the tag commit, no prepare script"
|
||||
- "scripts/check-git-install.sh <tag> as the release gate for the layer"
|
||||
|
||||
requirements-completed: [SC-2, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A scratch app installs vue-journal-layer anonymously from git tag v0.1.0 (and v0.0.1), pnpm-lock.yaml pins the tag commit, nuxt build succeeds and the SSR smoke passes (D-04)"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0 -> check-git-install: ok v0.1.0 783426c5d84c5d7c088f1e74b01c5240811c88d2"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.0.1 -> check-git-install: ok v0.0.1 6f6792f51c3b437490a38ef3fb5e906447726139"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Post list with ?page=N pagination and 404s, post page (gated body, meta, featured image, siblings, related with latest fallback), category (include_empty=1, D-21 content, empty state, unknown 404), tag and author pages, all from journal.routes (D-05, D-06, D-21, D-24)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt -> smoke-ssr: ok (28 checks)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "SEO meta, canonicals that keep ?page=N, sitemap.xml from the Journal API built from journal.routes, /rss.xml as application/rss+xml (D-13)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "testing/expect-acme.txt lines for /rss.xml, /sitemap.xml, canonical and og:title, run by smoke-ssr.mjs (28 checks ok)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "pl/en strings register only for enabled locales: a pl-only app serves pl strings at /blog and 404s /en/blog and /pl/blog (D-16)"
|
||||
requirement: SC-2
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "temporary pl-only playground build + smoke-ssr.mjs with 4 expectations -> smoke-ssr: ok (4 checks); playground config restored"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The neutral token-driven base design looks clean on its own (D-03)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual quality of the neutral design is a judgment call; the smoke only proves the markup and that components carry no hard-coded colours"
|
||||
|
||||
duration: 1h 34m
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 01: vue-journal-layer Summary
|
||||
|
||||
**A reusable Nuxt 4 layer, `vue-journal-layer` v0.1.0 on git.golem15.com, renders every Journal view from the anonymous Journal API. It covers post list, post, category with rich content, tag, author, RSS, SEO and sitemap. Paths come from `journal.routes`, styling from `--journal-*` tokens, and pl/en strings are registered per enabled locale. A scratch app installs the layer from the git tag and passes a 28-check SSR smoke.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1h 34m
|
||||
- **Started:** 2026-10-09T14:33:15Z
|
||||
- **Completed:** 2026-10-09T16:07:44Z
|
||||
- **Tasks:** 4 (Task 1 was the resolved prerequisite gate)
|
||||
- **Files modified:** 55 tracked files in `../vue-journal-layer` (new repo)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-04 distribution proven end to end.** `check-git-install.sh` installs the layer anonymously from `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`. It then checks that `pnpm-lock.yaml` pins the tag's peeled commit, runs `nuxt build`, and runs the SSR smoke from `node_modules`. This passed for v0.0.1 (the spike) and for v0.1.0 (the release 16-02 consumes).
|
||||
- **Configurable routes (D-06).** A local module (`lib/journal-module.ts`, configKey `journal`) registers `journal-index`, `journal-post`, `journal-category`, `journal-tag` and `journal-author` at the paths in `journal.routes`. Setting a route to `false` disables it, and a path missing its param fails the build with an error naming `journal.routes.<key>`.
|
||||
- **Every Journal view (D-05, D-21, D-24):**
|
||||
- The index paginates with `?page=N`. `abc`, `0` and out-of-range pages answer 404.
|
||||
- Category pages render the D-21 `content` through the HTML gate above the list. An empty category answers 200, and an unknown one answers 404.
|
||||
- Post pages show meta, the featured image, previous and next links, and related posts, falling back to the latest posts.
|
||||
- Tag and author pages are included. An author resolves by slug or login, and a login URL's canonical points at the slug URL.
|
||||
- **HTML gate (T-16-01).** Every v-html sink uses `gateJournalHtml`, which applies the three Go regexes. On top of that, page transforms gate and slim the data before it is serialized, so even the SSR payload never carries the unsafe body (the `notcontains=alert(` check proves this).
|
||||
- **SEO (D-13).** Per-page `useSeoMeta`, `og:image` from the featured image, canonicals that keep `?page=N` (whitelist locked to `page`), a sitemap source that pages posts at `per_page=30` and builds every loc from `journal.routes`, and `/rss.xml` proxying the API feed.
|
||||
- **i18n (D-16).** pl and en strings with identical key trees (21 leaves) are registered through `i18n:registerModule`, and only for locales the app enables. A pl-only build serves pl at `/blog`, and `/en/blog` answers 404.
|
||||
|
||||
## Task Commits
|
||||
|
||||
All code commits are in `../vue-journal-layer` (branch `main`, pushed):
|
||||
|
||||
1. **Task 1: Prerequisite gate.** Resolved by the human, and the orchestrator re-ran the gate (gate-ok). There was no commit. The contract is recorded below.
|
||||
2. **Task 2: Tracer, git-tag install and SSR post page.** `6f6792f` (feat), tagged `v0.0.1`.
|
||||
3. **Task 3: Post list, categories, pagination, related posts, tokens and pl/en strings.** `c408eeb` (feat).
|
||||
4. **Task 4: Author and tag pages, SEO, sitemap, /rss.xml, README and v0.1.0.** `783426c` (feat), tagged `v0.1.0`.
|
||||
|
||||
**Plan metadata:** the docs commit in summercms.go (this SUMMARY).
|
||||
|
||||
Remote state: `refs/heads/main` 783426c, `refs/tags/v0.0.1` → 6f6792f, `refs/tags/v0.1.0` → 783426c (both are annotated tags).
|
||||
|
||||
## Prerequisite contract
|
||||
|
||||
This was read from `../sm-journal-plugin` at `3d533d4` (local `main`, not pushed) and from the quick-task SUMMARYs 261009-i6e and 261009-mgk. The plugin is the contract. This plan did not modify `../sm-journal-plugin`; its working tree shows only the pre-existing untracked `go.work.sum`.
|
||||
|
||||
### D-20 author endpoints (plugin commits 908ade9, 57a347c)
|
||||
|
||||
- **Routes.** `GET /_journal/api/v1/authors/{author}` and `GET /_journal/api/v1/authors/{author}/posts` are both anonymous and sit in the `throttle:journal-public-api` group. `{author}` is resolved in the handler, not by a route `Where`.
|
||||
- **Resolution.** The value is trimmed first.
|
||||
- It matches `backend_users.golem15_bloghub_author_slug` first, which works even when the author has no posts.
|
||||
- Otherwise it matches `login`, but only when that user has at least one published post.
|
||||
- Soft-deleted users never resolve.
|
||||
- A malformed value always answers 404: empty, over 255 bytes, invalid UTF-8, or containing control characters.
|
||||
- **Profile body.** `200 {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`.
|
||||
- It has exactly these four keys.
|
||||
- `slug` is the author slug when set, otherwise the login.
|
||||
- A missing name is `""`.
|
||||
- **There is no public profile field beyond the names.** Bio, subtitle and avatar are not exposed; that is the `journal-author-profile-fields` todo.
|
||||
- **Posts.** `/authors/{author}/posts` returns the `/posts` envelope `{data:[post rows], meta:{current_page,last_page,per_page,total}}` with `serializePost` rows.
|
||||
- Query keys are `page` (default 1, clamped to at least 1), `per_page` (default 9, clamped to 1..30) and `sort` (the `/posts` allow-list, default `published_at desc`).
|
||||
- Only published posts appear.
|
||||
- **404 body.** `{"error":"Author not found"}` from both routes, with no other keys.
|
||||
- **Post rows.** The `author` object on post rows is still `{id, first_name, last_name, login}` with no slug, so the layer links authors by `login` and canonicalises to `data.slug`.
|
||||
|
||||
### D-21 category content (plugin commits 740dcd0, f22d089, 3d533d4)
|
||||
|
||||
- **Key.** `content` appears on every `GET /_journal/api/v1/categories` row, children included, with and without `include_empty=1`.
|
||||
- **Value.** It is an **HTML** string (not markdown) or `null`, stored and served as is. It is not translatable.
|
||||
- **Writes.** The admin save path is gated by `classes.CheckHTML`, the same three patterns. 16-03's direct UPDATE bypasses that gate, so the layer gate stays mandatory.
|
||||
- **Other keys** are unchanged: `id, name, slug, description, parent_id, nest_depth, post_count, children?`. Post rows' `categories[]` entries are still `{id, name, slug}`.
|
||||
|
||||
### Differences from the plan's assumed interfaces
|
||||
|
||||
- The author route parameter is `{author}` (a slug or a login), as assumed. The author posts route is `/authors/{author}/posts`, not `posts?author=`.
|
||||
- The author object has no public profile fields beyond the names. `JournalAuthor` is `{id, slug, first_name, last_name}`, and `JournalAuthorCard` renders the name and offers a slot. No bio is rendered, so there is no third v-html sink yet.
|
||||
- The category key is `content` as assumed, and it is HTML.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
All of these are in `../vue-journal-layer`, a new repository:
|
||||
|
||||
- `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.nvmrc`, `.gitignore`, `tsconfig.json`: the package (`main: ./nuxt.config.ts`, nuxt as a dev and peer dependency, no `prepare` script).
|
||||
- `nuxt.config.ts`: the layer config. It has absolute paths, `pages: true`, the modules declared once, runtime config, css, `seo.canonicalQueryWhitelist`, the sitemap source and no locales.
|
||||
- `lib/journal-module.ts`: route registration, public runtime config and the i18n registration hook.
|
||||
- `app/utils/{types,routes,client,htmlGate,view,pagination,seo,sitemap}.ts`: pure TS helpers that `node --test` can import.
|
||||
- `app/composables/{useJournalApi,useJournalLinks}.ts`.
|
||||
- `app/journal-pages/{PostIndexPage,PostPage,CategoryPage,TagPage,AuthorPage}.vue`.
|
||||
- `app/components/Journal/{PostBody,PostCard,PostList,PostMeta,FeaturedImage,Pagination,RelatedPosts,IndexHeader,CategoryHeader,AuthorCard,RssLink}.vue`.
|
||||
- `app/assets/css/{journal-tokens,journal-base}.css`, `app/app.vue`, `app/layouts/default.vue`.
|
||||
- `i18n/locales/{pl,en}.json`.
|
||||
- `server/routes/rss.xml.ts`, `server/api/__sitemap__/journal.ts`.
|
||||
- `testing/{fixture-api,nuxt-server,smoke-ssr}.mjs`, `testing/fixtures/acme/*`, `testing/expect-acme.txt` (28 checks).
|
||||
- `scripts/check-git-install.sh`, `.playground/{nuxt.config.ts,app/app.vue}`, `README.md`.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Payload shaping.** Pages run API rows through `toPostView` and `toListPost` in `useAsyncData` transforms. The payload drops `content` and `translations` and carries only gated `content_html`. This is a security and size win; see deviation 2.
|
||||
- **Forwarding.** XFF forwarding appends the Nuxt peer address. Go reads the rightmost untrusted hop and trusts `127.0.0.1/32`, so behind nginx it still picks the visitor, and a Nuxt server reached directly cannot have its bucket spoofed by a client-supplied header.
|
||||
- **Author links.** Authors are linked by `author.login`, because that is the only key on post rows. The author page canonicalises to the slug URL.
|
||||
- **Featured images.** They carry intrinsic `width="1200" height="630"` attributes, and the rendered crop comes from the `--journal-image-ratio` token. Attributes cannot be set from CSS tokens; this is how the plan's wording was read.
|
||||
- **Smoke checks.** The smoke additionally asserts that every categories request carried `include_empty=1`. The expectations also cover a draft post (404), the related-posts fallback, `/blog/author/jdoe` (login) and an exact canonical.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] The layer tsconfig.json could not extend `./.nuxt/tsconfig.json`**
|
||||
- **Found during:** Task 2 (first playground build).
|
||||
- **Issue:** `vite:vue` failed with `failed to resolve "extends":"./.nuxt/tsconfig.json"`. A layer has no `.nuxt` (only `.playground/.nuxt`), and neither does a consumer's `node_modules` copy.
|
||||
- **Fix:** I wrote a self-contained `tsconfig.json` (ESNext, Bundler resolution, `allowImportingTsExtensions`, strict).
|
||||
- **Files modified:** `../vue-journal-layer/tsconfig.json`.
|
||||
- **Verification:** The playground build and both git-install runs pass.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**2. [Rule 2 - Missing critical] The SSR payload carried unsafe HTML**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** `useAsyncData` serializes the raw API row into `__NUXT_DATA__`, including an unsafe `content_html`, plus `content` and `translations`. The plan's `notcontains=alert(` check could not pass that way, and the raw HTML would reach the browser.
|
||||
- **Fix:** I added `app/utils/view.ts`, which provides `toPostView` (gates the body, sets `content_blocked`, drops `content` and `translations`, slims related posts) and `toListPost`. `JournalPostBody` gained a `blocked` prop. The category page gates `content` before it enters the payload.
|
||||
- **Files modified:** `app/utils/view.ts`, `PostBody.vue`, all pages.
|
||||
- **Verification:** The `/blog/unsafe-sample` smoke line passes (contains the blocked class, not `alert(`).
|
||||
- **Committed in:** 6f6792f and c408eeb.
|
||||
|
||||
**3. [Rule 1 - Bug] ASI bug in the smoke-ssr.mjs option parser**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** A line starting with `(` after a JSDoc cast was parsed as a call.
|
||||
- **Fix:** I rewrote the parser to collect options into an object.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**4. [Rule 2 - Missing critical] The forwarded XFF lacked the peer address**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** Forwarding the client header alone would let a client choose its own rate-limit bucket if Nuxt is reachable directly.
|
||||
- **Fix:** `useJournalApi`, the sitemap source and `/rss.xml` append the peer address.
|
||||
- **Verification:** The smoke forwarding assertion passes; the fixture recorded `203.0.113.7, 127.0.0.1`.
|
||||
- **Committed in:** 6f6792f and 783426c.
|
||||
|
||||
**5. [Rule 3 - Blocking] Minimal index, category, tag and author pages were shipped in v0.0.1**
|
||||
- **Found during:** Task 2.
|
||||
- **Issue:** The module registers every route that is not `false`, so the build failed (ENOENT) without the other page files.
|
||||
- **Fix:** I wrote simple working versions in Task 2. Tasks 3 and 4 replaced them with the full pages. These were not stubs: each fetched and rendered real data or answered 404.
|
||||
- **Committed in:** 6f6792f.
|
||||
|
||||
**6. [Scope addition] `site.url`/`site.name` (`https://acme.test`, Acme Blog) in the playground and the scratch consumer config**
|
||||
- **Issue:** Without them, canonicals carry the random smoke port, and titles show a literal `%siteName`.
|
||||
- **Fix:** I added both, which makes the canonical assertions exact. The README documents that apps must set `site.url` and `site.name`.
|
||||
- **Committed in:** 783426c.
|
||||
|
||||
**7. [Scope addition] TMPDIR guard in check-git-install.sh**
|
||||
- **Issue:** Nuxt's layer include regex `(^|/)vue-journal-layer(/|$)` matches every path below a directory named `vue-journal-layer`. A scratch app there gets the auto-import transform on all dependencies, and the build fails with `Identifier "h" has already been declared` in `node-mock-http`. I hit this with a workaround TMPDIR.
|
||||
- **Fix:** The script now refuses such a TMPDIR with a clear message, and the README notes it.
|
||||
- **Committed in:** 783426c.
|
||||
|
||||
**8. [Scope addition] Extra i18n keys**
|
||||
- **Issue:** The components needed `journal.pagination.label`, `journal.post.by`, `journal.tag.empty` and `journal.author.empty` beyond the plan's list.
|
||||
- **Fix:** I added them to both locales. Parity is checked at 21 leaves.
|
||||
- **Committed in:** c408eeb.
|
||||
|
||||
**9. [User decision, recorded per the orchestrator] The site repo is private**
|
||||
- `golem15/vue-grzybyfunkcjonalne-app` is **private** by the user's choice. It is checked over SSH (`git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git`), not anonymous HTTPS as Task 1's verify and `user_setup` assumed.
|
||||
- Only `vue-journal-layer` must be public (D-04 anonymous `git+https` install, verified by both git-install runs).
|
||||
- The site app is consumed only as a D-08 submodule over SSH. 16-02 and 16-04 must use the SSH URL for it.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 9 (5 auto-fixed under Rules 1 to 3, 3 small scope additions, 1 user decision on repo visibility).
|
||||
**Impact on plan:** All auto-fixes were needed for correctness, security or the build. Routes, components and option names match the plan's artifact list. The extra `app/utils/view.ts` module adds exported names; it changes none.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **The sandbox cannot write the `/tmp` pnpm store** (`ERR_SQLITE_ERROR attempt to write a readonly database`). The git-install runs used `TMPDIR=<session scratchpad>` with `npm_config_store_dir`/`pnpm_config_store_dir` pointed at the project-drive store. This is an environment workaround, and the script is unchanged by it.
|
||||
- **The `@nuxt/fonts` build step retries provider metadata fetches** (fonts.bunny.net, api.fontshare.com) that the sandbox blocks. These are warnings only and the builds succeed. Apps that declare font families will fetch at build time.
|
||||
- **nuxt-seo-utils warns** "treeShakeUseSeoMeta requires Unhead v3". This is harmless on Nuxt 4.4.8 with Unhead v2.
|
||||
- **The D-16 pl-only check** first ran from a test app under `.data/`, which Nuxt ignores (`useJournalApi is not defined`). I re-ran it by temporarily switching `.playground` to pl-only, building, running the smoke, and restoring the file (no diff).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every page fetches and renders real API data. `JournalAuthorCard` shows only the name because the D-20 endpoint exposes no profile fields; that gap is tracked by the existing todo `journal-author-profile-fields.md`, not by a stub.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model.
|
||||
- `/rss.xml` and `/api/__sitemap__/journal` are new GET-only Nitro routes. They read from the runtime-config API base (T-16-02) and forward XFF (T-16-03).
|
||||
- No write route is called: `grep -rnE "method: *'(POST|PUT|DELETE)'" app server` prints nothing.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
Done before resume. See [16-USER-SETUP.md](./16-USER-SETUP.md): both Gitea repos exist; the layer repo is public and the site repo is private (SSH).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- 16-02 can depend on `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` and set `journal.routes` to `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`.
|
||||
- **The site app must set `site.url` and `site.name`.**
|
||||
- 16-02 can reuse `testing/fixture-api.mjs` and `testing/nuxt-server.mjs` from `node_modules/vue-journal-layer/testing/`.
|
||||
- The 16-05 unit tests can target the pure utils in `app/utils/*.ts`: routes, client, htmlGate, pagination, view, seo and sitemap.
|
||||
- **Before release, the plugin commits must be pushed.** The D-20 and D-21 commits in `../sm-journal-plugin` are local only, and the Go host build in 16-03 and 16-04 needs them pushed.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-journal-layer/{lib/journal-module.ts, app/utils/client.ts, app/utils/htmlGate.ts, app/utils/routes.ts, server/api/__sitemap__/journal.ts, scripts/check-git-install.sh, testing/smoke-ssr.mjs, README.md}
|
||||
- FOUND: commits 6f6792f, c408eeb and 783426c in vue-journal-layer, and on origin/main
|
||||
- FOUND: remote refs refs/tags/v0.0.1 and refs/tags/v0.1.0, and package.json version 0.1.0
|
||||
- Plan verification:
|
||||
- The Task 1 gate was gate-ok, re-run by the orchestrator with the site repo checked over SSH.
|
||||
- `check-git-install.sh v0.1.0` printed `check-git-install: ok v0.1.0`.
|
||||
- The playground smoke printed `smoke-ssr: ok (28 checks)`.
|
||||
- The consuming-application name scan printed nothing.
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16-01"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01/D-08: vue-grzybyfunkcjonalne-app is a Nuxt 4 SSR app (summercms standards: @nuxtjs/i18n, @nuxtjs/seo, @nuxt/fonts through the layer, pnpm, node --test) mounted as a submodule of sm-grzybyfunkcjonalne-app the way vue-summercmsio-app is mounted in sm-summercmsio-app."
|
||||
- "D-04: the app depends on git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 with the commit locked in pnpm-lock.yaml, and does not re-list the layer's Nuxt modules (Pitfall 4)."
|
||||
- "D-15: the PHP public URLs answer on the new app with the same paths: /, /blog with ?page=N, /blog/:slug, /autor/:author, /kontakt, /trwaja-prace and the root /:category catch-all, where static routes win; /404, /error and unknown roots answer 404."
|
||||
- "D-16: the app serves pl only, without a URL prefix; /en/blog answers 404."
|
||||
- "D-07: routeRules swr caching keeps pages full SSR HTML and lets a newly published post appear without a rebuild."
|
||||
- "D-11: the homepage hero, latest 3 posts, about, CTA and services sections and the blog header carry the current Polish copy, as components plus i18n strings, with no sitemanager toggles."
|
||||
- "D-12: the main menu is typed config in app/data/menu.ts with the category dropdown resolved from the Journal API, and the FAQ what-mushrooms-can-give-you is static content in app/data/faq.pl.json; no menu or FAQ plugin is built (deferred to Phase 17)."
|
||||
- "D-14: /kontakt shows the contact details and a mailto link; the app contains no contact or newsletter form and calls no new Go endpoint."
|
||||
- "D-23: Google Analytics G-MLSV7WJ6W4 loads only when NUXT_PUBLIC_GA_ID is set; the default build and staging render no analytics script."
|
||||
- "D-03: the refreshed design lives in the app as --journal-* token overrides and app CSS; the layer is unchanged by this plan."
|
||||
- "D-24: /amanita-muscaria answers 200 with its content and an empty list."
|
||||
- "No English locale for the site, no production cutover, no sitemanager/blocks port (CONTEXT deferred ideas)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts"
|
||||
provides: "extends the layer; journal.routes for the PHP URLs; pl-only i18n; site config; swr routeRules; runtime gaId"
|
||||
contains: "/autor/:author"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts"
|
||||
provides: "typed MAIN_MENU config"
|
||||
contains: "journal-categories"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json"
|
||||
provides: "the 16 FAQ entries with the live Polish copy"
|
||||
contains: "items"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts"
|
||||
provides: "gaHeadScripts(id) returning no scripts for an empty or malformed id"
|
||||
contains: "googletagmanager"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt"
|
||||
provides: "SSR smoke expectations for every D-15 URL form"
|
||||
contains: "/autor/"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/.gitmodules"
|
||||
provides: "submodule vue-grzybyfunkcjonalne-app"
|
||||
contains: "vue-grzybyfunkcjonalne-app"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json"
|
||||
to: "git.golem15.com/golem15/vue-journal-layer#v0.1.0"
|
||||
via: "pnpm git dependency pinned by tag and lockfile commit"
|
||||
pattern: "vue-journal-layer.git#v0.1.0"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts"
|
||||
to: "../vue-journal-layer/lib/journal-module.ts"
|
||||
via: "journal.routes overrides: category /:category, author /autor/:author, tag false"
|
||||
pattern: "category: '/:category'"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue"
|
||||
to: "useJournalApi().listCategories()"
|
||||
via: "the journal-categories menu item lists every category (include_empty=1) through useJournalLinks().categoryLink"
|
||||
pattern: "listCategories"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue"
|
||||
to: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts"
|
||||
via: "useHead({ script: gaHeadScripts(runtimeConfig.public.gaId) })"
|
||||
pattern: "gaHeadScripts"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: the grzybyfunkcjonalne.pl Nuxt app extends `vue-journal-layer` v0.1.0 and answers the PHP site's URLs with SSR HTML, then the homepage, menu, FAQ, kontakt, error pages and analytics carry the current Polish content in a refreshed design.
|
||||
|
||||
<objective>
|
||||
Build `vue-grzybyfunkcjonalne-app` (D-08) on the layer (D-02, D-04) with the PHP URL map (D-15), pl-only i18n (D-16), SSR swr caching (D-07), the refreshed design (D-01, D-03), the content port (D-11, D-12, D-14) and GA through runtime config (D-23), and mount it as a submodule of the host.
|
||||
|
||||
Purpose: SC-1 (the site, as content and routes) and SC-3 (every PHP page has a named Nuxt successor).
|
||||
Output: the app repo pushed to git.golem15.com and mounted at `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@../vue-journal-layer/README.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/README.md
|
||||
@../sm-summercmsio-app/.gitmodules
|
||||
|
||||
<interfaces>
|
||||
From vue-journal-layer v0.1.0 (16-01): configKey journal with routes { index, post, category, tag, author } (path or false), perPage { index, category, tag, author }, sort, relatedCount, rss; route names journal-index, journal-post, journal-category, journal-tag, journal-author; runtime keys journal.apiBase (NUXT_JOURNAL_API_BASE) and public.journal.apiBase; composables useJournalApi() (listPosts, getPost, listCategories, findCategory, listTags, getAuthor, listAuthorPosts) and useJournalLinks() (postLink, categoryLink, tagLink, authorLink, indexLink); utils gateJournalHtml, isSafeJournalHtml; overridable components Journal/{PostBody, PostCard, PostList, PostMeta, FeaturedImage, Pagination, RelatedPosts, IndexHeader, CategoryHeader, AuthorCard, RssLink}; tokens --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*; testing tools node_modules/vue-journal-layer/testing/{fixture-api.mjs, nuxt-server.mjs, smoke-ssr.mjs} (expect grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; smoke-ssr flag --env KEY=VALUE).
|
||||
Live content facts (RESEARCH Content Inventory): hero title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam"; about "Czym są grzyby funkcjonalne?" with button "Poznaj naszą misję"; CTA "Skorzystaj z wiedzy ekspertów"; services "Dlaczego warto?" with 4 boxes; FAQ intro plus 16 Q&A; footer address "Wróblewskiego, 1A/1U, Namysłów", phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, links Artykuły blogowe, Golem15, Ciemność.com, Dobra bomba, social x.com/ophiel118; blog header "Artykuły" / "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych"; colour seeds #0D705D, #27AE60, #138C74, #1AAC8F, #444444; fonts Open Sans, Jost, Poppins.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Repo `git.golem15.com/golem15/vue-grzybyfunkcjonalne-app`, mounted as submodule `vue-grzybyfunkcjonalne-app` in `sm-grzybyfunkcjonalne-app` (`.gitmodules` entry, gitlink).
|
||||
- `nuxt.config.ts` keys: `extends: ['vue-journal-layer']`; `journal.routes` = `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`; `journal.perPage` = `{ index: 12, category: 10, author: 10 }`; `journal.sort` = `published_at desc`; `i18n` pl only (`prefix_except_default`, `defaultLocale: 'pl'`); `site` (`url https://grzybyfunkcjonalne.pl`, `name Grzyby Funkcjonalne`); `routeRules` swr; `runtimeConfig.public.gaId` (env `NUXT_PUBLIC_GA_ID`); `robots.disallow`; `sitemap.exclude`; `linkChecker.excludeLinks`.
|
||||
- Pages `/` (`index.vue`), `/kontakt` (`kontakt.vue`), `/trwaja-prace` (`trwaja-prace.vue`), `app/error.vue`, layout `default.vue`.
|
||||
- Components `SiteHeader`, `SiteFooter`, `HeroSection`, `LatestPosts`, `AboutSection`, `CtaSection`, `ServicesSection`, `FaqSection`, `PageHero`, override `Journal/IndexHeader`.
|
||||
- Typed data `app/data/menu.ts` (`MenuItem`, `MAIN_MENU`), `app/data/contact.ts` (`CONTACT`), `app/data/faq.pl.json` (`{ slug, intro, items: [{ question, answer_html }] }`).
|
||||
- Util `app/utils/analytics.ts` (`GA_ID_PATTERN`, `gaHeadScripts`).
|
||||
- i18n `i18n/locales/pl.json` (site copy plus overrides of `journal.*` strings).
|
||||
- Fixtures `tests/fixtures/api/*` and expectations `tests/fixtures/expect.txt`, `tests/fixtures/expect-ga.txt`; package scripts `build`, `dev`, `preview`, `smoke:ssr`, `smoke:ga`.
|
||||
|
||||
<!-- planner-discipline-allow: <form -->
|
||||
The `<form` literal appears in Task 3 only as the text of an SSR smoke expectation (a notcontains check written into tests/fixtures/expect.txt); the negative grep in Task 3 scans `app/` only, so the fixture line never trips it.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer — the site app extends layer v0.1.0 and answers the PHP post and category URLs as SSR HTML, mounted in the host</name>
|
||||
<reversibility rating="one-way">D-15: the URL shapes are search-indexed; changing them after the cutover needs a 301 map. Already decided by the user in CONTEXT — recorded here, not re-asked.</reversibility>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-04, D-07, D-08, D-15, D-16, D-24), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, routing rules, Pattern 1 app excerpt, Pattern 4, Pitfall 4, A2, A8), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract, any layer deviations), ../vue-journal-layer/README.md, ../vue-journal-layer/testing/smoke-ssr.mjs, ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/.gitmodules, .planning/notes/core-plugins-own-repos.md, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/badania-nad-cordycepsem.json</read_first>
|
||||
<action>Create the app as a nested repo at ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (git init -b main, remote origin git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git). Copy .nvmrc, tsconfig.json, pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) and .gitignore (node_modules/, .nuxt/, .output/, .data/, dist, .env) from ../sm-summercmsio-app/vue-summercmsio-app.
|
||||
|
||||
package.json: name vue-grzybyfunkcjonalne-app, private, type module, engines node >=22.18; scripts build (nuxt build), dev (nuxt dev), preview (nuxt preview), prepare (nuxt prepare), smoke:ssr (node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures tests/fixtures/api --expect tests/fixtures/expect.txt); no generate script (D-07 SSR). dependencies exactly nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 (D-04); devDependencies typescript 5.9.3. Do not list @nuxtjs/i18n, @nuxtjs/seo or @nuxt/fonts (the layer declares them; Pitfall 4). pnpm install writes pnpm-lock.yaml with the tag commit. A link:../../vue-journal-layer override for local layer work is documented in the README and never committed (D-04).
|
||||
|
||||
nuxt.config.ts (short comment above each block, summercms.io style): compatibilityDate 2025-07-15, devtools off, extends ['vue-journal-layer'], no modules key. journal: routes { index '/blog', post '/blog/:slug', category '/:category', author '/autor/:author', tag false } (D-15; the PHP site has no tag pages), perPage { index 12, category 10, author 10 } (PHP blog.htm 12, index.htm 10, autor.htm 10), sort 'published_at desc'. i18n: strategy prefix_except_default, defaultLocale pl, locales [{ code pl, language pl-PL, file pl.json, name Polski }], langDir locales/, baseUrl https://grzybyfunkcjonalne.pl (D-16: pl only, no prefix). site: url https://grzybyfunkcjonalne.pl, name Grzyby Funkcjonalne, description Serwis informacyjny poświęcony grzybom funkcjonalnym, defaultLocale pl. routeRules: '/**' swr 300 and '/blog/**' swr 120 (D-07: Nitro swr on node-server, never isr). runtimeConfig: journal.apiBase http://127.0.0.1:8096/_journal/api/v1 and public.gaId '' (D-23, env NUXT_PUBLIC_GA_ID). css: ~/assets/css/tokens.css (the app may use ~ for its own files). fonts: Jost (400, 500, 600, 700) for headings and Open Sans (400, 600, 700) for body, normal style, latin and latin-ext subsets, global.
|
||||
|
||||
tokens.css: one :root:root block (higher specificity than the layer's :root, RESEARCH Pattern 4 and A8) overriding --journal-color-* with the refreshed palette seeded from #0D705D (primary), #27AE60 (accent), #138C74 (link), #1AAC8F (link hover), #444444 (text) and white background, --journal-font-heading Jost and --journal-font-body Open Sans, plus site tokens --site-* (header height, hero overlay), each with a trailing comment naming its use. app.vue renders NuxtLayout and NuxtPage; layouts/default.vue wraps the slot in a header slot area, main and footer slot area (filled in Task 2).
|
||||
|
||||
i18n/locales/pl.json: overrides for the layer strings the site wants in its own words (journal.index.title Artykuły, journal.pagination.pageTitle Strona {page}) plus site keys used in this task.
|
||||
|
||||
Fixtures (tests/fixtures/api, the layer fixture-api format): 14 published posts copied from /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export (pl translation title, slug, excerpt and content_html, published_at converted to the Go wire format, categories as { id, name, slug }), including blogbadania-nad-cordycepsem; categories tree with cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail and amanita-muscaria (post_count 0) and a content HTML string on cordyceps containing the marker text Cordyceps w pigułce; tags []; one author with slug admin per the 16-01 Prerequisite contract; rss.xml with one item. tests/fixtures/expect.txt: 200 /blog/blogbadania-nad-cordycepsem contains=Najnowsze Badania nad Cordycepsem; 404 /blog/badania-nad-cordycepsem; 200 /cordyceps contains=Cordyceps w pigułce; 200 /amanita-muscaria; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /404; 404 /error; 200 /blog; 200 /blog?page=2; 404 /blog?page=3; 404 /en/blog; 200 /autor/admin; 404 /autor/nobody; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/blogbadania-nad-cordycepsem; 200 /sitemap.xml contains=/amanita-muscaria.
|
||||
|
||||
Commit in the app (feat: SSR site on vue-journal-layer with the PHP URL map) and push main. Then mount it: in ../sm-grzybyfunkcjonalne-app run git submodule add git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git vue-grzybyfunkcjonalne-app (git adopts the existing nested repo), commit only .gitmodules and the gitlink in the host (chore: mount vue-grzybyfunkcjonalne-app submodule). Never stage files under the submodule from the host repo (.planning/notes/core-plugins-own-repos.md).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app install --frozen-lockfile && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app | grep -q '^160000' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL", "ERR_PNPM_OUTDATED_LOCKFILE" or "--- FAIL"; the "smoke-ssr: ok" line or the "--- PASS: TestBootUserTranslateJournal" line is absent; the host index has no 160000 gitlink for vue-grzybyfunkcjonalne-app.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every line of tests/fixtures/expect.txt passes against the built .output: post by pl slug 200, base slug 404, /cordyceps 200 with its content marker, /amanita-muscaria 200, unknown roots and /404, /error 404, /blog?page=2 200 and ?page=3 404, /en/blog 404, /autor/admin 200, /rss.xml RSS type, sitemap entries for a post and an empty category.
|
||||
- package.json pins vue-journal-layer to #v0.1.0 and pnpm-lock.yaml records the commit of that tag.
|
||||
- `grep -nE '^\s*modules:' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints nothing.
|
||||
- nuxt.config.ts contains category: '/:category', author: '/autor/:author' and tag: false.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app` starts with 160000 and .gitmodules has url git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git.
|
||||
- Host go vet and TestBootUserTranslateJournal stay green.
|
||||
</acceptance_criteria>
|
||||
<done>The site app answers the PHP post, category, blog, author and error URLs with the right status codes from SSR, installed from the layer tag and mounted in the host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Homepage, typed menu, FAQ and footer with the current Polish copy in the refreshed design</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Content Inventory, Runtime State Inventory: live HTML is the copy authority), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/homepage.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/homepage, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu, ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/base.css</read_first>
|
||||
<action>Copy authority (RESEARCH Runtime State Inventory): fetch https://grzybyfunkcjonalne.pl/ with curl and take every Polish string from the live HTML; fall back to /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql sitemanager settings and the g15_faq rows only for text the live page does not show. Carry the copy verbatim (D-11); drop the sitemanager enable_* toggles and the arsha demo blocks (clients, portfolio, pricing, team, whyus, newsletter, contact).
|
||||
|
||||
i18n/locales/pl.json gains the keys home.hero.title, home.hero.text, home.hero.cta, home.latest.title, home.latest.more (Czytaj więcej → /blog), home.about.title, home.about.columns (two rich-text strings), home.about.cta, home.cta.title, home.cta.text, home.services.title, home.services.items (4 entries with title and text), footer.* and menu.* keys. Rich text with markup is stored as HTML strings and rendered through the layer's gateJournalHtml before v-html.
|
||||
|
||||
app/data/faq.pl.json: { slug: what-mushrooms-can-give-you, intro, items: [16 × { question, answer_html }] } with the live answers (D-12). app/data/menu.ts: export type MenuItem = { kind: 'link'; labelKey: string; to: string } | { kind: 'journal-categories'; labelKey: string }, export const MAIN_MENU: MenuItem[] = Blog → /blog, then the Grzyby Funkcjonalne dropdown of kind journal-categories (the live g15_menu main: Blog plus a blog_categories dropdown). app/data/contact.ts: export const CONTACT with name, street Wróblewskiego 1A/1U, city Namysłów, phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, footer links (Artykuły blogowe → /blog, Golem15, Ciemność.com, Dobra bomba with their live URLs) and social x.com/ophiel118.
|
||||
|
||||
Download the hero background (live las.webp), the CTA image (live 65f9f2e96287d085855960.png) and the favicon from the live site into public/images/hero-forest.webp, public/images/cta.png and public/favicon.ico (keep the original bytes).
|
||||
|
||||
Components (tokens only, no Bootstrap, no hard-coded colours outside tokens.css): SiteHeader (logo text Grzyby Funkcjonalne linking /, MAIN_MENU rendered from the typed config; the journal-categories item lists useJournalApi().listCategories() flattened, each through useJournalLinks().categoryLink; keyboard-accessible dropdown and a mobile toggle), SiteFooter (CONTACT details, tel and mailto links, footer links, social, copyright with the current year), HeroSection (full-width image with a darkening overlay token, h1, text, CTA to /blog), LatestPosts (useJournalApi().listPosts({ perPage: 3 }) rendered with the layer JournalPostCard, plus the Czytaj więcej link to the journal-index route), AboutSection, CtaSection, ServicesSection (4 boxes), FaqSection (accessible details/summary list; answers through gateJournalHtml). index.vue composes Hero, LatestPosts, About, Cta, Services, Faq and sets useSeoMeta title Witamy and the site description. layouts/default.vue renders SiteHeader, main and SiteFooter. base.css: global typography from tokens, section spacing, buttons, cards, responsive breakpoints at 768px and 1100px; the refreshed look is cleaner and roomier than the arsha template (D-01, D-03), not a pixel copy.
|
||||
|
||||
Append to expect.txt: 200 / contains=Grzyby Funkcjonalne; 200 / contains=Czym są grzyby funkcjonalne?; 200 / contains=Dlaczego warto?; 200 / contains=<the first FAQ question>; 200 / contains=kontakt@grzybyfunkcjonalne.pl; 200 / contains=href="/cordyceps"; 200 / contains=href="/amanita-muscaria"; 200 / contains=Najnowsze Badania nad Cordycepsem.
|
||||
|
||||
Commit in the app (feat: homepage, menu, FAQ and footer with the current Polish copy) and push main; bump the host gitlink in a separate host commit (chore: point vue-grzybyfunkcjonalne-app at the homepage).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL"; the "smoke-ssr: ok" line is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- GET / answers 200 and contains the hero title, the about and services headings, the first FAQ question, the contact email and the latest post title from the fixtures.
|
||||
- The header lists all seven category links, including /amanita-muscaria (include_empty=1 through the layer client).
|
||||
- app/data/faq.pl.json has exactly 16 items, each with non-empty question and answer_html.
|
||||
- app/data/menu.ts exports MenuItem and MAIN_MENU, and MAIN_MENU contains an item with kind journal-categories.
|
||||
- `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components` prints nothing (colours live in tokens.css).
|
||||
</acceptance_criteria>
|
||||
<done>The homepage, menu, FAQ and footer show the current Polish content in the refreshed design, and the menu lists every Journal category.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Kontakt, trwaja-prace, error pages, blog header, analytics through runtime config and the app README</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/kontakt.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/content-kontakt.pl.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/trwaja-prace.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/404.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/error.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/blog.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (the GA snippet), ../sm-summercmsio-app/vue-summercmsio-app/README.md, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-14, D-23)</read_first>
|
||||
<action>kontakt.vue (D-14): PageHero with Kontakt and the subtitle Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych (content-kontakt.pl.htm), then the CONTACT details with a tel link and a mailto:kontakt@grzybyfunkcjonalne.pl link. No HTML form element, no input fields and no request to any endpoint; the contact and newsletter forms stay dropped (backlog 999.1). trwaja-prace.vue: PageHero plus the heading Niebawem (trwaja-prace.htm). Both set useSeoMeta titles.
|
||||
|
||||
error.vue: reads the Nuxt error; 404 renders the PHP 404.htm copy (Nie Znaleziono / Nie znaleziono tej strony) and 500 renders the error.htm copy (Wystąpił Błąd / Spróbuj ponownie za chwilę), each with a link to / and the site layout. /404 and /error have no page of their own: they fall into the /:category route, the category lookup misses and the 404 page renders (live status for both is 404).
|
||||
|
||||
Journal/IndexHeader.vue overrides the layer component: PageHero with the blog image (public/images/cta.png, the live blog header image), title Artykuły (plus Strona N when page > 1, matching the live "Artykuły | Strona 2" title) and the description Przeczytaj nasze artykuły na temat grzybów funkcjonalnych (blog.htm meta). PageHero is a shared component (image, title, subtitle, overlay token).
|
||||
|
||||
analytics.ts (D-23): export const GA_ID_PATTERN = /^G-[A-Z0-9]{4,20}$/ and export function gaHeadScripts(id: string) returning [] when id is empty or fails the pattern, else two head script objects: the async loader src https://www.googletagmanager.com/gtag/js?id=<id> and an inline script that defines window.dataLayer, gtag, gtag('js', new Date()) and gtag('config', '<id>'); the id is the only interpolated value and is pattern-checked first. app.vue calls useHead({ script: gaHeadScripts(useRuntimeConfig().public.gaId) }). The production value G-MLSV7WJ6W4 is set only through NUXT_PUBLIC_GA_ID on the server (16-03 env template); the default and staging builds render no analytics.
|
||||
|
||||
nuxt.config.ts: robots { disallow: ['/gfadmin', '/_journal'] }; sitemap { exclude: ['/trwaja-prace'] } (the live sitemap lists /, /blog, categories and posts, not the Niebawem page); linkChecker { excludeLinks: ['/gfadmin', '/gfadmin/**', '/_journal/**', '/storage/**', '/rss.xml'] }. package.json gains the script smoke:ga (the smoke:ssr command plus --env NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 --expect tests/fixtures/expect-ga.txt).
|
||||
|
||||
Append to expect.txt: 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl notcontains=<form; 200 /trwaja-prace contains=Niebawem; 404 /404 contains=Nie znaleziono tej strony; 200 /blog contains=Przeczytaj nasze artykuły; 200 /blog?page=2 contains=Strona 2; 200 / notcontains=googletagmanager; 200 /robots.txt contains=Disallow: /gfadmin; 200 /sitemap.xml notcontains=/trwaja-prace. expect-ga.txt: 200 / contains=googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4.
|
||||
|
||||
README.md (an application README may name the site): what the app is, requirements (Node 22.18+, pnpm 11), commands (pnpm install --frozen-lockfile, dev, build, preview, smoke:ssr, smoke:ga), the D-15 URL map table (PHP page → Nuxt route), configuration (journal.routes, runtime env NUXT_JOURNAL_API_BASE, NUXT_PUBLIC_GA_ID, NUXT_SITE_URL, NUXT_SITE_INDEXABLE, NITRO_HOST, NITRO_PORT), content locations (pl.json, app/data/*), the layer dependency and how to bump its tag, local layer work with an uncommitted link: override.
|
||||
|
||||
Commit in the app (feat: kontakt, error pages, blog header and runtime GA), push main, and bump the host gitlink in a separate host commit (chore: point vue-grzybyfunkcjonalne-app at kontakt and analytics).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ga</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke-ssr: FAIL"; fewer than two "smoke-ssr: ok" lines are printed.</fails_when>
|
||||
<automated>! grep -rn '<form' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app</automated>
|
||||
<fails_when>Exit status 1 with a matching line printed: the app contains a form element (D-14 dropped forms).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- /kontakt answers 200 with the mailto link and no form; /trwaja-prace answers 200 with Niebawem; /404 answers 404 with the PHP 404 copy.
|
||||
- /blog shows the Artykuły header and description; /blog?page=2 shows Strona 2.
|
||||
- Without NUXT_PUBLIC_GA_ID the homepage has no googletagmanager script; with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 it loads gtag/js?id=G-MLSV7WJ6W4.
|
||||
- gaHeadScripts returns [] for '' and for a value that fails GA_ID_PATTERN.
|
||||
- /robots.txt disallows /gfadmin; /sitemap.xml does not list /trwaja-prace.
|
||||
- The host gitlink for vue-grzybyfunkcjonalne-app points at the app's pushed main commit.
|
||||
</acceptance_criteria>
|
||||
<done>Every non-Journal PHP page has its Nuxt successor, errors render the PHP copy, and analytics is controlled by runtime config only.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Repo-committed site copy → v-html | FAQ answers and rich homepage copy are HTML strings rendered in the DOM |
|
||||
| Server env → head scripts | NUXT_PUBLIC_GA_ID is interpolated into an inline script |
|
||||
| Visitor → kontakt page | A contact page could invite input if a form were added |
|
||||
| pnpm git dependency | The app pulls the layer from git.golem15.com |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-06 | Tampering | FaqSection, AboutSection rich text (v-html) | low | mitigate | Copy is committed in the repo and reviewed; every v-html still goes through the layer's gateJournalHtml (same three Go patterns) |
|
||||
| T-16-07 | Tampering | app.vue gaHeadScripts inline script | medium | mitigate | GA_ID_PATTERN (^G-[A-Z0-9]{4,20}$) gates the only interpolated value; malformed or empty ids emit no script (Task 3 acceptance) |
|
||||
| T-16-08 | Information Disclosure | analytics on staging | low | mitigate | gaId defaults to ''; staging leaves NUXT_PUBLIC_GA_ID empty (D-23); production sets G-MLSV7WJ6W4 only in the server env |
|
||||
| T-16-09 | Spoofing | /kontakt | low | accept | D-14 drops the form: no input surface, no new Go endpoint; mailto and tel links only (negative grep for a form element) |
|
||||
| T-16-SC | Tampering | npm/pnpm installs and the layer git dependency | high | mitigate | Exact versions only (nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, already approved in RESEARCH Package Legitimacy Audit); the layer is pinned by tag and lockfile commit and installed with --frozen-lockfile; no new packages |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build` then `run smoke:ssr` and `run smoke:ga` print "smoke-ssr: ok".
|
||||
- The host has the vue-grzybyfunkcjonalne-app gitlink and its go vet and boot test stay green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The site app is SSR, pl-only, installed from layer tag v0.1.0 and mounted in the host (D-04, D-07, D-08, D-16).
|
||||
- Every PHP URL form from D-15 answers with the expected status on the fixture API.
|
||||
- Homepage, menu, FAQ, kontakt, trwaja-prace and error pages carry the current Polish copy in the refreshed design (D-11, D-12, D-14).
|
||||
- GA follows NUXT_PUBLIC_GA_ID (D-23).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 02
|
||||
subsystem: frontend (Nuxt site app)
|
||||
tags: [nuxt, ssr, vue-journal-layer, i18n, nuxtjs-seo, swr, google-analytics, submodule]
|
||||
status: complete
|
||||
|
||||
requires:
|
||||
- phase: 16-01
|
||||
provides: "vue-journal-layer v0.1.0 (journal.routes, useJournalApi/useJournalLinks, gateJournalHtml, --journal-* tokens, testing/smoke-ssr.mjs and fixture-api.mjs)"
|
||||
provides:
|
||||
- "vue-grzybyfunkcjonalne-app repo (private, SSH) on git.golem15.com, main b50c808"
|
||||
- "submodule vue-grzybyfunkcjonalne-app in sm-grzybyfunkcjonalne-app (gitlink b50c808, pushed)"
|
||||
- "PHP URL map on the layer: /blog, /blog/:slug, /:category, /autor/:author, no tag pages; /404, /error and unknown roots 404"
|
||||
- "homepage, typed menu, FAQ, footer, /kontakt, /trwaja-prace, error.vue with the live Polish copy"
|
||||
- "GA through NUXT_PUBLIC_GA_ID only (gaHeadScripts, GA_ID_PATTERN)"
|
||||
- "smoke:ssr (35 checks) and smoke:ga (2 checks) on 14 fixture posts from the PHP export"
|
||||
affects: [16-03, 16-04, 16-05]
|
||||
|
||||
actuals:
|
||||
tokens: 30493 # chars/4 over the 44 tracked app files, pnpm-lock.yaml and public/ binaries excluded
|
||||
tasks: 3
|
||||
commits: 6 # 3 in vue-grzybyfunkcjonalne-app (git rev-list --count HEAD, new repo) + 3 in sm-grzybyfunkcjonalne-app (git rev-list --count ff20e6c..HEAD)
|
||||
commits: 6
|
||||
plan_head_before: ff20e6cce25628341c4c1e6b5879f1f3979baa2d # sm-grzybyfunkcjonalne-app; vue-grzybyfunkcjonalne-app started empty
|
||||
plan_head_after: 33bc5e04bfc2ad2c79c33b01c962d49ed21dd7cc # sm-grzybyfunkcjonalne-app; app repo main b50c808f88e43fd97072690e52bc9144efbcc3a8
|
||||
|
||||
tech-stack:
|
||||
added: ["vue-grzybyfunkcjonalne-app (new repo): nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vue-journal-layer git tag v0.1.0 (lock commit 783426c)"]
|
||||
patterns:
|
||||
- "Site app extends the layer with no modules key; journal.routes carries the site's URL shape"
|
||||
- "Visitor address recorded in event.context on the Nitro request hook, so swr-cached renders still forward X-Forwarded-For"
|
||||
- "Site copy in pl.json (HTML columns allowed via i18n compilation.strictMessage false), data in typed app/data files, every v-html through gateJournalHtml"
|
||||
- "Colours only in tokens.css (:root:root overrides of --journal-* plus --site-*); components hold no colour literals"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/composables/useJournalApi.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/server/plugins/client-address.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/server/plugins/html-errors.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/deferred-items.md
|
||||
modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
|
||||
key-decisions:
|
||||
- "Under routeRules swr the site app records the visitor address in event.context on the Nitro request hook and overrides useJournalApi to forward it, because Nitro's cached handler strips request headers and every SSR cache miss would otherwise share one Go rate-limit bucket; the proper fix belongs in vue-journal-layer (deferred-items.md)"
|
||||
- "Page URLs of the site answer errors with the HTML error page even for fetch-like requests (server/plugins/html-errors.ts); only /api/, /_ and .json paths keep Nuxt's JSON errors"
|
||||
- "Site rich-text copy stays in pl.json with i18n compilation.strictMessage false; contact data (email, phone) lives in app/data/contact.ts because an email address is not a valid vue-i18n message"
|
||||
- "The host sm-grzybyfunkcjonalne-app master was pushed to its empty remote together with the submodule mount"
|
||||
|
||||
patterns-established:
|
||||
- "App-side SSR smoke: layer smoke-ssr.mjs with app fixtures built from the PHP export by a script, one expectations file per environment variant"
|
||||
|
||||
requirements-completed: [SC-1, SC-3]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The site app extends vue-journal-layer v0.1.0 from the git tag (lockfile commit 783426c), builds as an SSR Node server with swr route rules and is mounted as a submodule of sm-grzybyfunkcjonalne-app (D-04, D-07, D-08)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app install --frozen-lockfile && build -> ok; git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app -> 160000 b50c808"
|
||||
status: pass
|
||||
- kind: integration
|
||||
ref: "go -C ../sm-grzybyfunkcjonalne-app vet ./... && go test ./... -run '^(TestBootUserTranslateJournal)$' -> PASS"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Every D-15 URL form answers with the expected status: post by pl slug 200, base slug 404, /cordyceps with content, /amanita-muscaria 200, unknown roots, /rss, /feed, /404, /error 404, /blog?page=2 200, ?page=3 404, /en/blog 404, /autor/admin 200, /autor/nobody 404, /rss.xml RSS, sitemap entries (D-15, D-16, D-24)"
|
||||
requirement: SC-3
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "pnpm run smoke:ssr -> smoke-ssr: ok (35 checks), tests/fixtures/expect.txt"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Homepage (hero, latest 3 posts, about, CTA, reasons, FAQ with 16 entries), menu with all 7 categories, footer, /kontakt with mailto and no form, /trwaja-prace, the PHP 404 copy and the blog header carry the live Polish copy (D-11, D-12, D-14)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "tests/fixtures/expect.txt homepage, kontakt, trwaja-prace, /404 and /blog lines in smoke:ssr (35 checks ok); ! grep -rn '<form' app -> no match"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Google Analytics G-MLSV7WJ6W4 loads only with NUXT_PUBLIC_GA_ID; empty or malformed ids give no script (D-23, T-16-07)"
|
||||
requirement: SC-1
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "smoke:ssr '200 / notcontains=googletagmanager'; pnpm run smoke:ga -> smoke-ssr: ok (2 checks)"
|
||||
status: pass
|
||||
- kind: command
|
||||
ref: "node -e gaHeadScripts('') / gaHeadScripts(\"G-ABC');alert(1);//\") / gaHeadScripts('UA-123') -> [] each"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The refreshed design (Jost/Open Sans, green palette, roomier sections) reads as a cleaner successor of the arsha look on desktop and mobile (D-01, D-03)"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Visual quality is a judgment call; the smoke only proves markup, copy and that components hold no colour literals. 16-04's staging checkpoint is the natural place to look at it."
|
||||
|
||||
duration: 16 min
|
||||
completed: 2026-10-09
|
||||
---
|
||||
|
||||
# Phase 16 Plan 02: vue-grzybyfunkcjonalne-app Summary
|
||||
|
||||
**grzybyfunkcjonalne.pl is now a Nuxt 4 SSR app that extends `vue-journal-layer` v0.1.0 from its git tag. It answers the PHP site's URLs (`/blog`, `/blog/:slug`, `/:category`, `/autor/:author`, `/kontakt`, `/trwaja-prace`) with full HTML, returns real 404s where the PHP site soft-200'd, and carries the live Polish homepage, menu, FAQ, footer and error copy in a refreshed green design. Analytics loads only from `NUXT_PUBLIC_GA_ID`. The app is pushed (private, SSH) and mounted as a submodule of `sm-grzybyfunkcjonalne-app`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 16 min (wall clock as measured by `date`)
|
||||
- **Started:** 2026-10-09T16:11:41Z
|
||||
- **Completed:** 2026-10-09T16:28:10Z
|
||||
- **Tasks:** 3 (Task 1 a tracer, auto-verified end to end before expansion)
|
||||
- **Files:** 44 tracked files in the new app repo; `.gitmodules` and the gitlink in the host
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **URL parity (D-15, D-16, D-24).** `journal.routes` is `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`, with page sizes 12/10/10 as in `blog.htm`, `index.htm` and `autor.htm`. Static pages win over `/:category`.
|
||||
- Unknown roots, `/rss`, `/feed`, `/404` and `/error` all answer 404.
|
||||
- `/amanita-muscaria` answers 200 with an empty list.
|
||||
- Posts answer on the Polish slug only, and `/en/blog` is a 404.
|
||||
- **SSR with swr (D-07).** `'/**'` is cached 300 s and `'/blog/**'` 120 s on the Node server. Pages stay full HTML.
|
||||
- **Content port (D-11, D-12, D-14).** All Polish copy comes from the live HTML (curl, 2026-10-09):
|
||||
- Homepage: hero, latest 3 posts (layer `JournalPostCard`), "Czym są grzyby funkcjonalne?" with both rich-text columns, "Skorzystaj z wiedzy ekspertów", "Dlaczego warto?" with 4 reasons, and the FAQ (16 Q&A in `app/data/faq.pl.json`).
|
||||
- `MAIN_MENU` is typed config. Its `journal-categories` dropdown lists all 7 categories from the API, the empty one included.
|
||||
- Footer: address, `tel:` and `mailto:` links, footer and project links, X profile.
|
||||
- `/kontakt` shows the contact details only, with no form.
|
||||
- `/trwaja-prace` shows "Niebawem".
|
||||
- `error.vue` shows the PHP 404 copy ("Nie Znaleziono / Nie znaleziono tej strony") or the error copy ("Wystąpił Błąd / Spróbuj ponownie za chwilę").
|
||||
- The blog hero overrides `JournalIndexHeader`: "Artykuły" with "Przeczytaj nasze artykuły…", plus "Strona N" after page 1.
|
||||
- **Design (D-01, D-03).**
|
||||
- Colours: `tokens.css` overrides `--journal-*` on `:root:root` and adds `--site-*` tokens seeded from #0D705D, #27AE60, #138C74, #1AAC8F and #444444.
|
||||
- Fonts: Jost for headings and Open Sans for body text, self-hosted by `@nuxt/fonts`.
|
||||
- `base.css` holds the global styles with breakpoints at 768px and 1100px. Components contain no colour literals.
|
||||
- The layer is unchanged.
|
||||
- **Analytics (D-23).** `gaHeadScripts(id)` returns nothing for an empty id or one that fails `GA_ID_PATTERN`. Otherwise it returns the gtag loader and config script. `app.vue` feeds it `runtimeConfig.public.gaId`.
|
||||
- **SEO.**
|
||||
- `robots.txt` disallows `/gfadmin` and `/_journal`.
|
||||
- The sitemap lists posts and every category but not `/trwaja-prace`.
|
||||
- `linkChecker` skips Go-served paths.
|
||||
- Titles take the form `Witamy | Grzyby Funkcjonalne` (the homepage), as on the live site.
|
||||
- **Tests.** `tests/fixtures/api` holds 14 published posts generated from the PHP journal export (pl text, Go wire dates, the admin author the 16-03 runbook assigns) and the 7 PHP categories. `smoke:ssr` runs 35 checks and `smoke:ga` runs 2.
|
||||
|
||||
## Task Commits
|
||||
|
||||
App repo `vue-grzybyfunkcjonalne-app` (branch `main`, pushed over SSH):
|
||||
|
||||
1. **Task 1 (tracer): the app on layer v0.1.0 with the PHP URL map, mounted in the host.** `2b2d1c7` (feat). Host: `467d0a0` (chore: mount vue-grzybyfunkcjonalne-app submodule).
|
||||
2. **Task 2: homepage, typed menu, FAQ and footer.** `caf4d3f` (feat). Host: `6063e9b` (chore: point vue-grzybyfunkcjonalne-app at the homepage).
|
||||
3. **Task 3: kontakt, trwaja-prace, error pages, blog header, runtime GA and README.** `b50c808` (feat). Host: `33bc5e0` (chore: point vue-grzybyfunkcjonalne-app at kontakt and analytics).
|
||||
|
||||
The tracer gate was auto mode off, `end-of-phase` and automated-only `<verify>`. The tracer verify was re-run (install, build, smoke, gitlink, host vet and boot test all passed) before expansion.
|
||||
|
||||
**Remote state:**
|
||||
|
||||
- App `refs/heads/main` is at `b50c808`.
|
||||
- The host `master` is at `33bc5e0`. The remote was empty, so this was its first push, made together with the mount.
|
||||
|
||||
**Plan metadata:** the docs commit in summercms.go (this SUMMARY, `deferred-items.md`, STATE, ROADMAP, REQUIREMENTS).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
All of these are in `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (new repository) unless noted:
|
||||
|
||||
- `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.nvmrc`, `.gitignore`, `tsconfig.json`: the package.
|
||||
- Scripts: build, dev, preview, prepare, `smoke:ssr` and `smoke:ga`.
|
||||
- Dependencies: nuxt 4.4.8, vue 3.5.35 and the layer tag. The layer's modules are not re-listed.
|
||||
- `nuxt.config.ts`: config for the layer, routes, i18n, site, routeRules, runtime config, robots, sitemap, linkChecker, css and fonts.
|
||||
- `app/app.vue` and `app/error.vue`; `app/layouts/default.vue` (header, main, footer, with Journal routes inside `.journal-layout`).
|
||||
- `app/pages/{index,kontakt,trwaja-prace}.vue`.
|
||||
- `app/components/{SiteHeader,SiteFooter,HeroSection,LatestPosts,AboutSection,CtaSection,ServicesSection,FaqSection,PageHero}.vue` and `app/components/Journal/IndexHeader.vue`.
|
||||
- `app/data/{menu.ts,contact.ts,faq.pl.json}`, `app/utils/analytics.ts`, `app/composables/useJournalApi.ts`.
|
||||
- `server/plugins/{client-address,html-errors}.ts`.
|
||||
- `app/assets/css/{tokens,base}.css`, `i18n/locales/pl.json`.
|
||||
- `public/favicon.ico`, `public/images/{hero-forest.webp,cta.png}`.
|
||||
- `tests/fixtures/api/{posts,categories,tags,authors}.json`, `tests/fixtures/api/rss.xml`, `tests/fixtures/{expect,expect-ga}.txt`.
|
||||
- `README.md`.
|
||||
- In the host: `../sm-grzybyfunkcjonalne-app/.gitmodules` and the `vue-grzybyfunkcjonalne-app` gitlink.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` in the frontmatter. Smaller choices:
|
||||
|
||||
- **Button targets.** The hero "Czytam", the CTA "Czytam" and "Poznaj naszą misję" all link to `/blog`. On the live site they pointed at `#`.
|
||||
- **Homepage meta description.** It is the live one, "Odkryj Sekrety Natury dla Lepszego Zdrowia". `site.description` keeps the tagline.
|
||||
- **Footer labels.** They read "Telefon" and "E-mail". The live labels were the English "Phone number" and "Email".
|
||||
- **FAQ heading.** It is kept verbatim from the live page, including its typo ("Grzyby funcjonalne - Pytania i Odpowiedzi").
|
||||
- **Social link.** The live footer renders no social links, but the sitemanager data holds `https://x.com/ophiel118`, which the plan names. The footer shows it.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical] Visitor address lost on swr-cached SSR renders**
|
||||
- **Found during:** Task 1 (first smoke run: `smoke-ssr: FAIL x-forwarded-for not forwarded`).
|
||||
- **Issue:** Nitro's cached handler (used for `routeRules` swr) renders from a request copy that keeps only `varies` headers. The layer's `useJournalApi` therefore forwarded only the Nuxt peer address. Every cache miss, including attacker-chosen query strings, would share the Go limiter's 120/min bucket (Pitfall 5, T-16-03).
|
||||
- **Fix:**
|
||||
- `server/plugins/client-address.ts` records `x-forwarded-for` plus the peer in `event.context` on the Nitro `request` hook. The context object is shared with the cached render.
|
||||
- `app/composables/useJournalApi.ts` overrides the layer composable (the project wins over a layer by import priority) and forwards that value.
|
||||
- The layer is unchanged. The layer fix is logged in `deferred-items.md`.
|
||||
- **Files modified:** `server/plugins/client-address.ts`, `app/composables/useJournalApi.ts` (both new, outside the plan's file list).
|
||||
- **Verification:** The smoke's forwarding assertion passes on every run.
|
||||
- **Commit:** 2b2d1c7.
|
||||
|
||||
**2. [Rule 3 - Blocking] Nuxt answered error pages with JSON to the smoke's fetch**
|
||||
- **Found during:** Task 2 (inspecting `/nieistnieje`).
|
||||
- **Issue:** Node's fetch sends `sec-fetch-mode: cors`, so Nuxt's error handler returned JSON. The plan's `404 /404 contains=Nie znaleziono tej strony` could never pass. A curl-based check would hit the same thing.
|
||||
- **Fix:** `server/plugins/html-errors.ts` adds `text/html` to `Accept` for page paths that do not ask for JSON. `/api/`, `/_…` and `.json` paths keep Nuxt's behaviour. Client navigation uses `_payload.json`, so it is unaffected.
|
||||
- **Verification:** `404 /404 contains=Nie znaleziono tej strony` passes.
|
||||
- **Commit:** b50c808.
|
||||
|
||||
**3. [Rule 1 - Bug in plan data] Homepage "latest post" expectation**
|
||||
- **Issue:** The plan's line `200 / contains=Najnowsze Badania nad Cordycepsem` assumed the cordyceps post is among the 3 newest. With the real export dates (2024-06-12) and a fixture set covering all 6 categories, it is not. The 3 newest are Lion's Mane posts from October 2024.
|
||||
- **Fix:** The fixtures keep the true dates. The homepage lines check the actual newest titles ("Jak Dawkować Lion…", "Wprowadzenie do Grzyba Lwia Grzywa"), which matches the acceptance wording "the latest post title from the fixtures". The cordyceps title is still checked on its own post page.
|
||||
- **Commit:** caf4d3f.
|
||||
|
||||
**4. [Rule 3 - Blocking] `smoke:ga` could not observe forwarding with a single `/` line**
|
||||
- **Issue:** `nuxt-server.mjs`'s readiness probe renders `/` into the swr cache, so the GA run's `/` request made no API call. The smoke's forwarding assertion then failed.
|
||||
- **Fix:** `expect-ga.txt` also checks the gtag config on `/blog`, an uncached page.
|
||||
- **Commit:** b50c808.
|
||||
|
||||
**5. [Rule 3 - Blocking] Build constraints**
|
||||
- `app.head.htmlAttrs.lang` was removed after nuxt-seo-utils flagged it. i18n renders `lang="pl-PL"`.
|
||||
- `i18n.compilation.strictMessage: false` was added, because `@nuxtjs/i18n` rejects HTML in locale messages by default and the plan stores the about columns as HTML strings in `pl.json`. All such HTML is still gated before `v-html`.
|
||||
- **Commits:** 2b2d1c7, caf4d3f.
|
||||
|
||||
**6. [Scope adjustment] favicon.ico**
|
||||
- The live `/favicon.ico` is a soft-200 HTML page, so there were no original bytes to keep.
|
||||
- `public/favicon.ico` (16/32/48) was converted with ImageMagick from the live icon (`thumb_105_64…webp`). The hero and CTA images are byte-identical copies.
|
||||
- **Commit:** caf4d3f.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 6 (2 Rule 2/3 security and correctness fixes in new app files, 1 plan-data correction, 2 build/test blockers, 1 asset substitution).
|
||||
**Impact on plan:** All artifacts and key links in the plan exist. The two server plugins and the composable override are additions; they change no route, option or copy.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **pnpm store workaround.** The sandbox `/tmp` pnpm store is read-only, so installs ran with `TMPDIR=<scratchpad>` and `npm_config_store_dir`/`pnpm_config_store_dir` pointed at the project-drive store, as in 16-01.
|
||||
- **Font metadata fetches.** `@nuxt/fonts` retries metadata fetches to fonts.bunny.net and api.fontshare.com, which the sandbox blocks. These are warnings only, and the builds succeed.
|
||||
- **Pre-existing host state.** The host working tree shows ` m plugins/golem15/journal` (submodule working tree drift) and an untracked `.gsd/`. Both predate this plan and were left alone; only `.gitmodules` and the app gitlink were committed.
|
||||
- **Host push.** The host remote `sm-grzybyfunkcjonalne-app` had no branches (`origin/master [gone]`). Pushing `master` created it with the full local history.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every section renders real data or committed copy.
|
||||
|
||||
`featured_image_url` is `null` in the fixtures because the export carries no images; 16-03 migrates them. Cards then render without an image, the same as for any post without one.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| threat_flag: header-trust | server/plugins/client-address.ts | Copies the client-supplied `x-forwarded-for` plus the peer address into the SSR fetch header, the same values the layer forwards. Go reads the rightmost untrusted hop and trusts only 127.0.0.1, so a client still cannot pick another visitor's bucket. Same trust model as T-16-03. |
|
||||
| threat_flag: error-format | server/plugins/html-errors.ts | Rewrites `Accept` on page paths so errors render as HTML. It does not touch `/api/`, `/_…` or `.json` paths and does not expose more data than the JSON error did. |
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. Production sets `NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4` through the 16-03 env template.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- **16-03** builds `.output` from the submodule and runs it with `NUXT_JOURNAL_API_BASE` and `NUXT_SITE_INDEXABLE=false` on staging.
|
||||
- Its curl-based smoke gets HTML error pages for page paths thanks to `html-errors.ts`.
|
||||
- The Go limiter must trust 127.0.0.1 so the forwarded XFF is honoured; the host `config/http.yaml` already does.
|
||||
- **16-05** unit tests:
|
||||
- `app/utils/analytics.ts` (`GA_ID_PATTERN`, `gaHeadScripts`) and `app/data/*` shape checks (16 FAQ items, `MAIN_MENU` kinds) can run under `node --test`.
|
||||
- The two server plugins are small enough to unit-test with a stub event.
|
||||
- **Layer follow-ups** are in `deferred-items.md`: forward the client address under swr in the layer, and give the smoke tool an `Accept: text/html` default.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: app files `nuxt.config.ts`, `app/data/menu.ts`, `app/data/faq.pl.json`, `app/utils/analytics.ts`, `tests/fixtures/expect.txt`, `tests/fixtures/expect-ga.txt`, `README.md`, and the host `.gitmodules` entry with url `git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git`.
|
||||
- FOUND: app commits 2b2d1c7, caf4d3f and b50c808 on origin/main; host commits 467d0a0, 6063e9b and 33bc5e0 on origin/master.
|
||||
- Plan verification:
|
||||
- `pnpm install --frozen-lockfile` and `build` both passed.
|
||||
- `smoke:ssr` printed `smoke-ssr: ok (35 checks)` and `smoke:ga` printed `smoke-ssr: ok (2 checks)`.
|
||||
- The host gitlink is `160000 b50c808`, and host `go vet ./...` and `TestBootUserTranslateJournal` pass.
|
||||
- The `<form` grep over `app/` and the hex-colour grep over components and pages match nothing.
|
||||
- The lockfile pins `783426c5d84c5d7c088f1e74b01c5240811c88d2` (tag v0.1.0).
|
||||
|
||||
---
|
||||
*Phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views*
|
||||
*Completed: 2026-10-09*
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16-02"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/config/backend.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/build.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitignore
|
||||
- .planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
user_setup:
|
||||
- service: local PostgreSQL (rehearsal database)
|
||||
why: "Docker is not usable for this user, so scripts/smoke.sh needs a Postgres >= 15 with ICU and a role that may CREATE DATABASE to rehearse the migration runbook on a throwaway database"
|
||||
env_vars:
|
||||
- name: SMOKE_ADMIN_DSN
|
||||
source: "A DSN to the local postgres maintenance database for a role with CREATEDB, e.g. postgres://<role>:<password>@127.0.0.1:5432/postgres?sslmode=disable"
|
||||
estimate:
|
||||
tokens: 140000
|
||||
raw_tokens: 140000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-09: the admin is mounted at /gfadmin through backend.uri: /gfadmin in config/backend.yaml; no assembled route starts with /backend, and nginx answers 404 for /backend."
|
||||
- "D-22: grzyby:import-featured-images reads a JSONL slug-to-file map from a PHP database query, copies each original into the Go bucket under its Winter disk name (attach.BlobKey) and inserts the system_files row for the Journal post, so old /storage/app/uploads/public/... image URLs keep resolving; a second run creates nothing."
|
||||
- "D-21: grzyby:import-categories fills each Journal category's name, description and rich content field from a PHP database query, preferring the pl translation, and the category pages then render that content."
|
||||
- "D-25: scripts/migrate.sh runs migrate, then makes pl the only default locale and asserts it, then imports the journal export exactly once into a fresh database and refuses a database that already holds posts."
|
||||
- "D-17: the runbook imports posts with journal:import-posts from the PHP journal export, copies storage/app/media and the featured images, and reports gaps instead of patching them (two UAT-host image references stay broken and are listed)."
|
||||
- "D-07/D-10: deploy files run two supervisor programs (Go on 127.0.0.1:8096, Nuxt on 127.0.0.1:3016) behind one staging nginx vhost on the PHP server; nginx sends /gfadmin and /_journal to Go, image files under /storage/app/{uploads/public,media} to disk with nosniff, and everything else to Nuxt."
|
||||
- "D-15/D-18: scripts/urls.txt lists every URL in the live sitemap plus the static and negative cases, and scripts/smoke.sh checks each one either against a local rehearsal (Go + Nuxt + imported posts) or against a base URL."
|
||||
- "D-14/Phase 15 D-14: no new Go HTTP endpoint is added; the app plugin only adds console commands."
|
||||
- "D-15: /autor/<slug> keeps resolving by assigning the imported posts to a named admin author through migrate.sh (--author-login, --author-slug)."
|
||||
- "Pitfall 2 is documented: imported posts must not be re-saved in /gfadmin until the Journal HTML-content follow-up lands (todo recorded)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/config/backend.yaml"
|
||||
provides: "admin mount path"
|
||||
contains: "uri: /gfadmin"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go"
|
||||
provides: "grzyby:import-featured-images and ImportFeaturedImages core"
|
||||
contains: "grzyby:import-featured-images"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go"
|
||||
provides: "grzyby:import-categories and ImportCategories core"
|
||||
contains: "grzyby:import-categories"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh"
|
||||
provides: "ordered runbook: migrate, pl default, import once, categories, media, featured images, author"
|
||||
contains: "is_default"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/smoke.sh"
|
||||
provides: "local rehearsal on a throwaway database or --url checks over urls.txt"
|
||||
contains: "urls.txt"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "staging vhost with Go/Nuxt/disk routing"
|
||||
contains: "nosniff"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/urls.txt"
|
||||
provides: "every live sitemap URL plus static and negative cases"
|
||||
contains: "/blog/blogbadania-nad-cordycepsem"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/summer.yaml"
|
||||
to: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go"
|
||||
via: "plugin golem15.grzyby registered by summer plugin:add; summer build regenerates plugins.gen.go"
|
||||
pattern: "golem15.grzyby"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go"
|
||||
to: "summercms.go/modules/lagoon/attach (BlobKey, File)"
|
||||
via: "blob key PartitionDirectory(disk_name)+disk_name; system_files row with attachment_type Golem15\\Journal\\Models\\Post and field featured_images"
|
||||
pattern: "attach.BlobKey"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh"
|
||||
to: "golem15_translate_locales"
|
||||
via: "UPDATE ... SET is_default = (code = 'pl') before journal:import-posts"
|
||||
pattern: "code = 'pl'"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
to: "127.0.0.1:8096 and 127.0.0.1:3016"
|
||||
via: "proxy_pass with Host and X-Forwarded-For"
|
||||
pattern: "proxy_add_x_forwarded_for"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: on a throwaway database the Go host migrates, imports the PHP journal export with pl as default, attaches a featured image under its Winter disk name and serves it through the Journal API with the admin at /gfadmin; then the runbook, deploy files and a URL-by-URL rehearsal of the whole site grow from that path.
|
||||
|
||||
<objective>
|
||||
Turn `sm-grzybyfunkcjonalne-app` into the deployable host: admin at /gfadmin (D-09), an app-local plugin with the featured-image (D-22) and category-content (D-21) commands, the ordered migration runbook (D-25, D-17), two-process deploy files for staging next to the PHP site (D-07, D-10), and `urls.txt` plus `smoke.sh` that rehearse every old URL locally (D-15, D-18).
|
||||
|
||||
Purpose: SC-1 (served by the SummerCMS binary) and the URL half of SC-3.
|
||||
Output: host config, plugin `golem15.grzyby`, scripts, deploy configs and README, verified by a local rehearsal.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
@../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/backend.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/storage.yaml
|
||||
@../sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
@../sm-journal-plugin/console/import_posts.go
|
||||
@../sm-journal-plugin/console/export_posts.go
|
||||
@../sm-summercmsio-app/scripts/smoke.sh
|
||||
@../sm-summercmsio-app/scripts/check-deploy.sh
|
||||
@../sm-summercmsio-app/scripts/build.sh
|
||||
@../sm-summercmsio-app/deploy/nginx/summercms.io.conf
|
||||
@../sm-summercmsio-app/deploy/supervisor/summercms-io.conf
|
||||
@../sm-summercmsio-app/deploy/env.example
|
||||
|
||||
<interfaces>
|
||||
Framework and plugin APIs this plan calls (read from source, do not re-derive):
|
||||
- summer CLI (summercms.go/cmd/summer): `make:plugin <vendor.plugin>` scaffolds plugins/<name> as module <app module>/plugins/<name> with plugin.go and go.mod; `plugin:add <dir>` registers a local plugin module in summer.yaml, go.work and the app go.mod; `build` regenerates main.go and plugins.gen.go (never hand-edit those two files).
|
||||
- bonfire.Command { Name, Description, Flags []bonfire.Flag{ Name, Description, Bare }, Run func(ctx, bonfire.Input, bonfire.Output) error }; in.Flag(name) (string, bool); out.Info, out.Warning, out.Error.
|
||||
- pact.HasCommands: Commands() []bonfire.Command (main.go appends them); party.Register(&Plugin{}) in init; Plugin has ID(), Requires(), Register(*backpack.App), Boot(*backpack.App).
|
||||
- app.Lookup[*gorm.DB]() and app.Lookup[*blob.Bucket]() (gocloud.dev/blob); attach.OpenBucket(ctx, app.Config) opens the configured bucket (config/storage.yaml uploads.bucket_url file://./storage/app/uploads/public).
|
||||
- attach.PartitionDirectory(diskName), attach.BlobKey(diskName) = PartitionDirectory(diskName)+diskName, attach.File { DiskName, FileName, FileSize, ContentType, Field, AttachmentID string, AttachmentType, IsPublic *bool, SortOrder } on table system_files; f.URL() gives /storage/app/uploads/public/<partition>/<disk_name>.
|
||||
- Journal: models.Post (golem15_journal_posts, slug host column, MorphName Golem15\Journal\Models\Post), models.Category (golem15_journal_categories, name, slug, description, plus the D-21 field named in the 16-01 Prerequisite contract); journal:import-posts --path DIR [--dry-run] [--force].
|
||||
- cabana backend.uri: one or more lowercase path segments; boot fails on an invalid value; env override SUMMER_BACKEND__URI.
|
||||
- Translate: golem15_translate_locales (code, is_default, is_enabled); seeded default is en (Pitfall 1).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Config key value `backend.uri: /gfadmin` (`config/backend.yaml`).
|
||||
- App-local plugin `golem15.grzyby` at `plugins/grzyby` (module `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby`, package `grzyby`, `Requires()` = `golem15.journal`, implements `pact.HasCommands`), listed in `summer.yaml`, `go.work` and the host `go.mod`.
|
||||
- CLI commands `grzyby:import-featured-images --map <jsonl> --source <dir> [--export <dir>] [--dry-run]` and `grzyby:import-categories --map <jsonl> [--dry-run]`.
|
||||
- Go symbols in `plugins/grzyby/console`: `FeaturedImageEntry`, `FeaturedImageOptions`, `FeaturedImageStats`, `ReadFeaturedImageMap`, `ImportFeaturedImages`, `ImportFeaturedImagesCommand`; `CategoryEntry`, `CategoryStats`, `ReadCategoryMap`, `ImportCategories`, `ImportCategoriesCommand`; helpers `flagOrDefault`, `flagOn`, `lookupDB`, `lookupBucket`, `validDiskName`.
|
||||
- Scripts `scripts/migrate.sh` (flags `--bin`, `--workdir`, `--export`, `--featured-map`, `--uploads`, `--categories-map`, `--media`, `--author-login`, `--author-slug`, `--dry-run`), `scripts/smoke.sh` (local rehearsal; `--go-only`; `--url <base>`; env `SMOKE_ADMIN_DSN`, `SMOKE_EXPORT_DIR`, `SMOKE_GO_PORT`, `SMOKE_NUXT_PORT`), `scripts/build.sh`, `scripts/check-deploy.sh`, `scripts/urls.txt` (grammar `[remote] STATUS PATH [contains=…] [notcontains=…] [type=…]`), `scripts/php-export/{featured-images.sql,categories.sql,README.md}`.
|
||||
- Deploy files `deploy/nginx/grzybyfunkcjonalne-staging.conf`, `deploy/supervisor/grzybyfunkcjonalne-go.conf` (`[program:grzybyfunkcjonalne-go]`), `deploy/supervisor/grzybyfunkcjonalne-nuxt.conf` (`[program:grzybyfunkcjonalne-nuxt]`), `deploy/env.example` (adds `SUMMER_URL`), `deploy/nuxt.env.example` (`NUXT_JOURNAL_API_BASE`, `NUXT_SITE_URL`, `NUXT_SITE_INDEXABLE`, `NUXT_PUBLIC_GA_ID`, `NITRO_HOST`, `NITRO_PORT`).
|
||||
- Host `README.md`; `.gitignore` entry `/build/`; todo `.planning/todos/pending/journal-imported-html-admin-resave.md`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer — /gfadmin, plugin golem15.grzyby and grzyby:import-featured-images serve one imported post with its Winter-named featured image</name>
|
||||
<precondition>SMOKE_ADMIN_DSN is set to a Postgres >= 15 maintenance-database DSN for a role with CREATEDB and ICU support (psql "$SMOKE_ADMIN_DSN" -c 'select 1' exits 0), or `docker info` exits 0; and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export holds the PHP export JSON files.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh</files>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-09, D-17, D-22, D-25), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer table, Featured images option a, Pitfalls 1, 3, 6, 7, Migration runbook), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (config/backend.yaml, app-local plugin + import_featured_images.go, smoke.sh), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract), ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/config/storage.yaml, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/console/export_posts.go (mustDB, flagOrDefault, flagOn), ../sm-journal-plugin/plugin.go (Commands), ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../summercms.go/modules/lagoon/attach/file.go, ../summercms.go/modules/lagoon/attach/thumb.go, ../summercms.go/modules/lagoon/attach/bucket.go, ../summercms.go/modules/cabana/prefix.go, ../summercms.go/internal/build/scaffold.go, ../sm-summercmsio-app/scripts/smoke.sh</read_first>
|
||||
<action>Journal gitlink: compare `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal` with the sibling ../sm-journal-plugin HEAD that carries the D-20/D-21 commits recorded in 16-01; when the host gitlink is older, fetch into the nested clone with git -c protocol.file.allow=always fetch file://<absolute ../sm-journal-plugin> and check out that commit, then commit the gitlink alone in the host (chore: point journal gitlink at author endpoint and category content) — Phase 15 nested-clone precedent.
|
||||
|
||||
config/backend.yaml: change the mount to uri: /gfadmin and update its comment (D-09: the site keeps the admin on its own domain; the old /backend path is intentionally gone). Commit (feat: mount the admin at /gfadmin).
|
||||
|
||||
Plugin: install the summer CLI from the framework checkout into a temp GOBIN (GOBIN=<tmp> go -C ../summercms.go install ./cmd/summer), then from the host root run summer make:plugin golem15.grzyby (creates plugins/grzyby) and summer plugin:add plugins/grzyby (summer.yaml, go.work, host go.mod), then summer build (regenerates main.go and plugins.gen.go; never edit them by hand). plugin.go: package grzyby, ID golem15.grzyby, Requires() returns golem15.journal, Register stores *backpack.App, Boot returns nil, var _ pact.HasCommands = (*Plugin)(nil), Commands() returns ImportFeaturedImagesCommand(p.app) (Task 2 adds the category command), init calls party.Register(&Plugin{}). No routes, no admin controllers, no migrations: the plugin adds no HTTP endpoint (Phase 15 D-14 contract unchanged).
|
||||
|
||||
console/helpers.go: flagOrDefault, flagOn, lookupDB (app.Lookup[*gorm.DB]), lookupBucket (app.Lookup[*blob.Bucket], else attach.OpenBucket(ctx, app.Config)), validDiskName (^[A-Za-z0-9]+\.[A-Za-z0-9]+$, so no separators or dot-dot).
|
||||
|
||||
console/import_featured_images.go (D-22, shaped like journal ImportPosts): FeaturedImageEntry { Slug slug, DiskName disk_name, FileName file_name, ContentType content_type, FileSize file_size, SortOrder sort_order } with those JSON keys; ReadFeaturedImageMap(path) reads JSON Lines (one object per line, blank lines skipped, errors name the line number); FeaturedImageOptions { MapPath, SourceDir, ExportDir, DryRun }; FeaturedImageStats { Created, Skipped, Failed }; ImportFeaturedImages(ctx, db *gorm.DB, bucket *blob.Bucket, opts, out bonfire.Output) (FeaturedImageStats, error). Per entry: reject a disk name failing validDiskName or a content type outside image/jpeg, image/png, image/gif, image/webp (Failed, continue); resolve the source file as SourceDir joined with attach.PartitionDirectory(disk_name) and disk_name and refuse it unless filepath.Rel from SourceDir stays inside; find the post by host slug = entry.slug; when no post matches and ExportDir is set, read <ExportDir>/<slug>.json and retry with translations.pl.slug (base-to-live slug, RESEARCH: live URLs use the pl slug); a miss is Failed with "post not found: <slug>". Skipped when a system_files row already exists with attachment_type = models.Post{}.MorphName(), attachment_id = the post id as a decimal string, field featured_images and the same disk_name, and the blob attach.BlobKey(disk_name) exists. Otherwise (unless DryRun) write the blob under attach.BlobKey(disk_name) with the entry content type (bucket.WriteAll), then insert attach.File { DiskName, FileName, FileSize, ContentType, Field featured_images, AttachmentID, AttachmentType, SortOrder, IsPublic nil } — Created. Fatal DB errors return; per-entry problems print out.Error and count Failed. ImportFeaturedImagesCommand(app) is bonfire command grzyby:import-featured-images with flags map, source, export and the bare flag dry-run; map and source are required; it prints "Results: N created, N skipped, N failed".
|
||||
|
||||
testdata/featured: a valid 1×1 PNG at uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and map.jsonl with one line for slug badania-nad-cordycepsem (the base slug, so the export fallback is exercised) with that disk_name, file_name cordyceps.png, content_type image/png, the real byte size, sort_order 0.
|
||||
|
||||
boot_test.go: four plugins now; the migrations loop checks only golem15.user, golem15.translate and golem15.journal (the app plugin has none); assert some assembled route Pattern starts with /gfadmin/ and none starts with /backend; assert the golem15.grzyby plugin implements pact.HasCommands and lists grzyby:import-featured-images; keep every existing assertion.
|
||||
|
||||
scripts/migrate.sh (D-25 ordered runbook; summercms.io script conventions: usage header, set -euo pipefail, die to stderr, tool loop): flags --bin, --workdir (holds config/ and .env), --export, --featured-map with --uploads, --dry-run. Steps in order: (1) bin migrate in the workdir; (2) read SUMMER_DATABASE__DSN from <workdir>/.env and refuse with "migrate: database already holds journal posts; import must run once into a fresh database (Pitfall 3)" when SELECT count(*) FROM golem15_journal_posts is not 0; (3) psql UPDATE golem15_translate_locales SET is_default = (code = 'pl') and die unless SELECT code FROM golem15_translate_locales WHERE is_default AND is_enabled returns exactly pl (Pitfall 1); (4) journal:import-posts --path EXPORT --dry-run, then without --dry-run; (5) when --featured-map is given, grzyby:import-featured-images --map … --source <uploads> --export EXPORT; print "migrate: ok". With --dry-run only steps that write nothing run.
|
||||
|
||||
scripts/smoke.sh (this task builds the --go-only path; Task 3 adds Nuxt and urls.txt): require bin/grzybyfunkcjonalne (build it in this task with go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne .); database: when SMOKE_ADMIN_DSN is set, create grzyby_smoke_<random hex> with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL' and drop only that database on exit; else docker postgres:15 like summercms.io; it never touches any other database name. Work dir with config/ copied and a .env of random secrets plus SUMMER_URL=http://127.0.0.1:<go port>; run migrate.sh with --export "${SMOKE_EXPORT_DIR:-/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export}" and the testdata featured map; serve Go on SMOKE_GO_PORT (default 18096, refuse a busy port); check: GET /_journal/api/v1/posts?per_page=1 meta.total equals the number of published export files, GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and that file exists under the work dir storage, GET /_journal/api/v1/posts/badania-nad-cordycepsem answers 404, GET /gfadmin answers a non-404 status, GET /backend answers 404; a second grzyby:import-featured-images run prints 0 created; print "smoke: ok (go)".
|
||||
|
||||
Commits: plugin and boot test (feat: app plugin golem15.grzyby with featured-image import), scripts (feat: migration runbook and Go rehearsal). go vet and go test stay green at each.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "smoke:" followed by an error, or "migrate:" followed by an error; the lines "--- PASS: TestBootUserTranslateJournal" or "smoke: ok (go)" are absent.</fails_when>
|
||||
<automated>! grep -q 'uri: /backend' ../sm-grzybyfunkcjonalne-app/config/backend.yaml</automated>
|
||||
<fails_when>Exit status 1: config/backend.yaml still mounts the admin at the old path.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- config/backend.yaml contains `uri: /gfadmin`; the boot test asserts a /gfadmin/ route and no /backend route.
|
||||
- summer.yaml lists golem15.grzyby and plugins.gen.go imports git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby, both produced by summer, not hand-edited.
|
||||
- On the rehearsal database the post blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and the file exists at that path under the work dir; the base slug answers 404.
|
||||
- The second featured-image run reports 0 created (idempotent, D-22).
|
||||
- pl is the only default locale before the import ran (migrate.sh dies otherwise).
|
||||
- The host journal gitlink points at a commit containing the D-20 and D-21 additions.
|
||||
</acceptance_criteria>
|
||||
<done>An imported post with its Winter-named featured image is served by the Go host from a scripted, ordered migration, with the admin at /gfadmin.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Category content import, the rest of the runbook (categories, media, author) and the PHP export queries</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, .planning/todos/pending/journal-imported-html-admin-resave.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-17, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer: categories, authors, inline media refs; Pitfall 2; Open Questions 1 and 3; Content Inventory: category pillar content), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract: D-21 field name, D-20 author slug storage), ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql (category rows, for the query shape only), .planning/todos/pending/menu-plugin-port.md (todo file format)</read_first>
|
||||
<action>console/import_categories.go (D-21): CategoryEntry { Slug slug, Name name, Description description (nullable), Content content (nullable), Translations translations map[locale]map[field]string } with those JSON keys; ReadCategoryMap(path) reads JSON Lines like the featured map; CategoryStats { Updated, Unchanged, Failed }; ImportCategories(ctx, db, entries, dryRun, out). For each entry take the pl translation value of slug, name, description and content when present and non-empty, else the base value (pl is the default locale after D-25, so host columns hold pl). Find the category by host slug (pl slug, then base slug); a miss is Failed with "category not found: <slug>" (journal:import-posts already created every category a post references, drafts included, so all seven exist). Update name, description and the D-21 content field (the Go field and column named in the 16-01 Prerequisite contract) in one UPDATE; identical values count Unchanged. ImportCategoriesCommand(app) is grzyby:import-categories with flag map (required) and bare dry-run, printing "Results: N updated, N unchanged, N failed". Register it in Plugin.Commands() and extend the boot test to expect both command names. testdata/categories/map.jsonl: one line for cordyceps with a pl name, a description and content containing the marker text Cordyceps w pigułce.
|
||||
|
||||
migrate.sh gains, in this order after the post import: --categories-map runs grzyby:import-categories; --media <php storage/app/media dir> rsyncs it to <workdir>/storage/app/media/ (images and files as they are; Artur Wolak.png and las.webp are referenced by 32 post bodies); --featured-map (Task 1); --author-login with --author-slug (D-15 /autor/<slug>, D-20): psql sets the author slug column recorded in the 16-01 contract (assumed backend_users.golem15_bloghub_author_slug) for that login and sets user_id on every golem15_journal_posts row whose user_id is NULL, dying when the login does not exist. Finally it prints a gap report (D-17: flag, do not patch): the count of post bodies still referencing https://gf.uat.golem15.com/storage/ (2 known broken references), and the count of published posts without a featured image.
|
||||
|
||||
smoke.sh --go-only additionally runs --categories-map with the testdata map and checks that GET /_journal/api/v1/categories?include_empty=1 returns cordyceps with the content marker and that amanita-muscaria is present with post_count 0 (D-24).
|
||||
|
||||
scripts/php-export (run on the PHP server, D-22 "SQL query on the PHP database"): featured-images.sql selects one JSON_OBJECT per row (slug, disk_name, file_name, content_type, file_size, sort_order) from system_files where field = 'featured_images' joined to the posts table; the slug is the pl slug from the translate index table when one exists, else the base slug; comments at the top name the two values to check on the server before running (RESEARCH Open Question 3): the post model morph (Golem15\\Journal\\Models\\Post or Golem15\\Blog\\Models\\Post with its table) and the translate tables (winter_translate_* or rainlab_translate_*). categories.sql selects one JSON_OBJECT per category (slug, name, description, content, translations: { pl: the pl attribute_data JSON }). README.md: run each with mysql -N -B --raw <database> < file > out.jsonl (JSON_OBJECT needs MySQL 5.7.8+ or MariaDB 10.2.3+; one object per line, which is exactly the commands' JSONL input), how to copy the outputs and the uploads/media directories to the staging work dir, and that every map is read-only input (no secrets).
|
||||
|
||||
Todo .planning/todos/pending/journal-imported-html-admin-resave.md (summercms.go planning repo, separate docs commit): RESEARCH Pitfall 2 — imported pl content is HTML and an admin re-save of an imported post runs FormatHTML and replaces the body with a raw-HTML-omitted comment; until sm-journal-plugin converts or passes through HTML content, do not edit imported posts in /gfadmin. The host README (Task 3) repeats the warning.
|
||||
|
||||
Commits: plugin (feat: grzyby:import-categories for category content), scripts (feat: runbook categories, media and author steps; PHP export queries), and the todo in summercms.go (docs: todo for Journal HTML content re-save).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL" or "smoke:" followed by an error; "smoke: ok (go)" or "--- PASS: TestBootUserTranslateJournal" is absent.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The rehearsal's categories response carries the Cordyceps w pigułce marker in the D-21 field for cordyceps and lists amanita-muscaria with post_count 0.
|
||||
- Re-running grzyby:import-categories with the same map reports 0 updated and 1 unchanged.
|
||||
- migrate.sh runs migrate, the pl default switch and its assertion, the post import, categories, media, featured images and author assignment in that order; `grep -n 'journal:import-posts' scripts/migrate.sh` appears after the line containing `code = 'pl'`.
|
||||
- featured-images.sql and categories.sql each emit one JSON_OBJECT per row, and the README states the server checks for the model morph and the translate table names.
|
||||
- The boot test expects both grzyby:import-featured-images and grzyby:import-categories.
|
||||
- The todo file exists under .planning/todos/pending/ and names Pitfall 2.
|
||||
</acceptance_criteria>
|
||||
<done>Category content, media, featured images and the /autor author are all covered by one ordered runbook with PHP-side export queries ready for the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Two-process deploy files, build script, urls.txt and the full local rehearsal over every old URL</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/.gitignore</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-summercmsio-app/scripts/build.sh, ../sm-summercmsio-app/scripts/check-deploy.sh, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf, ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf, ../sm-summercmsio-app/deploy/env.example, ../sm-summercmsio-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Code Examples nginx and supervisor, Pitfalls 5, 6, 7, 12, Security Domain), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-07, D-09, D-10, D-15, D-18, D-23)</read_first>
|
||||
<action>scripts/build.sh (summercms.io build.sh conventions; one mode, against the framework working tree because the host requires summercms v0.0.0 through a local replace): tool loop go, pnpm, node, git, rsync; SUMMERCMS_FRAMEWORK (default ../summercms.go); die when vue-grzybyfunkcjonalne-app/package.json or any plugin go.mod is missing (run git submodule update --init); install the summer CLI to a temp GOBIN and run summer build from the host root, then die when git status shows main.go or plugins.gen.go changed ("commit them first"); go vet ./...; CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o bin/grzybyfunkcjonalne .; pnpm -C vue-grzybyfunkcjonalne-app install --frozen-lockfile and build; rsync -a --delete vue-grzybyfunkcjonalne-app/.output/ build/nuxt/.output/; print "build: bin/grzybyfunkcjonalne and build/nuxt/.output ready". .gitignore adds /build/.
|
||||
|
||||
deploy/nginx/grzybyfunkcjonalne-staging.conf (comment header: staging vhost on the PHP site's server next to the live Winter vhost, which stays untouched, D-10; server name and paths are confirmed at 16-04): HTTP server with the ACME webroot location and a 301 to HTTPS; HTTPS server for staging.grzybyfunkcjonalne.pl with the four certbot lines, gzip as summercms.io. Locations: location ^~ /backend returns 404 (D-09); location ^~ /gfadmin and location ^~ /_journal/ proxy to http://127.0.0.1:8096 with proxy_http_version 1.1, Host $host, X-Real-IP, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, client_max_body_size 64m (config/http.yaml upload_bytes); a regex location ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ with root /srv/grzybyfunkcjonalne/go, add_header X-Content-Type-Options nosniff always, expires 30d (Pitfall 7, RESEARCH static.go warning); a plain prefix location /storage/ returning 404 — it must not use the ^~ modifier, because ^~ would stop nginx before the regex image location is tried; location / with limit_except GET HEAD deny all, proxy to http://127.0.0.1:3016 with the same headers (Pitfall 5: X-Forwarded-For reaches Nuxt, which forwards it to Go).
|
||||
|
||||
deploy/supervisor/grzybyfunkcjonalne-go.conf: [program:grzybyfunkcjonalne-go] command /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096, directory /srv/grzybyfunkcjonalne/go (config/ and .env are read from it), user grzybyfunkcjonalne, environment SUMMER_ENV="production", autostart, autorestart, startsecs 3, stopsignal TERM, stopwaitsecs 15, log settings as summercms.io. deploy/supervisor/grzybyfunkcjonalne-nuxt.conf: [program:grzybyfunkcjonalne-nuxt] command /usr/bin/node --env-file=/srv/grzybyfunkcjonalne/nuxt/.env /srv/grzybyfunkcjonalne/nuxt/.output/server/index.mjs, directory /srv/grzybyfunkcjonalne/nuxt, same user and restart/log settings, environment NODE_ENV="production". deploy/env.example: SUMMER_DATABASE__DSN as postgres://grzybyfunkcjonalne:<password>@127.0.0.1:5432/grzybyfunkcjonalne?sslmode=disable, the three secrets SUMMER_APP__KEY, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET with empty values and their generation hints (summercms.io wording), plus SUMMER_URL=https://staging.grzybyfunkcjonalne.pl (Pitfall 6: RSS links and Go origin must be https behind nginx); header naming the server path, owner and mode 0640. deploy/nuxt.env.example: NITRO_HOST=127.0.0.1, NITRO_PORT=3016, NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1, NUXT_SITE_URL=https://staging.grzybyfunkcjonalne.pl, NUXT_SITE_INDEXABLE=false (staging is never indexed; production removes the line), NUXT_PUBLIC_GA_ID= (empty on staging; production sets G-MLSV7WJ6W4, D-23).
|
||||
|
||||
scripts/check-deploy.sh (adapt summercms.io): nginx -t in a temp prefix with the certbot paths swapped for a throwaway certificate, listen ports moved to loopback 18480/18443 and the root path pointed at a temp dir; then python3 configparser checks both supervisor programs for command, directory, user, autostart, autorestart, stopsignal, that the Go command contains serve --addr 127.0.0.1:8096 and the Nuxt command contains --env-file= and .output/server/index.mjs; then greps the nginx file for X-Content-Type-Options nosniff, location ^~ /backend, location ^~ /gfadmin, location ^~ /_journal/ and limit_except GET HEAD, and dies if it finds location ^~ /storage/; prints "check-deploy: ok".
|
||||
|
||||
scripts/urls.txt (grammar [remote] STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; lines marked remote are checked only with --url because no nginx runs locally): fetch https://grzybyfunkcjonalne.pl/sitemap.xml now and write 200 PATH for every loc (71 at research time: /, /blog, seven categories, 62 posts; RESEARCH says re-pull before freezing). Then add: 200 /blog?page=2 contains=?page=2 (canonical keeps the query, A7); 200 /blog?page=6; 404 /blog?page=7; 404 /blog?page=abc; 200 /cordyceps?page=2; 200 /reishi?page=2; 404 /chaga?page=2 (pages exist only where a category has more than 10 published posts in the export: cordyceps 14, reishi 13); 200 /amanita-muscaria (D-24); 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl; 200 /trwaja-prace contains=Niebawem; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml type=application/xml; 200 /robots.txt contains=Disallow:; 404 /404; 404 /error; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /blog/badania-nad-cordycepsem (base slug); 404 /en/blog (D-16); 200 /autor/admin; 404 /autor/nobody; 404 /backend; 200 /_journal/api/v1/posts type=application/json; remote 200 /storage/app/media/las.webp type=image/; remote 404 /storage/app/uploads/public/x.php; remote 404 /storage/config.yaml.
|
||||
|
||||
scripts/smoke.sh full mode (default, no flag): requires the build.sh outputs; does the --go-only setup with --author-login and --author-slug admin (before invoking migrate.sh the script runs bin migrate, which is idempotent, and then creates that admin with the cabana command admin:create --email admin@example.test --login admin --superuser, piping a random password from /dev/urandom on stdin, never printed or passed as a flag; migrate.sh's own migrate step is then a no-op and its fresh-database check still passes because no post exists yet; migrate.sh gains no admin creation of its own and the README runbook lists the same order: migrate, admin:create, migrate.sh), then starts Nuxt from build/nuxt/.output/server/index.mjs on SMOKE_NUXT_PORT (default 13016) with NUXT_JOURNAL_API_BASE pointing at the rehearsal Go server and NUXT_SITE_INDEXABLE=false; checks every non-remote urls.txt line, sending paths that start with /gfadmin, /_journal or /backend to Go and everything else to Nuxt, with X-Forwarded-For: 203.0.113.7; prints "smoke: ok (N urls)". --url BASE checks every line (remote included) against BASE only and starts nothing. Any mismatch prints "smoke: FAIL <line>: got <status/type>" and exits 1.
|
||||
|
||||
README.md (host; it may name the site): what the app is (headless Go host for the Nuxt SSR site, two processes), layout table (summercms.io README style: vue-grzybyfunkcjonalne-app, plugins/golem15/{user,translate,journal}, plugins/grzyby, config, scripts, deploy), build and checks commands, the migration runbook (D-25 order, fresh database only, Pitfall 3 recovery: drop, migrate, pl default, import again), the PHP export steps (scripts/php-export/README.md), the admin at /gfadmin, the Pitfall 2 warning, ports and env files, and a pointer to DEPLOY.md (written in 16-04).
|
||||
|
||||
Commits: deploy and scripts (feat: staging deploy files and full URL rehearsal), README (docs: host README).</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh</automated>
|
||||
<fails_when>Non-zero exit; output contains "check-deploy:" followed by an error, "build:" followed by an error, or "smoke: FAIL"; the lines "check-deploy: ok" or "smoke: ok (" are absent.</fails_when>
|
||||
<automated>! grep -nE '^(SUMMER_APP__KEY|SUMMER_ADMIN__JWT__SECRET|SUMMER_GOLEM15__USER__JWT__SECRET)=.' ../sm-grzybyfunkcjonalne-app/deploy/env.example && grep -qE '^SUMMER_DATABASE__DSN=.*<password>' ../sm-grzybyfunkcjonalne-app/deploy/env.example</automated>
|
||||
<fails_when>Non-zero exit: a secret line in the template has a value, or the DSN line lacks the <password> marker (a real credential was written).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- check-deploy.sh prints "check-deploy: ok" (nginx -t passes; both supervisor programs parse with the required keys).
|
||||
- The local rehearsal passes every non-remote urls.txt line, including all live sitemap URLs, /blog?page=2 keeping ?page=2 in the canonical, /amanita-muscaria 200, /autor/admin 200, /backend 404, /en/blog 404 and /rss.xml with an RSS content type.
|
||||
- urls.txt contains one 200 line for every loc in the live sitemap fetched during this task (the count is recorded in the SUMMARY).
|
||||
- The nginx file has no `location ^~ /storage/` and has the image regex location with X-Content-Type-Options nosniff.
|
||||
- deploy/nuxt.env.example sets NUXT_SITE_INDEXABLE=false and an empty NUXT_PUBLIC_GA_ID.
|
||||
- README.md describes the runbook order and the Pitfall 2 warning.
|
||||
</acceptance_criteria>
|
||||
<done>The host builds both processes, its deploy files pass nginx -t and supervisor parsing, and a local rehearsal answers every old URL with the expected status.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Internet → nginx → Go admin | /gfadmin and the Journal write routes are reachable on the public domain |
|
||||
| Internet → nginx → disk | Uploaded and media files are served from the Go host's storage directory |
|
||||
| PHP database export → Go commands | JSONL maps produced on the PHP server drive file copies and row writes |
|
||||
| Operator → server secrets | .env files hold the database DSN and JWT secrets |
|
||||
| Rehearsal scripts → Postgres | smoke.sh creates and drops databases with an admin DSN |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-10 | Elevation of Privilege | /gfadmin admin exposure | high | mitigate | cabana backend auth unchanged; backend.cookie_secure stays true (refused false in production); /backend has no Go route (boot test) and nginx returns 404 for it |
|
||||
| T-16-11 | Tampering | same-origin XSS through files under /storage | high | mitigate | nginx serves only jpe?g/png/gif/webp under /storage/app/{uploads/public,media} with X-Content-Type-Options nosniff; every other /storage/ path is 404 (plain prefix, not ^~); no Go static handler is mounted; check-deploy.sh asserts all three |
|
||||
| T-16-12 | Tampering | grzyby:import-featured-images file paths | medium | mitigate | validDiskName rejects separators and dot-dot; the source path must stay inside --source (filepath.Rel); content type allowlist image/jpeg, png, gif, webp; blobs are written only under attach.BlobKey |
|
||||
| T-16-13 | Information Disclosure | deploy/env.example, nuxt.env.example | medium | mitigate | Templates carry empty values only (negative grep); .env stays on the server, .gitignore keeps it out; smoke.sh generates throwaway secrets |
|
||||
| T-16-14 | Tampering | journal:import-posts re-runs after the pl switch | medium | mitigate | migrate.sh refuses a database that already holds posts and asserts pl is the only default before importing (Pitfalls 1 and 3) |
|
||||
| T-16-15 | Spoofing | X-Forwarded-For through nginx and Nuxt | medium | mitigate | nginx appends $proxy_add_x_forwarded_for; Go trusts only 127.0.0.1/32 and reads the rightmost untrusted hop, so a client-supplied XFF cannot pick another bucket |
|
||||
| T-16-16 | Denial of Service | Nuxt location | low | mitigate | limit_except GET HEAD on location /; Go keeps its 120/min journal buckets; Nuxt swr caching (16-02) |
|
||||
| T-16-17 | Tampering | smoke.sh with SMOKE_ADMIN_DSN | medium | mitigate | The script creates grzyby_smoke_<random> and drops only that name on exit; it never runs migrate or DROP against any other database (T-02-05 precedent) |
|
||||
| T-16-SC | Tampering | Go module additions | high | mitigate | The plugin uses only modules already in the host graph and named in STACK.md (summercms framework, sm-journal-plugin, gorm, gocloud.dev); no new Go or npm package |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` prints "check-deploy: ok".
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh` prints "smoke: ok (N urls)".
|
||||
- `go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1` stays green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin at /gfadmin, /backend gone (D-09).
|
||||
- Featured images and category content import idempotently from PHP-side maps (D-21, D-22).
|
||||
- One ordered runbook: migrate, pl default, single import, categories, media, images, author (D-17, D-25).
|
||||
- Staging deploy files for two processes pass their checks (D-07, D-10), and a local rehearsal answers every live URL (D-15).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md` when done, recording the live sitemap URL count frozen into urls.txt.
|
||||
</output>
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["16-03"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-3]
|
||||
user_setup:
|
||||
- service: PHP site server (staging host)
|
||||
why: "D-10/D-18: staging runs on the same server as the live Winter site; the executor cannot create DNS records, certificates, database roles or its own SSH access"
|
||||
env_vars:
|
||||
- name: STAGING_SSH
|
||||
source: "An ssh host alias (in ~/.ssh/config) for the PHP server with a user that may write /srv/grzybyfunkcjonalne and run sudo nginx -t, systemctl reload nginx and supervisorctl"
|
||||
- name: STAGING_URL
|
||||
source: "The staging base URL, e.g. https://staging.grzybyfunkcjonalne.pl (DNS record and certificate created by the human)"
|
||||
dashboard_config:
|
||||
- task: "DNS A/AAAA record for the staging host pointing at the PHP server"
|
||||
location: "DNS provider of grzybyfunkcjonalne.pl"
|
||||
- task: "Run scripts/php-export/featured-images.sql and categories.sql on the PHP database and place the two JSONL outputs on the server"
|
||||
location: "PHP server, mysql client against the live Winter database (read-only queries)"
|
||||
estimate:
|
||||
tokens: 80000
|
||||
raw_tokens: 80000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-18 / SC-1: grzybyfunkcjonalne content runs end to end on staging on the PHP site's server: the Go binary (supervisor program grzybyfunkcjonalne-go) and the Nuxt server (grzybyfunkcjonalne-nuxt) behind one staging nginx vhost, with the imported posts."
|
||||
- "D-15 / D-18: scripts/smoke.sh --url $STAGING_URL passes every line of scripts/urls.txt, which holds every URL of the live sitemap plus the static and negative cases."
|
||||
- "D-10: the live Winter vhost, php-fpm and the PHP database are not modified; only new files (one nginx vhost, two supervisor programs, /srv/grzybyfunkcjonalne) are added, and the Winter install stays the rollback."
|
||||
- "D-25: on the staging database pl was made the only default locale before journal:import-posts, and the import ran once into a fresh database."
|
||||
- "D-21/D-22/D-17: category pillar content, featured images (old /storage/app/uploads/public URLs) and storage/app/media files are present on staging, filled from the PHP database queries and storage copies."
|
||||
- "D-09: the admin answers at $STAGING_URL/gfadmin and /backend answers 404."
|
||||
- "D-23: staging renders no Google Analytics script (NUXT_PUBLIC_GA_ID empty)."
|
||||
- "Staging is not indexable (NUXT_SITE_INDEXABLE=false), so it never competes with the live site in search."
|
||||
- "The production nginx/DNS switch is not performed by this plan (deferred operational step)."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/DEPLOY.md"
|
||||
provides: "server facts, upload/release steps, migration runbook, smoke, rollback, and the deferred cutover notes"
|
||||
contains: "Rollback"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "the vhost as installed on the server"
|
||||
contains: "server_name"
|
||||
key_links:
|
||||
- from: "nginx staging vhost on the PHP server"
|
||||
to: "grzybyfunkcjonalne-go (127.0.0.1:8096) and grzybyfunkcjonalne-nuxt (127.0.0.1:3016)"
|
||||
via: "proxy_pass by path: /gfadmin and /_journal/ to Go, image files under /storage/app to disk, everything else to Nuxt"
|
||||
pattern: "proxy_pass"
|
||||
- from: "grzybyfunkcjonalne-nuxt"
|
||||
to: "grzybyfunkcjonalne-go"
|
||||
via: "NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1 in /srv/grzybyfunkcjonalne/nuxt/.env"
|
||||
pattern: "NUXT_JOURNAL_API_BASE"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: one imported post loads over HTTPS from the staging host on the PHP server, served by Nuxt from the Go Journal API; then every old URL is checked on staging and the deploy is documented.
|
||||
|
||||
<objective>
|
||||
Deploy the site to staging on the PHP site's server (D-10, D-18): resolve the server prerequisites at a human checkpoint, install the two processes and the vhost, run the migration runbook against the real PHP data, and check every old URL on staging.
|
||||
|
||||
Purpose: SC-1 counts as met once the site is served by SummerCMS on staging (D-18); SC-3 is proven against the live URL list.
|
||||
Output: a running staging site, DEPLOY.md, deploy files matching what is installed, and a passing remote smoke.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/README.md
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
@../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
@../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
@../sm-summercmsio-app/DEPLOY.md
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Staging site at `$STAGING_URL` on the PHP server: directory `/srv/grzybyfunkcjonalne/{go,nuxt,import}` (exact paths recorded in DEPLOY.md), supervisor programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, nginx vhost `grzybyfunkcjonalne-staging.conf`, Postgres database `grzybyfunkcjonalne` (ICU pl-PL).
|
||||
- `DEPLOY.md` in the host repo (sections: Server facts, Prerequisites, Build and upload, First install, Migration runbook, Smoke, Release, Rollback, Production cutover (not done in this phase)).
|
||||
- Deploy files and `scripts/check-deploy.sh` updated to the confirmed server facts; `scripts/urls.txt` re-pulled from the live sitemap when it changed.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Server prerequisites on the PHP site's server (staging host, TLS, Postgres, Node, access, PHP export maps)</name>
|
||||
<read_first>.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 12, Open Questions 3 and 4, Environment Availability), ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/config/database.yaml, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-summercmsio-app/DEPLOY.md</read_first>
|
||||
<action>Print a checklist built from the files above and stop for the human. The human: (1) picks the staging host name (default staging.grzybyfunkcjonalne.pl), creates its DNS record pointing at the PHP server and a certbot certificate for it; (2) makes sure PostgreSQL 15 or newer with ICU runs on the server and creates role grzybyfunkcjonalne and database grzybyfunkcjonalne with the ICU command in config/database.yaml (TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'), keeping the password for the DSN; (3) makes sure Node 22.18 or newer is installed and notes its path; (4) creates system user grzybyfunkcjonalne and /srv/grzybyfunkcjonalne writable by the deploy user, confirms ports 8096 and 3016 are free on 127.0.0.1 (or names free ones); (5) gives the executor SSH access as an ~/.ssh/config alias exported as STAGING_SSH, with sudo for nginx -t, nginx reload and supervisorctl, and exports STAGING_URL; (6) runs scripts/php-export/featured-images.sql and categories.sql against the live PHP database after checking the post model morph and translate table names as the README says, and places featured-images.jsonl and categories.jsonl in /srv/grzybyfunkcjonalne/import/; (7) states the absolute paths of the PHP storage/app/uploads/public and storage/app/media directories and that the deploy user can read them; (8) states the admin email and login to create. The executor then runs the verify command and records every confirmed fact (host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables used, Postgres version) in the SUMMARY "Server facts" section.</action>
|
||||
<instructions>
|
||||
Claude prepared the deploy files, the runbook and the PHP export queries in 16-03 and printed the checklist.
|
||||
The human does the steps that need DNS, certificates, database roles, server accounts or SSH keys (items 1-8 of the action), then exports STAGING_SSH and STAGING_URL in the session.
|
||||
</instructions>
|
||||
<verification>The verify command prints prerequisites-ok.</verification>
|
||||
<resume-signal>Type "server ready" with the staging URL and ssh alias once items 1-8 are done.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -n "$STAGING_SSH" && test -n "$STAGING_URL" && NODE_V=$(ssh "$STAGING_SSH" 'node -p process.versions.node') && printf '22.18.0\n%s\n' "$NODE_V" | sort -V -C && ssh "$STAGING_SSH" 'psql --version && sudo nginx -t && sudo supervisorctl version && test -s /srv/grzybyfunkcjonalne/import/featured-images.jsonl && test -s /srv/grzybyfunkcjonalne/import/categories.jsonl' && { curl -sS -o /dev/null --max-time 10 "$STAGING_URL/"; test $? -ne 6; } && echo prerequisites-ok</automated>
|
||||
<fails_when>Non-zero exit or "prerequisites-ok" absent: STAGING_SSH or STAGING_URL unset, ssh fails, Node is older than 22.18, psql/nginx/supervisorctl missing, nginx -t fails, an import map is missing or empty, or curl exit 6 (staging host name does not resolve).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- STAGING_SSH and STAGING_URL are set and ssh works with the needed sudo commands.
|
||||
- Node on the server is 22.18 or newer; psql reports PostgreSQL 15 or newer; nginx -t passes before any change.
|
||||
- /srv/grzybyfunkcjonalne/import/featured-images.jsonl and categories.jsonl exist and are non-empty.
|
||||
- The staging host name resolves.
|
||||
- The SUMMARY "Server facts" section lists host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables, Postgres version.
|
||||
</acceptance_criteria>
|
||||
<done>Every server fact the deploy needs is confirmed and recorded, and the PHP export maps are on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer — deploy both processes, run the migration runbook on the PHP data and serve one imported post over HTTPS on staging</name>
|
||||
<reversibility rating="costly">D-07/D-10: the two-process topology (supervisor programs, vhost, ports) is what the production cutover reuses; changing it means redoing the server layout.</reversibility>
|
||||
<precondition>STAGING_SSH and STAGING_URL are set and Task 1's verify printed prerequisites-ok in this session.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh</files>
|
||||
<read_first>The Task 1 "Server facts" in this plan's SUMMARY, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-summercmsio-app/DEPLOY.md (upload, release and cutover order)</read_first>
|
||||
<action>Align the deploy files with the confirmed server facts (server_name, certificate paths, /srv paths, user, Node path, ports) and re-run scripts/check-deploy.sh; commit the changes in the host (chore: match staging deploy files to the server). Run scripts/build.sh locally.
|
||||
|
||||
Upload with rsync over $STAGING_SSH: bin/grzybyfunkcjonalne, config/ and scripts/ to /srv/grzybyfunkcjonalne/go/; build/nuxt/.output/ to /srv/grzybyfunkcjonalne/nuxt/.output/; the journal export (/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export) to /srv/grzybyfunkcjonalne/import/journal-export/. Never rsync a local .env.
|
||||
|
||||
On the server, write /srv/grzybyfunkcjonalne/go/.env from deploy/env.example: the DSN from Task 1, and SUMMER_APP__KEY from bin/grzybyfunkcjonalne key:generate, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET from head -c 32 /dev/urandom | base64, all generated on the server and never printed or copied back; SUMMER_URL=$STAGING_URL; owner root, group grzybyfunkcjonalne, mode 0640. Write /srv/grzybyfunkcjonalne/nuxt/.env from deploy/nuxt.env.example with NUXT_SITE_URL=$STAGING_URL, NUXT_SITE_INDEXABLE=false and NUXT_PUBLIC_GA_ID empty (D-23).
|
||||
|
||||
Run in /srv/grzybyfunkcjonalne/go as the service user: bin/grzybyfunkcjonalne migrate; bin/grzybyfunkcjonalne admin:create --email <Task 1 email> --login <Task 1 login> --superuser with a random password piped on stdin from a 0600 file /srv/grzybyfunkcjonalne/admin-initial-password (never on the command line; the human changes it with admin:reset-password); then scripts/migrate.sh --bin bin/grzybyfunkcjonalne --workdir . --export ../import/journal-export --categories-map ../import/categories.jsonl --media <PHP storage/app/media> --featured-map ../import/featured-images.jsonl --uploads <PHP storage/app/uploads/public> --author-login <Task 1 login> --author-slug admin (D-25 order; it refuses a database that already holds posts, so a failed run means drop, re-create, migrate and run again, Pitfall 3). Keep its full output in the SUMMARY, including the gap report.
|
||||
|
||||
Install deploy/supervisor/*.conf into the server's supervisor conf.d and deploy/nginx/grzybyfunkcjonalne-staging.conf into the nginx sites directory as new files; do not edit the live Winter vhost or php-fpm (D-10). sudo nginx -t must pass before sudo systemctl reload nginx; then supervisorctl reread, update and start both programs.</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && ssh "$STAGING_SSH" 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && curl -fsS "$STAGING_URL/blog/blogbadania-nad-cordycepsem" | grep -q 'Najnowsze Badania nad Cordycepsem' && curl -fsS "$STAGING_URL/_journal/api/v1/posts/blogbadania-nad-cordycepsem" | grep -q '"featured_image_url"' && echo staging-tracer-ok</automated>
|
||||
<fails_when>Non-zero exit or "staging-tracer-ok" absent: check-deploy fails, fewer than two supervisor programs are RUNNING, the post page does not answer 200 with its title over HTTPS, or the API answer lacks featured_image_url.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both supervisor programs are RUNNING on the server and nginx -t passed before the reload.
|
||||
- $STAGING_URL/blog/blogbadania-nad-cordycepsem answers 200 over HTTPS with the post title, rendered by Nuxt from the Go Journal API.
|
||||
- migrate.sh printed "migrate: ok"; its gap report is recorded in the SUMMARY.
|
||||
- The live Winter vhost file and php-fpm configuration are unchanged (the SUMMARY lists only new files installed).
|
||||
- No secret value appears in the repo, the SUMMARY or the shell history (generated on the server).
|
||||
</acceptance_criteria>
|
||||
<done>Staging serves an imported Journal post over HTTPS from the Go binary and the Nuxt server running side by side with the live PHP site.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Smoke every old URL on staging, verify images and media, write DEPLOY.md</name>
|
||||
<precondition>Task 2's verify printed staging-tracer-ok in this session.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-summercmsio-app/DEPLOY.md, the Task 1 "Server facts" and Task 2 runbook output in this plan's SUMMARY, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-10, D-18, deferred production cutover)</read_first>
|
||||
<action>Re-pull https://grzybyfunkcjonalne.pl/sitemap.xml; when its loc set differs from the 200 lines frozen in urls.txt by 16-03, update urls.txt and record the difference (RESEARCH: live facts drift). Run scripts/smoke.sh --url "$STAGING_URL" (every line, remote lines included). Additionally check, from the staging API, one featured_image_url and one /storage/app/media/ URL referenced by a post body: each answers 200 with an image content type and X-Content-Type-Options nosniff; /gfadmin answers a non-404 status; the homepage has no googletagmanager script; /robots.txt disallows everything (NUXT_SITE_INDEXABLE=false). Any failing line is fixed at its source (deploy file, data step or app code; app or layer fixes go through their repos, tags and gitlinks) and the smoke re-run until it passes — a URL from the live sitemap is never dropped from urls.txt to make it pass (D-15).
|
||||
|
||||
DEPLOY.md (host repo, summercms.io DEPLOY.md structure): Server facts (from Task 1, no secrets), Prerequisites, Build and upload, First install (.env files, migrate, admin:create with stdin password, migrate.sh with every flag and the D-25 order), Smoke (smoke.sh --url), Release (rebuild, rsync, supervisorctl restart of both programs; swr caches expire by themselves), Rollback (stop both programs and remove the staging vhost; the Winter site was never touched, D-10), Do not re-save imported posts in /gfadmin (Pitfall 2), Production cutover (not part of Phase 16: switch the apex vhost to these upstreams, set NUXT_SITE_URL to the apex, remove NUXT_SITE_INDEXABLE, set NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 and SUMMER_URL to the apex; Winter stays as rollback). README.md points to DEPLOY.md. Commit (docs: staging deploy guide) and, if urls.txt changed, (test: re-pull live sitemap URLs).</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL"</automated>
|
||||
<fails_when>Non-zero exit; output contains "smoke: FAIL"; the line starting "smoke: ok (" is absent.</fails_when>
|
||||
<automated>HOME_HTML=$(curl -fsS "$STAGING_URL/") && ! printf '%s' "$HOME_HTML" | grep -q googletagmanager && curl -fsS "$STAGING_URL/robots.txt" | grep -qx 'Disallow: /' && grep -q '^## Rollback' ../sm-grzybyfunkcjonalne-app/DEPLOY.md && echo staging-checks-ok</automated>
|
||||
<fails_when>Non-zero exit or "staging-checks-ok" absent: the staging homepage loads analytics, robots.txt does not disallow everything, or DEPLOY.md has no Rollback section.</fails_when>
|
||||
<human-check>Open $STAGING_URL/, /blog, /blog?page=2, a post, /cordyceps (pillar content above the list), /amanita-muscaria, /kontakt and /autor/admin and compare with the live site: the refreshed design (D-01, D-03) reads cleanly, the Polish copy is complete (D-11, D-12), featured images and inline media show. Log in at $STAGING_URL/gfadmin with the initial password from /srv/grzybyfunkcjonalne/admin-initial-password, change it with admin:reset-password, then delete that file. Reply "staging approved" or list issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- smoke.sh --url $STAGING_URL passes every urls.txt line, including every live sitemap URL, the negative cases and the remote storage lines.
|
||||
- One featured image URL and one media URL answer 200 with an image content type and X-Content-Type-Options nosniff on staging.
|
||||
- Staging renders no analytics and its robots.txt disallows everything.
|
||||
- DEPLOY.md has the sections Server facts, Prerequisites, Build and upload, First install, Smoke, Release, Rollback and Production cutover, and contains no secret value.
|
||||
- The human-check reply is recorded in the SUMMARY.
|
||||
</acceptance_criteria>
|
||||
<done>Every old URL answers correctly on staging, images and media load, and the deploy and rollback are documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Executor → PHP production server | The deploy runs next to the live Winter site |
|
||||
| Server .env files → processes | Secrets live only on the server |
|
||||
| Public internet → staging host | Staging is reachable before cutover |
|
||||
| PHP database → JSONL maps | Read-only queries against the live database |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-18 | Tampering | live Winter vhost, php-fpm, PHP database | high | mitigate | Only new files are installed (staging vhost, two supervisor programs, /srv/grzybyfunkcjonalne); nginx -t must pass before reload; PHP queries are SELECT-only; the SUMMARY lists every file touched on the server (D-10) |
|
||||
| T-16-19 | Information Disclosure | /srv/grzybyfunkcjonalne/go/.env, nuxt/.env | high | mitigate | Secrets are generated on the server, written root:grzybyfunkcjonalne 0640, never printed, never rsynced, never committed; DEPLOY.md names keys only |
|
||||
| T-16-20 | Elevation of Privilege | initial admin account | high | mitigate | Password piped on stdin from a 0600 file, never a command-line flag; the human-check changes it with admin:reset-password and deletes the file; /gfadmin keeps cabana auth with Secure cookies in production |
|
||||
| T-16-21 | Information Disclosure | staging duplicate content and analytics | medium | mitigate | NUXT_SITE_INDEXABLE=false (robots.txt disallows all, checked); NUXT_PUBLIC_GA_ID empty on staging (checked) |
|
||||
| T-16-22 | Information Disclosure | featured-images.jsonl, categories.jsonl | low | accept | They hold public post slugs, file names and category copy only; they stay under /srv/grzybyfunkcjonalne/import and are not committed |
|
||||
| T-16-SC | Tampering | packages on the server | high | mitigate | No package is installed by this plan; the Nuxt .output and the Go binary are built locally from locked dependencies (16-01..16-03) and uploaded; Node and PostgreSQL come from the server's existing package sources at the human checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 prints prerequisites-ok; Task 2 prints staging-tracer-ok.
|
||||
- `bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL"` prints "smoke: ok (N urls)".
|
||||
- The human-check reply "staging approved" is recorded.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 is met on staging: Go + Nuxt + imported posts serve the site on the PHP server (D-18).
|
||||
- Every live URL and the negative cases pass on staging (D-15).
|
||||
- The Winter site is untouched and remains the rollback (D-10); production cutover is documented, not performed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md` when done, with the Server facts, the runbook output and gap report, the smoke result and the human-check reply.
|
||||
</output>
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["16-04"]
|
||||
files_modified:
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/tests/routes.test.ts
|
||||
- ../vue-journal-layer/tests/client.test.ts
|
||||
- ../vue-journal-layer/tests/pagination.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
- ../vue-journal-layer/tests/sitemap.test.ts
|
||||
- ../vue-journal-layer/tests/seo.test.ts
|
||||
- ../vue-journal-layer/tests/i18n.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json
|
||||
- scripts/check-phase16.sh
|
||||
- .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-4]
|
||||
user_setup:
|
||||
- service: local PostgreSQL (Go integration tests)
|
||||
why: "Docker is not usable for this user; the grzyby console integration tests need a Postgres >= 15 with ICU through an admin DSN, or Docker for testcontainers"
|
||||
env_vars:
|
||||
- name: GRZYBY_TEST_ADMIN_DSN
|
||||
source: "Same kind of DSN as SMOKE_ADMIN_DSN (maintenance database, role with CREATEDB); may be the same value"
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4 / CLAUDE.md rule 3: every piece of new phase code has unit tests in this last plan: layer utils (node --test), the site app (node --test, including an SSR test of the built server against the fixture API) and the app-local Go commands (go test)."
|
||||
- "D-15: the SSR test asserts the status of every PHP URL form on the built app, including static routes beating /:category (RESEARCH A2) and real 404s for unknown roots, /404, /error and out-of-range or non-numeric pages (A5)."
|
||||
- "D-16: the SSR test asserts /en and /en/blog answer 404 and pl strings render without a prefix."
|
||||
- "D-23: the SSR test asserts no analytics without NUXT_PUBLIC_GA_ID, gtag for G-MLSV7WJ6W4 with it, and no script for a malformed id."
|
||||
- "D-06/D-13/D-24: layer unit tests cover route normalization, sitemap entries built from journal.routes, include_empty=1 on every categories request and canonical page titles."
|
||||
- "T-16-01: htmlGate tests mirror the Go TestFormatHTMLRejectsUnsafeHTML cases (script, iframe, event handler, javascript:, vbscript:, data:) and the SSR test proves an unsafe fixture body renders the blocked notice."
|
||||
- "D-22: Go tests prove grzyby:import-featured-images writes the blob at attach.BlobKey(disk_name) and the system_files row, is idempotent, resolves base slugs through the export, and refuses traversal disk names (T-16-12)."
|
||||
- "D-21: Go tests prove grzyby:import-categories prefers pl values, writes the content field, and reports unchanged on a re-run."
|
||||
- "D-09: the boot test asserts backend.uri resolves to /gfadmin, admin routes live under /gfadmin/ and none under /backend (T-16-10)."
|
||||
- "Pure tests never touch the network or a database; DB tests use only throwaway databases they create (T-16-23) and fail, not skip, when no database is available outside -short."
|
||||
- "go vet and go test stay green in every Go repo touched; scripts/check-phase16.sh --all ends with Phase 16 gate passed."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts"
|
||||
provides: "SSR test of the built server against the fixture API"
|
||||
contains: "startNuxtServer"
|
||||
- path: "../vue-journal-layer/tests/htmlGate.test.ts"
|
||||
provides: "Go-parity cases for the HTML gate"
|
||||
contains: "vbscript"
|
||||
- path: "../vue-journal-layer/tests/routes.test.ts"
|
||||
provides: "journal.routes normalization and path filling"
|
||||
contains: "normalizeJournalRoutes"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go"
|
||||
provides: "D-22 command tests"
|
||||
contains: "TestImportFeaturedImages"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go"
|
||||
provides: "D-21 command tests"
|
||||
contains: "TestImportCategories"
|
||||
- path: "scripts/check-phase16.sh"
|
||||
provides: "fail-closed phase gate across the three repos"
|
||||
contains: "Phase 16 gate passed"
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts"
|
||||
to: "../vue-journal-layer/testing/fixture-api.mjs and nuxt-server.mjs"
|
||||
via: "imported from node_modules/vue-journal-layer/testing (plain .mjs, no type stripping needed)"
|
||||
pattern: "vue-journal-layer/testing"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go"
|
||||
to: "Postgres (GRZYBY_TEST_ADMIN_DSN or testcontainers)"
|
||||
via: "creates grzyby_test_<random> with ICU pl-PL, migrates translate, journal and attach tables, drops it on exit"
|
||||
pattern: "GRZYBY_TEST_ADMIN_DSN"
|
||||
- from: "scripts/check-phase16.sh"
|
||||
to: "layer, app and host test commands"
|
||||
via: "fails on FAIL, SKIP, no tests to run, not ok, or a missing named PASS line"
|
||||
pattern: "no tests to run"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme.
|
||||
|
||||
MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted.
|
||||
|
||||
This plan's slice: one SSR test boots the built site against the fixture API and proves the URL map end to end; then unit tests cover every layer util, the app utils and the Go commands, and one gate script runs them all.
|
||||
|
||||
<objective>
|
||||
Deliver full unit test coverage for Phase 16 code as the phase's last plan (CLAUDE.md rule 3, SC-4): the built site (SSR), the layer utils, the app utils and content, the app-local Go commands and the host boot, plus a fail-closed phase gate and the validation sign-off.
|
||||
|
||||
Purpose: SC-4.
|
||||
Output: test files in the three repos, `scripts/check-phase16.sh`, an updated 16-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts
|
||||
@../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts
|
||||
@../sm-journal-plugin/admin_harness_test.go
|
||||
@../sm-journal-plugin/classes/format_html_test.go
|
||||
@scripts/check-phase15.sh
|
||||
</context>
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves.
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
- Package scripts `test` (`node --test --test-reporter=tap tests/*.test.ts`) in `vue-journal-layer` and `vue-grzybyfunkcjonalne-app`.
|
||||
- Layer tests `tests/{routes,client,pagination,htmlGate,sitemap,seo,i18n}.test.ts`.
|
||||
- App tests `tests/ssr.test.ts`, `tests/analytics.test.ts`, `tests/content.test.ts`; fixture post `test-niebezpieczny-wpis` (unsafe body) with matching expect lines.
|
||||
- Go tests `TestReadFeaturedImageMap`, `TestValidDiskName`, `TestFeaturedImageSourceEscape`, `TestImportFeaturedImages`, `TestImportFeaturedImagesDryRun`, `TestReadCategoryMap`, `TestImportCategories` (package `console` of plugin `golem15.grzyby`), harness `TestMain` with env `GRZYBY_TEST_ADMIN_DSN`; host tests `TestBackendURIGfadmin`, `TestGrzybyCommandsRegistered`.
|
||||
- Gate script `scripts/check-phase16.sh` (summercms.go, Phase 15 precedent `scripts/check-phase15.sh`) with `--all`, env overrides `PHASE16_LAYER`, `PHASE16_HOST`, ending with `Phase 16 gate passed`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer test — the built site answers every PHP URL form correctly against the fixture API (tests/ssr.test.ts)</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Validation Architecture), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-16, D-23, D-24)</read_first>
|
||||
<action>Add the package script test = node --test --test-reporter=tap tests/*.test.ts and a README line that pnpm build must run before pnpm test (the summercms.io output.test.ts convention). Add fixture post test-niebezpieczny-wpis whose content_html contains a script tag and an onerror attribute, and an expect.txt line 200 /blog/test-niebezpieczny-wpis contains=journal-post-body--blocked notcontains=onerror.
|
||||
|
||||
tests/ssr.test.ts imports startFixtureApi from vue-journal-layer/testing/fixture-api.mjs and startNuxtServer from vue-journal-layer/testing/nuxt-server.mjs (resolved from node_modules; plain .mjs because Node does not strip types there). before() asserts .output/server/index.mjs exists (failing with "run pnpm build before pnpm test") and boots the fixture API and the server (default env); after() closes both. Tests, each a plain test() with node:assert/strict, fetching with redirect manual:
|
||||
- D-15 status table: 200 for /, /blog, /blog?page=2, /blog/blogbadania-nad-cordycepsem, /cordyceps, /amanita-muscaria, /kontakt, /trwaja-prace, /autor/admin, /rss.xml, /sitemap.xml, /robots.txt; 404 for /blog?page=3, /blog?page=abc, /blog?page=0, /nieistnieje, /rss, /feed, /404, /error, /blog/badania-nad-cordycepsem, /autor/nobody, /blog/tag/anything.
|
||||
- static routes win over /:category (RESEARCH A2): /kontakt contains mailto:kontakt@grzybyfunkcjonalne.pl and /trwaja-prace contains Niebawem.
|
||||
- canonical and titles: /blog?page=2 has a canonical link ending /blog?page=2 and a title containing Strona 2; the post page title contains | Grzyby Funkcjonalne (RESEARCH A6, A7).
|
||||
- D-16: /en and /en/blog answer 404; /blog contains the pl string Artykuły.
|
||||
- D-24 and D-21: /amanita-muscaria answers 200 with the empty-state string; /cordyceps contains Cordyceps w pigułce before the first post card.
|
||||
- T-16-01: /blog/test-niebezpieczny-wpis contains journal-post-body--blocked and no onerror; / contains no journal-post-body--blocked (FAQ and homepage copy pass the gate).
|
||||
- Pitfall 5: a request sent with X-Forwarded-For: 198.51.100.9 makes the fixture API record an x-forwarded-for value containing 198.51.100.9.
|
||||
- Content types: /rss.xml starts with application/rss+xml; /sitemap.xml lists /blog/blogbadania-nad-cordycepsem and /amanita-muscaria and not /trwaja-prace.
|
||||
- D-14: no page among /, /kontakt, /blog contains a form element.
|
||||
- D-23: a second server with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 renders googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4 on /; the default server renders no googletagmanager; a third server with a malformed id (G-X');alert(1)//) renders no googletagmanager and no alert(1).
|
||||
Commit in the app (test: SSR test of the built site against the fixture API), push, and bump the host gitlink in a separate host commit.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test</automated>
|
||||
<fails_when>Non-zero exit; TAP output contains "not ok" or "# fail" with a count above 0; the "# pass" line is absent or 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tests/ssr.test.ts passes on the built .output with every case above, under node --test with no skipped test.
|
||||
- Removing the gate call from the layer PostBody would make the T-16-01 case fail (the case asserts the blocked class, not just a 200).
|
||||
- The fixture API request log is asserted for the forwarded x-forwarded-for value.
|
||||
- package.json has the test script; the host gitlink points at the pushed app commit.
|
||||
</acceptance_criteria>
|
||||
<done>The built site is proven against every PHP URL form, the locale, analytics and HTML-gate rules by an automated SSR test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Layer util tests and app util/content tests</name>
|
||||
<files>../vue-journal-layer/package.json, ../vue-journal-layer/README.md, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/tests/client.test.ts, ../vue-journal-layer/tests/pagination.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/sitemap.test.ts, ../vue-journal-layer/tests/seo.test.ts, ../vue-journal-layer/tests/i18n.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<behavior>
|
||||
- normalizeJournalRoutes: defaults round-trip; an override replaces one key and keeps the others; false disables; post without :slug, category without :category, or a path without a leading / throws an Error naming journal.routes.<key>.
|
||||
- fillJournalPath('/blog/:slug', { slug: 'a b' }) encodes to /blog/a%20b; a slug with / is encoded per segment.
|
||||
- clampPerPage: 0 → 1, 31 → 30, 'abc' → 9; buildPostListQuery emits page, per_page, sort, category, tag only when set.
|
||||
- createJournalClient with a fake fetcher: listCategories always sends include_empty=1; findCategory finds a nested child; getPost returns null for errors with status 404 or statusCode 404 and rethrows a 500.
|
||||
- parsePageParam: undefined → 1, '2' → 2, '0', '-1', '1.5', 'abc', ['2'] → null; pageOutOfRange(3, 2) true, (1, 0) false, (2, 2) false.
|
||||
- htmlGate: the six Go cases (script, iframe, event handler, javascript:, vbscript:, data:) are unsafe, upper-case variants too; plain headings, tables and links are safe; gateJournalHtml returns { safe: false, html: '' } for unsafe input.
|
||||
- buildJournalSitemapEntries: uses routes.post and routes.category (custom /:category gives /cordyceps), skips a false route, flattens nested categories, sets lastmod from updated_at.
|
||||
- postSeoMeta: description falls back to the first 160 characters of the content text; og image becomes absolute; listTitle adds the page title only when page > 1.
|
||||
- i18n: pl.json and en.json have identical leaf key sets and identical {name} interpolation tokens per key (for example {page}).
|
||||
- app gaHeadScripts: '' and malformed ids give []; G-MLSV7WJ6W4 gives two scripts with the id in the loader src.
|
||||
- app content: faq.pl.json has 16 items with non-empty question and answer_html; MAIN_MENU items have known kinds and every labelKey exists in pl.json; every t('…') key used in app/**/*.vue exists in pl.json or in the layer's journal.* keys.
|
||||
</behavior>
|
||||
<read_first>../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../sm-journal-plugin/classes/format_html_test.go, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts (leaves helper)</read_first>
|
||||
<action>Layer: add the package script test = node --test --test-reporter=tap tests/*.test.ts and a Testing line in the README. Write the seven test files from the behavior list, importing the utils with explicit .ts paths (scrollSpy.test.ts pattern) and a fake fetcher object for the client (no network). Reuse the summercms.io leaves() helper shape for i18n parity. If a test exposes a bug in a util, fix the util in the same repo (one logical change per commit), and when runtime code changed, tag the layer v0.1.1, bump the app's dependency and lockfile to it, and record that in the SUMMARY. Commit (test: Journal layer util tests) and push.
|
||||
|
||||
App: tests/analytics.test.ts imports ../app/utils/analytics.ts; tests/content.test.ts reads app/data/faq.pl.json, imports ../app/data/menu.ts and scans app/**/*.vue for t('…') and $t('…') keys with a regex, resolving each against pl.json plus the layer's i18n/locales/pl.json read from node_modules/vue-journal-layer (as JSON, no TS import). Commit in the app (test: app util and content tests), push, bump the host gitlink in a separate host commit.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-journal-layer test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test</automated>
|
||||
<fails_when>Non-zero exit; TAP output contains "not ok" or a "# fail" count above 0; either run lacks a "# pass" line with a count above 0.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All seven layer test files and both app unit test files exist and pass under node --test with no skips.
|
||||
- htmlGate.test.ts contains the six Go cases from sm-journal-plugin classes/format_html_test.go.
|
||||
- client.test.ts asserts include_empty=1 on the categories request.
|
||||
- i18n.test.ts fails if a key is added to pl.json without en.json.
|
||||
- Any util fix is released as layer tag v0.1.1 and the app pins it (or the SUMMARY states no runtime change was needed).
|
||||
</acceptance_criteria>
|
||||
<done>Every layer util and app util/content rule has a passing unit test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Go tests for the grzyby commands and the host boot, phase gate script and validation sign-off</name>
|
||||
<files>../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json, scripts/check-phase16.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md</files>
|
||||
<behavior>
|
||||
- TestReadFeaturedImageMap: two valid lines and a blank line parse to 2 entries; invalid JSON on line 3 returns an error containing "line 3".
|
||||
- TestValidDiskName: 665f1e2a3b4c5d6e7f8091.png is valid; ../x.png, a/b.png, x, .png, x.png/ and an empty string are invalid.
|
||||
- TestFeaturedImageSourceEscape: a disk name that would resolve outside --source is refused and counted Failed without reading the file.
|
||||
- TestImportFeaturedImages (Postgres): a published post with host slug blogbadania-nad-cordycepsem and a map entry keyed by the base slug badania-nad-cordycepsem with --export testdata/featured/export gives Created 1; the mem:// bucket holds attach.BlobKey(disk_name) with the source bytes; the system_files row has attachment_type Golem15\Journal\Models\Post, attachment_id equal to the post id as a string, field featured_images, the map's file_name, content_type and file_size; a second run gives Created 0 and Skipped 1; an unknown slug gives Failed 1; content type text/html gives Failed 1.
|
||||
- TestImportFeaturedImagesDryRun: no blob, no row, stats report what would be created.
|
||||
- TestReadCategoryMap and TestImportCategories (Postgres): pl translation values win over base; name, description and the D-21 content field are written; a re-run reports Unchanged; an unknown slug reports Failed.
|
||||
- TestBackendURIGfadmin: compass.Load("config") gives backend.uri /gfadmin and cabana.AdminPrefix resolves /gfadmin; TestGrzybyCommandsRegistered: the activated golem15.grzyby plugin returns both command names.
|
||||
</behavior>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-journal-plugin/admin_harness_test.go (testcontainers harness, ICU database, fail-closed TestMain), ../summercms.go/modules/lagoon/attach/bucket_test.go (mem:// bucket in tests), ../summercms.go/modules/cabana/prefix.go, scripts/check-phase15.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md</read_first>
|
||||
<action>testmain_test.go (package console): when GRZYBY_TEST_ADMIN_DSN is set, create grzyby_test_<random hex> with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL', and drop only that database in TestMain teardown; otherwise start testcontainers postgres (the image and options sm-journal-plugin admin_harness_test.go uses); with neither available and not -short, TestMain exits non-zero with a message naming GRZYBY_TEST_ADMIN_DSN (no false-green skip, Phase 2 house rule); with -short the DB tests call t.Skip and the pure tests still run. Migrate the translate, journal and attach (system_files) tables through the plugins' Migrations() as the journal harness does. Buckets are mem:// (import gocloud.dev/blob/memblob). testcontainers-go and its postgres module are STACK-named and already used by the plugins; add them to the plugin go.mod at the versions the journal plugin pins (v0.44.0). testdata/featured/export/badania-nad-cordycepsem.json holds the journal-export file shape with translations.pl.slug blogbadania-nad-cordycepsem.
|
||||
|
||||
Write the Go tests from the behavior list as plain func TestX(t *testing.T) with testify-free stdlib assertions unless the plugin already uses testify (keep the journal plugin's style). Host boot_test.go gains TestBackendURIGfadmin and TestGrzybyCommandsRegistered next to the existing tests. If a test exposes a bug, fix it in the command in the same repo as a separate commit.
|
||||
|
||||
scripts/check-phase16.sh (summercms.go, Phase 15 gate style: set -euo pipefail, unset FORCE_COLOR, PHASE16_LAYER default ../vue-journal-layer, PHASE16_HOST default ../sm-grzybyfunkcjonalne-app, usage, --all): stages (1) pnpm -C layer test; (2) layer build:playground and smoke-ssr over testing/expect-acme.txt; (3) app pnpm install --frozen-lockfile, build, test, smoke:ssr, smoke:ga; (4) go -C host vet ./... and go -C host test ./... -count=1 -v requiring PASS lines for TestBootUserTranslateJournal, TestCORS, TestBackendURIGfadmin, TestGrzybyCommandsRegistered; (5) go -C host/plugins/grzyby vet ./... and test ./... -count=1 -v -race requiring PASS lines for every Go test named in this task; (6) bash host/scripts/check-deploy.sh. Every stage fails on a non-zero exit, "--- FAIL", "--- SKIP", "no tests to run", "not ok", "DATA RACE" or a missing required PASS line. It ends with "Phase 16 gate passed".
|
||||
|
||||
16-VALIDATION.md: bind each Per-Task Verification Map row to its plan and task (16-01-T2…16-05-T3), fill the Threat Ref column (T-16-01 htmlGate and SSR blocked-notice tests, T-16-10 boot tests, T-16-11 check-deploy, T-16-12 traversal tests, T-16-23 harness), mark rows green after the gate passes, tick the Wave 0 items, set wave_0_complete: true and nyquist_compliant: true. Commits: plugin tests and host tests (test: grzyby command and boot tests) in the host repo; the gate script (test: Phase 16 gate) and the VALIDATION update (docs: Phase 16 validation sign-off) as separate commits in summercms.go.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -count=1 -v -race -run '^(TestReadFeaturedImageMap|TestValidDiskName|TestFeaturedImageSourceEscape|TestImportFeaturedImages|TestImportFeaturedImagesDryRun|TestReadCategoryMap|TestImportCategories)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS|TestBackendURIGfadmin|TestGrzybyCommandsRegistered)$'</automated>
|
||||
<fails_when>Non-zero exit; output contains "--- FAIL", "--- SKIP", "no tests to run" or "DATA RACE"; any of the eleven named "--- PASS: Test…" lines is absent.</fails_when>
|
||||
<automated>bash scripts/check-phase16.sh --all</automated>
|
||||
<fails_when>Non-zero exit; the last line is not "Phase 16 gate passed".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Every Go test named in the behavior list prints --- PASS with -race, none skipped, against a throwaway database.
|
||||
- The harness drops only the grzyby_test_<random> database it created.
|
||||
- scripts/check-phase16.sh --all prints "Phase 16 gate passed".
|
||||
- 16-VALIDATION.md has nyquist_compliant: true, wave_0_complete: true and every row bound to a plan task.
|
||||
- go vet and go test ./... are green in sm-grzybyfunkcjonalne-app and plugins/grzyby.
|
||||
</acceptance_criteria>
|
||||
<done>The Go commands and host boot are covered by passing tests and one gate script proves the whole phase green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test harness → Postgres | Tests create and drop databases with an admin DSN |
|
||||
| Gate script → developer machine | The gate runs builds and tests across three repos |
|
||||
| Tests → security controls | Regression tests are the evidence that the phase's mitigations hold |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16-23 | Tampering | console TestMain with GRZYBY_TEST_ADMIN_DSN | medium | mitigate | Creates grzyby_test_<random> and drops only that name; never migrates or drops the DSN's own database (T-02-05 precedent) |
|
||||
| T-16-24 | Repudiation (false green) | scripts/check-phase16.sh | medium | mitigate | Fails on FAIL, SKIP, no tests to run, not ok, DATA RACE and missing named PASS lines; DB tests fail instead of skipping outside -short |
|
||||
| T-16-25 | Tampering | regression coverage of T-16-01, T-16-10, T-16-12 | high | mitigate | htmlGate Go-parity cases plus the SSR blocked-notice case (T-16-01); TestBackendURIGfadmin and the boot route assertions (T-16-10); TestValidDiskName and TestFeaturedImageSourceEscape (T-16-12) |
|
||||
| T-16-SC | Tampering | test dependencies | high | mitigate | Only testcontainers-go v0.44.0 (STACK-named, already pinned by the Journal plugin) is added to plugins/grzyby/go.mod; no npm package is added; node --test is built in |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm -C ../vue-journal-layer test` and `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test` pass.
|
||||
- The Go command and host tests pass with -race.
|
||||
- `bash scripts/check-phase16.sh --all` prints "Phase 16 gate passed".
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: the phase's new code has unit tests, delivered in its last plan.
|
||||
- Every D-ID with behaviour (D-06, D-09, D-13, D-15, D-16, D-21, D-22, D-23, D-24) has at least one automated test.
|
||||
- 16-VALIDATION.md is signed off (nyquist_compliant: true).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -10,7 +10,7 @@ grzybyfunkcjonalne.pl gets a new Nuxt frontend built the same way as `vue-summer
|
||||
|
||||
The phase ends with the site **deploy-ready on staging** on the same server as the PHP site. The production nginx/DNS switch is a separate operational step after the phase.
|
||||
|
||||
Out of scope: porting the Winter arsha/Twig theme or a Go server-side template engine; the menu, FAQ, sitemanager, blocks and sitemap plugin ports; the contact and newsletter forms; an English locale; changes to `sm-journal-plugin`'s API contract (Phase 15 D-14). `summercms.go` is touched only if a framework feature turns out to be missing.
|
||||
Out of scope: porting the Winter arsha/Twig theme or a Go server-side template engine; the menu, FAQ, sitemanager, blocks and sitemap plugin ports; the contact and newsletter forms; an English locale; changes to `sm-journal-plugin`'s API contract (Phase 15 D-14), except the author endpoint and category pillar content added by pre-execution quick tasks (D-20, D-21). `summercms.go` is touched only if a framework feature turns out to be missing.
|
||||
|
||||
</domain>
|
||||
|
||||
@@ -45,6 +45,15 @@ Out of scope: porting the Winter arsha/Twig theme or a Go server-side template e
|
||||
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
|
||||
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
|
||||
|
||||
### Decisions after research (2026-10-09, from 16-RESEARCH.md open questions)
|
||||
- **D-19:** **5 plans**: (1) `vue-journal-layer`, including the spike that installs it from the git tag, (2) `vue-grzybyfunkcjonalne-app` with the content port, (3) host config, migration tooling and deploy scripts, (4) staging deploy plus smoke check as a human checkpoint, (5) unit tests.
|
||||
- **D-20:** **Author API in the Journal plugin**, as a prerequisite. A `/msd-quick` task in `sm-journal-plugin` runs *before* Phase 16 execution and adds an anonymous author endpoint: an author slug plus the public profile fields the author page needs, and the author's posts. This supersedes the "no `sm-journal-plugin` API changes" boundary for this addition only. Phase 16 plans consume that endpoint for `/autor/:author` (D-05) and do not implement it. If the endpoint isn't there at execution start, the layer plan stops at a checkpoint. — **Reversibility:** costly — published API addition on a shared plugin.
|
||||
- **D-21:** **Category pillar content in the Journal plugin**, as a prerequisite. A `/msd-quick` or `/msd-fast` task in `sm-journal-plugin` runs *before* execution and adds a rich content field to Category, exposed in the admin form and in the categories API. Phase 16 renders it on `/:category` pages above the post list, and plan 3's migration fills it from the PHP database. No app-side pillar files.
|
||||
- **D-22:** **Featured images** come in through an **app-local Go command** in `sm-grzybyfunkcjonalne-app`. It reads a slug→file map produced by a SQL query on the PHP database and copies the files under their Winter disk names, so old image URLs keep resolving. It is idempotent and has unit tests.
|
||||
- **D-23:** **Google Analytics carries over**: tag `G-MLSV7WJ6W4`, set through Nuxt runtime config (`NUXT_PUBLIC_…`) so staging can leave it empty.
|
||||
- **D-24:** **`/amanita-muscaria` stays.** The layer always requests categories with `include_empty=1`, so an empty category renders 200 with its pillar content and an empty list.
|
||||
- **D-25:** **`pl` becomes the default locale before `journal:import-posts`** (RESEARCH key finding: the 62 live post URLs use Polish slugs). It is a scripted, ordered migration step that imports once into a fresh database (`--force` re-runs are unsafe after the switch).
|
||||
|
||||
### Claude's Discretion
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
|
||||
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-09
|
||||
**Files analyzed:** 26 (grouped where they share an analog)
|
||||
**Analogs found:** 21 / 26
|
||||
|
||||
Paths are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. All analogs are git-tracked source (no `.msd` mirrors). D-20 (author endpoint) and D-21 (category content field) are pre-execution quick tasks in `sm-journal-plugin` and are **not** mapped here; only their consumption by the layer is.
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Plan | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|---|
|
||||
| `vue-journal-layer/package.json` | 1 | config | build | `sm-summercmsio-app/vue-summercmsio-app/package.json` | exact (move nuxt/vue to devDeps) |
|
||||
| `vue-journal-layer/nuxt.config.ts` | 1 | config | build | `vue-summercmsio-app/nuxt.config.ts` | role-match (layer: absolute paths) |
|
||||
| `vue-journal-layer/modules/journal.ts` | 1 | module | build-time route registration | none in repo; RESEARCH Pattern 1 | no analog |
|
||||
| `vue-journal-layer/app/composables/useJournalApi.ts`, `app/utils/client.ts`, `types.ts`, `query.ts` | 1 | service/client | request-response | none (summercms.io has no data fetch); RESEARCH Pattern 2 | no analog |
|
||||
| `vue-journal-layer/app/utils/{pagination,htmlGate,routes}.ts` | 1 | utility | transform | `vue-summercmsio-app/app/utils/scrollSpy.ts` | role-match (pure TS, node --test) |
|
||||
| `vue-journal-layer/app/journal-pages/*.vue` (PostIndex, Post, Category, Tag, Author) | 1 | page | request-response (SSR) | `vue-summercmsio-app/app/pages/index.vue` | partial |
|
||||
| `vue-journal-layer/app/components/Journal/*.vue` | 1 | component | render | `vue-summercmsio-app/app/components/*` | role-match |
|
||||
| `vue-journal-layer/app/assets/css/journal-tokens.css`, `journal-base.css` | 1 | config (style) | — | `vue-summercmsio-app/app/assets/css/tokens.css`, `base.css` | exact |
|
||||
| `vue-journal-layer/i18n/locales/{pl,en}.json` | 1 | config (i18n) | — | `vue-summercmsio-app/i18n/locales/en.json` | exact |
|
||||
| `vue-journal-layer/server/api/__sitemap__/journal.ts` | 1 | server route | request-response | none; RESEARCH Pattern 5 | no analog |
|
||||
| `vue-journal-layer/README.md` | 1 | docs | — | `vue-summercmsio-app/README.md` (neutral names: `blog`/`acme`) | role-match |
|
||||
| `vue-grzybyfunkcjonalne-app/package.json` | 2 | config | build | `vue-summercmsio-app/package.json` | exact (+ git dep) |
|
||||
| `vue-grzybyfunkcjonalne-app/nuxt.config.ts` | 2 | config | build | `vue-summercmsio-app/nuxt.config.ts` | exact (SSR instead of generate) |
|
||||
| `vue-grzybyfunkcjonalne-app/app/pages/{index,kontakt,trwaja-prace}.vue`, `app/error.vue` | 2 | page | render | `vue-summercmsio-app/app/pages/index.vue` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/app/components/*` (Hero, About, Cta, Services, Faq, SiteHeader, SiteFooter, Journal overrides) | 2 | component | render | `vue-summercmsio-app/app/components/*` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/app/data/{menu.ts,faq.pl.json}` | 2 | config/data | — | `vue-summercmsio-app/app/data/terminal.json` | role-match |
|
||||
| `vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css` | 2 | config (style) | — | `vue-summercmsio-app/app/assets/css/tokens.css` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/i18n/locales/pl.json` | 2 | config (i18n) | — | `vue-summercmsio-app/i18n/locales/en.json` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/.gitmodules` (add submodule) | 2/3 | config | — | `sm-summercmsio-app/.gitmodules` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/config/backend.yaml` | 3 | config | — | itself (currently `uri: /backend`) | exact |
|
||||
| app-local plugin `sm-grzybyfunkcjonalne-app/plugins/golem15/grzyby/plugin.go` (hosts the command) + `summer.yaml` entry | 3 | plugin | — | `sm-journal-plugin/plugin.go:60-65` (`Commands()`), `sm-summercmsio-app/plugins/golem15/summercms/plugin.go` | role-match |
|
||||
| `.../console/import_featured_images.go` (D-22) | 3 | command | file-I/O + CRUD | `sm-journal-plugin/console/import_posts.go` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/nginx/*.conf` | 3 | config | request routing | `sm-summercmsio-app/deploy/nginx/summercms.io.conf` | role-match (two upstreams) |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/supervisor/*.conf` (Go + Nuxt) | 3 | config | process | `sm-summercmsio-app/deploy/supervisor/summercms-io.conf` | exact (x2) |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/env.example` | 3 | config | — | `sm-summercmsio-app/deploy/env.example` | exact (+ `SUMMER_URL`, `NUXT_*`) |
|
||||
| `sm-grzybyfunkcjonalne-app/scripts/{build.sh,smoke.sh,check-deploy.sh,urls.txt,migrate.sh}` | 3/4 | script | batch | `sm-summercmsio-app/scripts/{build.sh,smoke.sh,check-deploy.sh}` | role-match |
|
||||
| Tests: layer/app `tests/*.test.ts`, Go `console/*_test.go` | 5 | test | — | `vue-summercmsio-app/tests/scrollSpy.test.ts`, `sm-grzybyfunkcjonalne-app/boot_test.go` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `vue-journal-layer/package.json` and `vue-grzybyfunkcjonalne-app/package.json`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/vue-summercmsio-app/package.json` (whole file)
|
||||
```json
|
||||
{
|
||||
"name": "vue-summercmsio-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build", "dev": "nuxt dev", "generate": "nuxt generate",
|
||||
"preview": "nuxt preview", "prepare": "nuxt prepare",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts"
|
||||
},
|
||||
"engines": { "node": ">=22.18" },
|
||||
"dependencies": {
|
||||
"@nuxt/fonts": "0.14.0", "@nuxtjs/i18n": "10.4.0", "@nuxtjs/seo": "5.2.1",
|
||||
"nuxt": "4.4.8", "vue": "3.5.35"
|
||||
},
|
||||
"devDependencies": { "typescript": "5.9.3" }
|
||||
}
|
||||
```
|
||||
Adapt: layer keeps the three modules in `dependencies`, moves `nuxt`/`vue` to `devDependencies` (RESEARCH Installation). App adds `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` and does not re-list modules at other versions (Pitfall 4). Drop `generate` from the app (SSR, D-07). Copy `.nvmrc` (22.22.0), `pnpm-workspace.yaml`, `tsconfig.json` from the same directory.
|
||||
|
||||
### `nuxt.config.ts` (layer and app)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/nuxt.config.ts` (lines 1-66)
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo'],
|
||||
css: ['~/assets/css/tokens.css', '~/assets/css/base.css'],
|
||||
fonts: { families: [ { name: 'Roboto', weights: [300,400,500,700], styles: ['normal'], subsets: ['latin','latin-ext'], global: true } ] },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
locales: [{ code: 'en', language: 'en-US', file: 'en.json', name: 'English' }],
|
||||
langDir: 'locales/',
|
||||
baseUrl: 'https://summercms.io',
|
||||
},
|
||||
site: { url: 'https://summercms.io', name: 'SummerCMS', description: '...', defaultLocale: 'en' },
|
||||
ogImage: { enabled: false },
|
||||
sitemap: { autoI18n: false },
|
||||
})
|
||||
```
|
||||
- **Layer:** keep `modules`, replace `~/` css paths with `fileURLToPath(new URL('./app/assets/css/journal-tokens.css', import.meta.url))` (anti-pattern: relative/`~` paths resolve to the consumer); add `resolve('./modules/journal')` to modules; locales pl+en; `sitemap: { sources: ['/api/__sitemap__/journal'] }`; declare `runtimeConfig.journal.apiBase` and `runtimeConfig.public.journal.apiBase` (`/_journal/api/v1`).
|
||||
- **App:** `extends: ['vue-journal-layer']`, `journal.routes` (RESEARCH Pattern 1 app excerpt: `category: '/:category'`, `author: '/autor/:author'`, `tag: false`), `defaultLocale: 'pl'` with pl only, `site.url` grzyby, `routeRules: { '/**': { swr: 300 } }`, `runtimeConfig.public.gaId` empty (D-23 `NUXT_PUBLIC_GA_ID`). Fonts: Open Sans/Jost/Poppins family list in the same shape. Remove the `nitro.prerender` / `/docs` link-checker blocks; add `linkChecker.excludeLinks` for `/gfadmin`, `/_journal/**`, `/storage/**`.
|
||||
- Comment style: short explanatory comments above each block, as the analog does.
|
||||
|
||||
### `vue-journal-layer/modules/journal.ts`, `app/composables/useJournalApi.ts`, `server/api/__sitemap__/journal.ts`
|
||||
|
||||
**No codebase analog.** Use RESEARCH.md Pattern 1 (`defineNuxtModule` + `extendPages`, route names `journal-<key>`, `path === false` skips), Pattern 2 (server `apiBase` + forwarded `x-forwarded-for`), Pattern 3 (category resolves against `GET /categories?include_empty=1`, 404 via `createError({ statusCode: 404, fatal: true })`, D-24; render D-21 category `content` above the list), Pattern 5 (sitemap source paging `per_page=30` until `meta.last_page`). Author page consumes the D-20 endpoint; plan 1 checkpoints if absent. Put the pure logic (query building, page parsing, route-path validation, html gate, sitemap loc building from `journal.routes`) in `app/utils/*.ts` so it is `node --test`-able like `scrollSpy.ts`.
|
||||
|
||||
### `app/utils/*.ts` (pure TS, both repos)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/utils/scrollSpy.ts` paired with `tests/scrollSpy.test.ts`. Exported constants + pure functions, no Nuxt imports, so tests import with an explicit `.ts` extension:
|
||||
```ts
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { activeSection, SPY_SECTIONS, SPY_THRESHOLD } from '../app/utils/scrollSpy.ts'
|
||||
|
||||
test('no sections means no active link', () => {
|
||||
assert.equal(activeSection([]), '')
|
||||
})
|
||||
```
|
||||
|
||||
### CSS tokens (layer `journal-tokens.css`, app `tokens.css`)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/assets/css/tokens.css` (lines 1-20): single `:root { ... }` block, grouped by family with a trailing comment per variable naming its use:
|
||||
```css
|
||||
/* Design tokens from the summercms.io handoff. Component styles use these. */
|
||||
:root {
|
||||
--navy-900: #18223a; /* terminal background */
|
||||
--yellow-500: #f5c55a; /* accent */
|
||||
--text: #e9edf3; /* body text */
|
||||
```
|
||||
Layer uses the `--journal-color-*`, `--journal-font-*`, `--journal-space-*`, `--journal-radius-*` prefix (D-03). App overrides on `:root:root` or `html` (RESEARCH Pattern 4), seeded with `#0D705D/#27AE60/#138C74/#1AAC8F/#444444`.
|
||||
|
||||
### Pages and components (both repos)
|
||||
|
||||
**Analog:** `vue-summercmsio-app/app/pages/index.vue` and `app/components/*` (SFCs consuming i18n keys from `i18n/locales/*.json`, tokens only, no hard-coded colors). Layer pages live in `app/journal-pages/` (not `app/pages/`, anti-pattern); layer components under `app/components/Journal/` so the app overrides by the same name. Links via `useLocalePath()` with route names.
|
||||
|
||||
### i18n locale files
|
||||
|
||||
**Analog:** `vue-summercmsio-app/i18n/locales/en.json`, plus the leaf-walk check in `tests/output.test.ts` (lines 34-45: `type Messages = { [key: string]: string | Messages }`, `loadMessages()`, `leaves(tree, prefix)`). Reuse that helper in plan 5 to assert pl/en key parity in the layer.
|
||||
|
||||
### `sm-grzybyfunkcjonalne-app/.gitmodules`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/.gitmodules` lines 1-3:
|
||||
```
|
||||
[submodule "vue-summercmsio-app"]
|
||||
path = vue-summercmsio-app
|
||||
url = git@git.golem15.com:golem15/vue-summercmsio-app.git
|
||||
```
|
||||
Submodule rule: commit and push inside the submodule first, then bump the gitlink as a separate commit.
|
||||
|
||||
### `sm-grzybyfunkcjonalne-app/config/backend.yaml`
|
||||
|
||||
Current content:
|
||||
```yaml
|
||||
# Admin mount path. Override with SUMMER_BACKEND__URI.
|
||||
uri: /backend
|
||||
```
|
||||
Change to `uri: /gfadmin` (D-09). Also review `config/http.yaml` (trusted proxy `127.0.0.1/32` at lines 15-16) - keep as is.
|
||||
|
||||
### App-local plugin + `import_featured_images.go` (D-22)
|
||||
|
||||
**Command analog:** `sm-journal-plugin/console/import_posts.go`
|
||||
|
||||
Imports (lines 3-21) - stdlib first, then framework modules, then gorm:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
Command declaration (lines 38-63): a constructor returning `bonfire.Command` that delegates to a testable function taking `*gorm.DB`:
|
||||
```go
|
||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:import-posts",
|
||||
Description: "Import journal posts with translations from JSON files.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path := flagOrDefault(in, "path", defaultExportPath)
|
||||
...
|
||||
stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil)
|
||||
...
|
||||
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed", ...))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
Core loop + error handling (lines 66-120): nil-DB guard, `os.Stat` dir check, per-item failures counted into a stats struct with `out.Error(...)` and `continue`, fatal DB errors returned; whole run in one transaction (`work := func(tx *gorm.DB) error`). Copy this shape for `grzyby:import-featured-images --map map.json --source <dir> [--dry-run]` with `Created/Skipped/Failed` stats; idempotency = skip when an `attach.File` row with the same `disk_name` + `attachment_id` + `field` exists. Helpers `flagOrDefault`, `flagOn`, `mustDB` are unexported in the journal console package - re-declare locally.
|
||||
|
||||
**Attachment API** (`summercms.go/modules/lagoon/attach`):
|
||||
- `attach.BlobKey(diskName)` = `PartitionDirectory(diskName) + diskName` (thumb.go:54-69) - the blob key under bucket `file://./storage/app/uploads/public` (host `config/storage.yaml`).
|
||||
- `attach.File` struct (file.go:26-45): set `DiskName, FileName, FileSize, ContentType, Field: "featured_images", AttachmentID: strconv(postID), AttachmentType: "Golem15\\Journal\\Models\\Post"`; `IsPublic` is `*bool` (nil = DEFAULT TRUE).
|
||||
- Look up post id by host `slug` (which is the pl slug after D-25).
|
||||
|
||||
**Plugin host analog:** `sm-journal-plugin/plugin.go:60-65`:
|
||||
```go
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
console.ExportPosts(p.app),
|
||||
console.ImportPosts(p.app),
|
||||
}
|
||||
}
|
||||
```
|
||||
Plugin skeleton: `sm-summercmsio-app/plugins/golem15/summercms/plugin.go` lines 9-40 and 76-78 (`ID()`, `Requires()`, `Register`, `Boot`, `func init() { party.Register(&Plugin{}) }`). Here implement `pact.HasCommands` instead of `HasRoutes`; store `app` in `Register`. Register in `summer.yaml` `plugins:` list (`id: golem15.grzyby`, module path) and regenerate `plugins.gen.go`/`main.go` with `summer build` (main.go is generated - do not hand-edit; it already appends `pact.HasCommands` commands). Whether the plugin lives as an in-repo directory (no separate module) is a planner decision; `go.work` must include it.
|
||||
|
||||
### `deploy/supervisor/*.conf`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/supervisor/summercms-io.conf` (whole file):
|
||||
```ini
|
||||
[program:summercms-io]
|
||||
command=/srv/summercms-io/bin/summercms-io serve --addr 127.0.0.1:8095
|
||||
directory=/srv/summercms-io
|
||||
user=summercms
|
||||
environment=SUMMER_ENV="production"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=15
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/supervisor/summercms-io.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
```
|
||||
Duplicate for `[program:grzyby-nuxt]` with `command=node .output/server/index.mjs`, `environment=NITRO_HOST="127.0.0.1",NITRO_PORT="...",NUXT_JOURNAL_API_BASE="http://127.0.0.1:<go>/_journal/api/v1"`. Keep the header comment style (path on server, why `directory=` matters).
|
||||
|
||||
### `deploy/nginx/*.conf`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/nginx/summercms.io.conf` - copy the HTTP→HTTPS + ACME block, the ssl/HSTS/gzip lines, and the per-path `location ^~` style (analog uses `location ^~ /backend { return 404; }`). New: `location ^~ /gfadmin` and `^~ /_journal` → Go upstream, `= /rss.xml` → Go `/_journal/api/v1/rss`, `^~ /storage/app/uploads/public/` and `^~ /storage/app/media/` → `alias` disk with `nosniff`, `/` → Nuxt upstream; `proxy_set_header Host $host` and `X-Forwarded-For` (Pitfalls 5, 6). Staging `server_name`, not the apex (D-10).
|
||||
|
||||
### `deploy/env.example`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/deploy/env.example` (whole file: header with owner/mode, one commented line per key, `SUMMER_DATABASE__DSN`, `SUMMER_APP__KEY`, `SUMMER_ADMIN__JWT__SECRET`, `SUMMER_GOLEM15__USER__JWT__SECRET`). Add `SUMMER_URL=https://<staging host>` and the Nuxt keys (`NUXT_JOURNAL_API_BASE`, `NUXT_PUBLIC_GA_ID`), either in this file or a second `nuxt.env.example`.
|
||||
|
||||
### `scripts/build.sh`, `scripts/smoke.sh`, `scripts/check-deploy.sh`
|
||||
|
||||
**Analog:** `sm-summercmsio-app/scripts/*`. Conventions to copy:
|
||||
- Header usage comment, `set -euo pipefail`, `ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`, `die()`/`fail()` to stderr, tool presence loop (`for tool in pnpm go ...; do command -v ...`), submodule presence check (`[[ -f "$SITE/package.json" ]] || die "... run git submodule update --init"`), `SUMMERCMS_FRAMEWORK` override, `release|dev` mode (build.sh lines 1-50).
|
||||
- smoke.sh (lines 1-60): postgres:15 via `docker run -d --rm -p 127.0.0.1::5432`, `pg_isready` loop, `trap cleanup EXIT` killing PID and container, env-overridable port. Extend: start Nuxt too, run migrate → set pl default (D-25 SQL `UPDATE golem15_translate_locales SET is_default = (code = 'pl')`) → import, then curl every line of `scripts/urls.txt` (71 sitemap URLs + static routes) for expected status.
|
||||
- build.sh adapts `nuxt generate` + embed to `pnpm build` producing `.output/` shipped next to the binary.
|
||||
|
||||
### Tests (plan 5)
|
||||
|
||||
- Layer/app TS: `node --test` per `tests/scrollSpy.test.ts` pattern above.
|
||||
- Go: plain `func TestX(t *testing.T)` per `sm-grzybyfunkcjonalne-app/boot_test.go` (lines 1-30: `compass.Load("config")`, `cfg.Set("admin.jwt.secret", ...)`, `backpack.New(cfg)`, `party.Activate(app, PluginIDs)`). Extend boot test to assert `/gfadmin` mount and the new command is in the catalog. Featured-image command tests use a temp source dir and an in-memory/`mem://` bucket (comment in `config/storage.yaml`: "mem:// in unit tests") and assert idempotency on second run.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Pinned toolchain
|
||||
**Source:** `vue-summercmsio-app/package.json`, `.nvmrc`. **Apply to:** both Nuxt repos. Exact versions, no carets.
|
||||
|
||||
### node --test with `.ts` imports
|
||||
**Source:** `vue-summercmsio-app/tests/*.test.ts`. **Apply to:** all TS unit tests; logic lives in `app/utils/*.ts` with no Nuxt auto-imports.
|
||||
|
||||
### Bonfire command + testable core
|
||||
**Source:** `sm-journal-plugin/console/import_posts.go:38-120`. **Apply to:** any app-local Go command.
|
||||
|
||||
### Deploy file headers
|
||||
**Source:** `sm-summercmsio-app/deploy/*`. **Apply to:** all deploy files: first comment names the server path and the reason for non-obvious settings; secrets never committed.
|
||||
|
||||
### Neutral README
|
||||
**Apply to:** `vue-journal-layer/README.md` - never name grzybyfunkcjonalne; use `blog`/`acme`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `vue-journal-layer/modules/journal.ts` | Nuxt module | build-time | No Nuxt module or layer exists in the workspace; use RESEARCH Pattern 1 |
|
||||
| `useJournalApi.ts` / `client.ts` | client | request-response | summercms.io app is static and fetches nothing; RESEARCH Pattern 2 |
|
||||
| `server/api/__sitemap__/journal.ts` | server route | request-response | No Nitro server routes exist; RESEARCH Pattern 5 |
|
||||
| `app/utils/htmlGate.ts` | utility | transform | Port of Go `FormatHTML` gate regexes from `sm-journal-plugin/classes`; no TS precedent |
|
||||
| `scripts/migrate.sh` / runbook (D-25 order) | script | batch | No migration runbook script precedent; build from smoke.sh conventions |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `sm-summercmsio-app/` (vue app, deploy, scripts, site plugin), `sm-grzybyfunkcjonalne-app/`, `sm-journal-plugin/console` and `plugin.go`, `summercms.go/modules/lagoon/attach`
|
||||
**Files scanned:** ~25
|
||||
**Pattern extraction date:** 2026-10-09
|
||||
@@ -0,0 +1,741 @@
|
||||
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Research
|
||||
|
||||
**Researched:** 2026-10-09
|
||||
**Domain:** Nuxt 4 SSR layer (reusable Journal views) + Nuxt site app + headless SummerCMS Go host, WinterCMS content migration, two-process deploy
|
||||
**Confidence:** MEDIUM-HIGH (in-repo facts and live-site observations HIGH; Nuxt layer-over-git mechanics MEDIUM, need a Wave 0 spike)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Rendering approach (user-stated before discussion)
|
||||
- **D-01:** Build a new Nuxt theme following summercms standards (the `vue-summercmsio-app` setup: Nuxt 4, `@nuxtjs/i18n`, `@nuxtjs/seo`, `@nuxt/fonts`, pnpm, node `--test`). The styling is based on grzybyfunkcjonalne but refreshed, not a pixel copy of arsha/Bootstrap. Go stays headless. No Go `html/template` theme layer and no Twig port. — **Reversibility:** costly — every public page is a Nuxt page that consumes the Journal JSON API.
|
||||
|
||||
#### Reuse shape
|
||||
- **D-02:** The reusable blog views are a **Nuxt layer** in a new repo `vue-journal-layer` (`git.golem15.com/golem15/vue-journal-layer`). Site apps consume it with `extends`. — **Reversibility:** costly — later blogs build on the layer's structure.
|
||||
- **D-03:** The layer ships a **neutral base design driven by CSS custom-property tokens** (colors, fonts, spacing, radii). It looks clean on its own. Site apps override the tokens and can override any layer component. The refreshed grzyby design lives in the site app, not in the layer.
|
||||
- **D-04:** Distribution is a **pnpm git dependency** with a pinned tag: `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, with the commit locked in `pnpm-lock.yaml`. There is no npm registry and no submodule for the layer. (`git.golem15.com` is Gitea 1.25.4, and a public repo needs no auth.) Local development uses `pnpm link` or a `link:../vue-journal-layer` override. A release is a git tag.
|
||||
- **D-05:** The layer owns these Journal views and their data access: post list with pagination (successor of `journalPosts`, with sort and page size configurable), single post (successor of `journalPost`: rendered markdown, featured image, meta, related/random posts like `randomPosts`), category and tag listings, the author page (successor of `journalAuthor`), and the RSS feed link. It also owns a typed client for `/_journal/api/v1` (anonymous GET routes only).
|
||||
- **D-06:** Route paths in the layer must be configurable or overridable by the app, because grzyby uses specific PHP URLs (D-15) that another blog may not want.
|
||||
|
||||
#### Render & deploy
|
||||
- **D-07:** **SSR**: `nuxt build` runs as a Node server, with `routeRules` swr/ISR caching so a newly published post shows up without a rebuild and pages stay full HTML for SEO. Unlike summercms.io (`nuxt generate` embedded in the binary), this site runs two processes: the Go binary and the Nuxt Node server. — **Reversibility:** costly — deploy scripts, supervisor and nginx topology depend on it.
|
||||
- **D-08:** The Nuxt app is a new repo `vue-grzybyfunkcjonalne-app`, mounted as a **submodule in `sm-grzybyfunkcjonalne-app`**, the same way `vue-summercmsio-app` is mounted in `sm-summercmsio-app`.
|
||||
- **D-09:** The admin is exposed on the site domain at **`/gfadmin`** (set with `backend.uri: /gfadmin` in the host config; cabana supports this without a framework change). nginx routes `/gfadmin` and `/_journal` (plus media/storage paths) to Go and everything else to Nuxt. PHP used `/backend`; the new path is intentional.
|
||||
- **D-10:** Deploy target is the **same server as the PHP site**. Staging runs next to the live Winter install. The later cutover switches nginx, and the Winter install stays as the rollback.
|
||||
|
||||
#### Non-Journal content
|
||||
- **D-11:** The homepage marketing content (hero, about, why-us, CTA and the other sitemanager blocks, `content/blocks/*`, `content/homepage`, `content/blog` headers, the kontakt page content) moves **into the Nuxt app repo** as components plus i18n/markdown, with the current Polish copy carried over. The sitemanager `enable_*` toggles are dropped, and the design decides which sections stay. No sitemanager or blocks plugin port.
|
||||
- **D-12:** The main menu (`g15_menu`) becomes typed config in the app, and the FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) becomes static i18n/markdown content in the app. Both get proper plugins later; todos `menu-plugin-port.md` and `faq-plugin-port.md` were added.
|
||||
- **D-13:** SEO and sitemap use **`@nuxtjs/seo` in the layer**: per-post meta, OG tags, canonical, robots (honor the per-page `robots_noindex` equivalents), and `sitemap.xml` generated from `/_journal/api/v1` plus the app's static pages. This replaces `eagle_seo` and `Golem15.Sitemap` for this site. `sm-sitemap-plugin` stays on the backlog.
|
||||
- **D-14:** The contact and newsletter forms are **dropped**. The kontakt page shows contact details and an email link only, and the newsletter stays in backlog 999.1. No new Go endpoints.
|
||||
|
||||
#### URLs, i18n, migration, cutover
|
||||
- **D-15:** **The PHP public URLs stay identical**: `/`, `/blog` (with pagination), `/blog/:post`, `/autor/:author`, `/kontakt`, `/:category` (root-level catch-all, where known static routes take precedence), plus `/404` and `/error` behavior. Research must enumerate the real live URLs (including the pagination query/segment form and `trwaja-prace`) so none are lost. — **Reversibility:** one-way — search-indexed URLs; changing them later needs a 301 map.
|
||||
- **D-16:** **Polish only, i18n-ready**: `@nuxtjs/i18n` is wired in the layer and the app, the layer ships pl and en UI strings, and the site serves `pl` without a URL prefix. A later blog can enable more locales.
|
||||
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
|
||||
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
|
||||
|
||||
### Claude's Discretion
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
|
||||
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
|
||||
- How markdown renders on the Nuxt side (render server-side from the API's markdown or HTML field, whichever the Phase 15 API returns), with sanitization consistent with Journal's XSS rules.
|
||||
- Exact swr/ISR TTLs, supervisor/nginx config and scripts layout, modeled on `sm-summercmsio-app/deploy` and `scripts/`.
|
||||
- Whether the refreshed visual direction gets a `/gsd-ui-phase` UI-SPEC before planning (recommended, not required).
|
||||
- Run the security-review agent only if new Go endpoints or auth surface appear. None are planned.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- `sm-menu-plugin` port (todo `menu-plugin-port.md`)
|
||||
- `sm-faq-plugin` port (todo `faq-plugin-port.md`)
|
||||
- Sitemanager/blocks plugin port for editor-managed homepage sections
|
||||
- Contact form via a Go endpoint, and the newsletter signup (backlog 999.1)
|
||||
- English locale for grzybyfunkcjonalne.pl
|
||||
- Production cutover (nginx switch on the PHP server, Winter kept as rollback): an operational step after the phase
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- `sitemap-plugin-port.md`: superseded for this site by `@nuxtjs/seo` sitemap (D-13); stays on the backlog for a site that needs editor-defined sitemaps.
|
||||
- `nest-framework-packages-under-modules.md`, `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`: keyword matches only, not this phase.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to Phase 16 (ROADMAP `**Requirements**: TBD`). The success criteria are the requirement source:
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| SC-1 | grzybyfunkcjonalne.pl is served by a SummerCMS binary (Journal content, routing, i18n as the site needs); met on staging (D-18) | Deploy topology, nginx/supervisor sketches, migration runbook (default locale `pl` before import), smoke script over the 71 sitemap URLs + static routes |
|
||||
| SC-2 | Blog views live in a reusable place (`vue-journal-layer`) | Layer structure, `extendPages`-based configurable routes, tokens, i18n merge rules, pnpm git dependency mechanics |
|
||||
| SC-3 | Every public page PHP Journal + theme rendered has a named Go/Nuxt successor; sitemap if needed | URL inventory table (PHP file → URL → live status → Nuxt route), sitemap via `@nuxtjs/seo` dynamic source |
|
||||
| SC-4 | New code has unit tests in the last plan | Validation Architecture: `node --test` for layer/app TS utils, Go tests for host config/commands, smoke script |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
The Phase 15 API is usable for most of the layer, but four facts change the plan. Three are data facts and one is about the API:
|
||||
|
||||
1. **The live post URLs use the Polish translation slug, not the export's base slug.** `https://grzybyfunkcjonalne.pl/sitemap.xml` lists 62 post URLs. All 62 match `translations.pl.slug` (or the base slug when no pl translation exists), and 57 of them differ from the base `slug` (e.g. live `/blog/blogbadania-nad-cordycepsem` vs base `badania-nad-cordycepsem`; the base-slug URL returns 404 live). The Go translate plugin seeds `en` as the default locale. The importer writes base fields to the host columns and pl to `golem15_translate_attributes`, and `GET /posts/{slug}` matches only the host `slug` column. So with the seeded locales every live post URL would 404 on Go. **Fix without code:** make `pl` the default locale (admin `makeDefault` action or SQL) **before** running `journal:import-posts`. `SetTranslated` then writes the pl values onto the host columns, so the API serves pl title/content/slug directly. [VERIFIED: live sitemap + export diff; sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go:13-18; translatable.go:192-201; posts_helpers.go:55-60]
|
||||
2. **The export lacks data the site needs:** no author, no tags, no featured images (the PHP exporter skips attachments), no category names, descriptions or category "pillar" content. Category pages on the live site render about 13 KB of pillar HTML per mushroom (`category.content`). The Go `Category` model has no `content` column, and the API does not return one. Recommend that category pillar content and descriptions move into the Nuxt app as content keyed by category slug. That matches the D-11 treatment of non-Journal content and needs no API change, but it needs user confirmation (A3).
|
||||
3. **The API cannot serve an author page (D-05) as specified.** No author endpoint exists. The `author` list filter is editor-only. The serialized author is only `{id, first_name, last_name, login}`, with no slug, bio, avatar or subtitle. `sort=random` is not supported, but `related_posts` (latest 3 in the same categories) comes back with every single-post response and is the natural `randomPosts` successor. The author route is not linked anywhere on the live site and is not in the live sitemap. Imported posts also have no author. **Decision needed (Open Question 1).**
|
||||
4. **Saving an imported post in the admin would destroy its body.** The pl `content` is HTML, not markdown. `classes.FormatHTML` (goldmark without unsafe HTML) turns HTML content into `<!-- raw HTML omitted -->`. The importer is safe because the pl `content_html` translation overwrites the host column afterwards. But editing an imported post in `/gfadmin` would run `FormatHTML` again and wipe the rendered body. [VERIFIED: probe run this session, output pasted under Pitfall 2]
|
||||
|
||||
**Primary recommendation:** Build `vue-journal-layer` as a Nuxt 4.4.8 layer:
|
||||
- Its page components sit outside `app/pages`, and a local module registers them with `extendPages`. Paths come from a `journal.routes` config key the app overrides. Route names stay stable.
|
||||
- A typed `$fetch` client calls `/_journal/api/v1`: an internal base URL on the server (forwarding `X-Forwarded-For`), same-origin from the browser.
|
||||
- `@nuxtjs/i18n` 10.4.0 and `@nuxtjs/seo` 5.2.1 run with a dynamic sitemap source.
|
||||
|
||||
The site is `vue-grzybyfunkcjonalne-app` with `extends: ['vue-journal-layer']`, the grzyby tokens and static content, and `routeRules: { '/**': { swr: 300 } }`. It runs as `node .output/server/index.mjs` under supervisor next to the Go binary. nginx sends `/gfadmin`, `/_journal` and `/storage/app/**` to Go or disk, and everything else to Nuxt. Migration order: migrate → set pl default → import → fix category names and descriptions → copy media and featured images → smoke every old URL.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Post/category/tag data, drafts, publish gate | API / Backend (Go `sm-journal-plugin`) | Database (Postgres) | Frozen contract (Phase 15 D-14); the layer is read-only |
|
||||
| Public HTML for every URL (SSR) | Frontend Server (Nuxt Nitro node-server) | — | D-07: full HTML for SEO, swr cache |
|
||||
| Route paths (`/blog/:post`, `/:category`, `/autor/:author`) | Frontend Server (layer route module + app config) | — | D-06/D-15: URL shape is a site concern, so the Go API stays path-agnostic |
|
||||
| HTML gate on `content_html` | API (Go `FormatHTML` on admin/API save) | Frontend Server (layer TS port of the same 3 regexes) | Imported translations bypass `FormatHTML`; defense in depth (see Security) |
|
||||
| sitemap.xml, robots.txt, canonical/OG | Frontend Server (`@nuxtjs/seo`) | API (data source) | D-13 replaces eagle_seo + Golem15.Sitemap |
|
||||
| RSS | API (Go `/_journal/api/v1/rss`) | Frontend Server (`/rss.xml` alias via nginx rewrite or Nitro proxy) | Live pages advertise `/rss.xml`; Go owns the feed |
|
||||
| Uploaded files (featured images, media) | CDN / Static (nginx serving `storage/app/**` from disk) | API (Go writes `system_files`) | The Go host mounts no static handler (see Pitfall 7) |
|
||||
| Admin `/gfadmin` | API / Backend (cabana + boardwalk SPA) | — | D-09 |
|
||||
| Homepage, kontakt, FAQ, menu, category pillar copy | Frontend Server (site app content) | — | D-11/D-12; A3 for pillar content |
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- Lean planning: few large plans; **present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files** (rule 2). [VERIFIED: summercms.go/CLAUDE.md]
|
||||
- **Unit tests are always the last plan of the phase**; earlier plans may carry smoke tests only (rule 3).
|
||||
- Go: standard library first; add a dependency only when the research doc or a phase decision names it; `go vet` and `go test ./...` green at every commit in every Go repo touched (`sm-grzybyfunkcjonalne-app`, and `sm-journal-plugin` only if touched).
|
||||
- Compiled plugins only; no runtime plugin loading.
|
||||
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
|
||||
- Framework/plugin/layer READMEs never name a consuming application. The `vue-journal-layer` README uses `blog`/`acme` and never "grzybyfunkcjonalne". `modules/*` README updates apply only if `summercms.go` is touched (not planned).
|
||||
- Core plugin contracts (user, blog, pages, payment) must not break; `sm-journal-plugin` API contract is frozen (Phase 15 D-14, CONTEXT "Out of scope").
|
||||
- Submodule workflow (`.planning/notes/core-plugins-own-repos.md`): commit inside the submodule and push first, then bump the gitlink in the app repo as a separate commit; never stage submodule files from the app repo.
|
||||
- Global user rule: no co-authored tags in commits.
|
||||
|
||||
## URL Inventory (D-15)
|
||||
|
||||
Sources: theme page files [VERIFIED: Read `themes/arsha/pages/*.htm` lines 1-3], Winter router sort [VERIFIED: `vendor/winter/storm/src/Router/Router.php:250-264` "Sorts all the routing rules by static segments, then dynamic"], live probes with curl on 2026-10-09 [VERIFIED: live HTTP], live `sitemap.xml` (71 `<loc>`: `/`, `/blog`, 7 category roots, 62 posts) [VERIFIED: live HTTP].
|
||||
|
||||
**Note:** the live server does not run the code in the local PHP repo. It serves theme `pkurg-arsha` (asset paths `/themes/pkurg-arsha/...`), `/kontakt` returns 404 live, and an unknown `/:x` renders a page titled "Kategoria". Its page config matches `sources/grzybyfunkcjonalne.pl-old-golem/themes/arsha` (Golem15.Blog components `blogPosts`/`blogPost`). The URL patterns are identical in both trees. The table uses live status as the ground truth.
|
||||
|
||||
| PHP page file | `url =` (verbatim) | Components / behavior | Live status (2026-10-09) | Nuxt successor (route name → path) |
|
||||
|---|---|---|---|---|
|
||||
| `category.htm` | `url = "/"` | homepage partial; `journalPostsHomepage` postsPerPage 3; `g15_faq` slug `what-mushrooms-can-give-you` | 200 | app page `index` → `/` |
|
||||
| `blog.htm` | `url = "/blog"` | `journalPosts` postsPerPage 12, sort `published_at desc` | 200; `/blog?page=2` 200 (title "Artykuły \| Strona 2", canonical keeps `?page=2`); `/blog?page=99` 200 empty; `/blog?page=abc` 200 | layer `journal-index` → `/blog`, `?page=N` |
|
||||
| `blog-post.htm` | `url = "/blog/:post"` | `journalPost` + `randomPosts` (3, `random`) | 200 for pl slug; 404 for base slug, unknown and unpublished | layer `journal-post` → `/blog/:slug` |
|
||||
| `index.htm` | `url = "/:category"` | category filter postsPerPage 10; **no category → homepage partial, status 200** (soft 404) | `/reishi` 200, `/reishi?page=2` 200; `/nieistnieje`, `/rss`, `/feed`, `/rss.xml` all 200 (soft) | layer `journal-category` → `/:category`; **404 when the slug is not a category** |
|
||||
| `autor.htm` | `url = "/autor/:author"` | `journalAuthor` + `journalPosts` (10) | `/autor/admin` 200 ("Admin Person"), canonical broken (`/autor/`); `/author/admin` 404 | layer `journal-author` → `/autor/:author` (see Open Question 1) |
|
||||
| `kontakt.htm` | `url = "/kontakt"` | `content-kontakt` (2 headings) | **404 live** | app page `kontakt` → `/kontakt` (D-14 contact details) |
|
||||
| `trwaja-prace.htm` | `url = "/trwaja-prace"` | `<h3>Niebawem</h3>` | 200 | app page `trwaja-prace` → `/trwaja-prace` (keep; static) |
|
||||
| `404.htm` | `url = "/404"` | error hero | 404 | Nuxt `error.vue` (no explicit route; `/404` falls to 404) |
|
||||
| `error.htm` | `url = "/error"` | 500 hero | 404 live | Nuxt `error.vue` handles 500; `/error` falls to 404 as live |
|
||||
|
||||
**Non-page URLs served by PHP:**
|
||||
|
||||
| URL | Source | Live | Successor |
|
||||
|---|---|---|---|
|
||||
| `/sitemap.xml` | `plugins/golem15/sitemap/routes.php` (definition: url `/`, all-blog-categories, all-blog-posts) | 200 XML | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) with a dynamic source from the API |
|
||||
| `/sitemap.xsl` | same | 200 | dropped (cosmetic; the sitemap module ships its own XSL) [ASSUMED] |
|
||||
| `/robots.txt` | `plugins/golem15/seo/routes.php` | 200, body is the literal `Golem15` (broken) | `@nuxtjs/robots` (via `@nuxtjs/seo`) |
|
||||
| `/rss.xml` | `<link rel="alternate" ... href="/rss.xml">` in every live page head, but it is the soft-200 catch-all | 200 HTML (wrong) | **New:** nginx `location = /rss.xml` → Go `/_journal/api/v1/rss`, or a Nitro route proxy |
|
||||
| `/backend` | Winter admin | 200 | `/gfadmin` (D-09); `/backend` → 404 on the Go host (D-09 intentional) |
|
||||
| `/storage/app/uploads/public/**` | Winter uploads + `thumb_*` | 200 | nginx static from Go host disk |
|
||||
| `/storage/app/media/**` | media library (`las.webp`, `Artur Wolak.png` referenced by 32 post bodies) | 200 | nginx static from Go host disk |
|
||||
|
||||
**Internal link forms found on live pages** (smoke targets): `/blog?page=N`, `/<category>` (7), `/blog/<pl-slug>`. Broken links, which stay broken or get fixed but must not be reproduced: `/blog/<category>?page=N` (category pagination partial bug; live 404) and `/author/` (journalAuthor partial). [VERIFIED: live HTML link extraction; `partials/pagination.htm:1-12`]
|
||||
|
||||
**Routing rules for the successor:**
|
||||
- Static routes (`/blog`, `/kontakt`, `/trwaja-prace`) must beat `/:category`. Vue Router ranks by path specificity, and "The order of the `routes` array doesn't even matter!" [CITED: router.vuejs.org/guide/essentials/route-matching-syntax]. Static-over-param precedence itself is [ASSUMED] and is covered by the URL smoke test.
|
||||
- Category pagination successor: `/<category>?page=N` (live works). Do not generate `/blog/<category>?page=N`.
|
||||
- Pagination: `?page=N` query, not a segment. Recommend `page` > `last_page` → 404 and non-numeric → 404, both via `createError({ statusCode: 404, fatal: true })`. This differs from live (soft 200) but is SEO-correct (A5).
|
||||
- Canonical for paginated lists must keep `?page=N`, as live does. `nuxt-seo-utils` strips queries from canonical except a whitelist; `page` is believed to be in the default whitelist [ASSUMED]. The smoke test asserts it.
|
||||
|
||||
## Journal Export vs Importer (D-17)
|
||||
|
||||
Export: 105 files in `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/`. 63 are published and 42 are drafts. 100 have a `pl` translation. Keys in every file: `slug, title, content, content_html, excerpt, metadata, published, published_at, categories, translations`. [VERIFIED: python scan this session]
|
||||
|
||||
Importer struct [VERIFIED: sm-journal-plugin/console/export_posts.go:53-67], verbatim fields: `Slug`, `Title`, `Content`, `ContentHTML`, `Excerpt`, `Metadata`, `Sources`, `Published`, `PublishedAt`, `Categories []string`, `Translations map[string]map[string]any`, `AuthorEmail string json:"author_email,omitempty"`, `CategoryNames map[string]string json:"category_names,omitempty"`.
|
||||
|
||||
| Item | Export has | Importer does | Survives? | Gap / action |
|
||||
|---|---|---|---|---|
|
||||
| Slug (live URL) | base `slug` + `translations.pl.slug` (57 differ; live = pl) | host `slug` = base; then `SetTranslated(pl)`; writes host if pl is default (`if locale == defaultCode { ... tx.Model(m).Update(column, ...) }`) | **Only if pl is the default locale before import** | Seed default is `'en', 'English', TRUE` / `'pl', 'Polski', FALSE` [VERIFIED: 202610060005_seed_en_pl_locales.go:13-18]. Set pl default first (admin Locales `makeDefault`, or SQL). |
|
||||
| Title / excerpt / content / content_html | base (older text; 52 titles, 100 contents differ) + pl (live text) | same mechanism; `stripUnpublishedTitlePrefix` removes `🔒 ` | Yes with pl default | Without pl default the API serves stale base text |
|
||||
| content format | published posts: base = markdown, pl `content` = **HTML** (61/63) | `FormatHTML(post.Content)` on base, then pl `content_html` overwrites | Yes at import | **Admin re-save destroys body** (Pitfall 2) |
|
||||
| published / published_at | `"2024-06-24 06:00:00"` style, UTC (PHP `'timezone' => 'UTC'`) | `parsePublishedAt` accepts `"2006-01-02 15:04:05"` | Yes | none |
|
||||
| Categories | slug list only (`cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail`; `amanita-muscaria` only on drafts) | creates missing category with `titleFromSlug` → "Lions Mane" (live "Lion's Mane") | Slugs yes; names partly; **no description, no pillar content** | Fix names and descriptions in `/gfadmin` (7 rows) or a seed step; pillar content → app (A3) |
|
||||
| Authors | **absent** (no `author_email`) | `user_id` stays nil | **No** | Posts render without author; author page empty (Open Question 1) |
|
||||
| Tags | **absent** (PHP exporter has no tags) | none | n/a | Live site uses no tags; tag route can be disabled for this site |
|
||||
| Featured images | **absent** (PHP `ExportPosts.php` warns "File attachments ... are NOT included") | none | **No** | Separate migration step (below) |
|
||||
| Inline media refs | absolute `https://grzybyfunkcjonalne.pl/storage/app/media/Artur%20Wolak.png` (32 occurrences) and `https://gf.uat.golem15.com/storage/app/uploads/public/660/fc7/c2e/660fc7c2e04d0300676113.png` (2) | stored verbatim | Yes if nginx serves `/storage/app/media/` | Copy `storage/app/media/`; the 2 UAT-host refs are already broken (flag, do not fix in this phase) |
|
||||
| Internal links in bodies | `https://grzybyfunkcjonalne.pl/lions-mane`, `/turkey-tail` (2 each) | verbatim | Yes (category routes kept) | none |
|
||||
| Re-import idempotency | — | existence check `db.Where("slug = ?", data.Slug)` uses base slug | **Breaks with pl default**: host slug becomes the pl slug, so a `--force` re-run does not find the row, creates a new one, and then hits `golem15_journal_posts_slug_unique` on the pl slug and rolls back the whole transaction | Run import once into a fresh DB; to redo, drop and re-migrate (Pitfall 3) |
|
||||
| Post IDs | not exported | new IDs | n/a | Featured-image mapping must key on slug |
|
||||
| Per-post SEO overrides (`golem15_seo_pages`) | not exported | n/a | No | Live post `<title>` = post title + " \| Grzyby Funkcjonalne"; derive from post fields (A6) |
|
||||
|
||||
**Featured images: recommended path.** The source of truth is the PHP DB on the server (live `system_files` with `field = 'featured_images'`). The local `storage/app/uploads/public` is unreadable to this user and the local `gf_old.sql` is a 2024 Golem15.Blog dump. Two options:
|
||||
- **(a, recommended)** An app-local Go command in `sm-grzybyfunkcjonalne-app` (e.g. `grzyby:import-featured-images --map map.json --source <php uploads/public>`). It reads a slug → `{disk_name, file_name, content_type, file_size}` map produced by a one-off SQL query on the PHP DB, copies each original into the Go bucket under the same Winter partition key (`attach.BlobKey(diskName)` = `PartitionDirectory(diskName) + diskName`), and inserts an `attach.File` row (`AttachmentType` = `Golem15\Journal\Models\Post`, `AttachmentID` = Go post id as string, `Field` = `featured_images`). URLs keep their `/storage/app/uploads/public/xxx/yyy/zzz/<disk_name>` shape, so old image URLs keep working. Go code means Go tests (fits SC-4). [VERIFIED: attach/thumb.go:54-90; attach/file.go:26-45; host config/storage.yaml:3-4 `bucket_url: "file://./storage/app/uploads/public"`, `public_path_prefix: "/storage/app/uploads/public"`]
|
||||
- **(b)** A script that uploads each image through the existing write route `POST /_journal/api/v1/posts/{id}/featured-images` with a backend JWT. No new Go code, but new disk names (old image URLs die), and it needs an admin token.
|
||||
|
||||
Recommend (a). Either way the PHP-side query needs server access (human checkpoint).
|
||||
|
||||
## Journal API available to the layer (anonymous GET)
|
||||
|
||||
[VERIFIED: Read sm-journal-plugin/routes.go:13-20]: verbatim `g.Get("/posts", ...)`, `g.Get("/posts/{slug}", ...)`, `g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `g.Get("/categories", ...)`, `g.Get("/tags", ...)`, `g.Get("/rss", ...)`, group middleware `surf.Use("throttle:journal-public-api")`.
|
||||
|
||||
| Need (D-05) | API support | Evidence | Layer approach |
|
||||
|---|---|---|---|
|
||||
| List + pagination | `GET /posts?page=&per_page=&sort=&category=&tag=&search=`; envelope `{data, meta{current_page,last_page,per_page,total}}` | posts.go:41-42, 101-112; lagoon/paginate.go:4-15 | Pass `page`, `per_page`; read `meta.last_page` |
|
||||
| Page size | `defaultPerPage = 9`, `maxPerPage = 30` | posts.go:23-26 | grzyby sizes 12/10/3 fit |
|
||||
| Sort | columns `published_at`, `created_at`, `updated_at`, `title`, `id` + `asc`/desc; **no `random`** (PHP API.md lists `random`) | posts.go:469-486 | Configurable sort among those values |
|
||||
| Single post | `GET /posts/{slug}` → `{data: {...post, previous_post, next_post, related_posts}}`; 404 `errPostNotFound` for draft/future | posts.go:119-153 | `related_posts` = `randomPosts` successor |
|
||||
| Related posts | same category, `Order("published_at DESC").Limit(3)`; **empty when the post has no category** (1 published post has none) | posts_helpers.go:133-153 | Fallback: latest 3 excluding current |
|
||||
| Post fields | `id, title, slug, excerpt, content, content_html, reading_time, published, published_at, is_pinned, metadata, sources, author, categories[{id,name,slug}], tags[{id,name,slug}], featured_images[{id,url,file_name,file_size}], featured_image_url, translations, created_at, updated_at` | posts.go:547-568 | Render `content_html` (HTML; markdown is not re-rendered client-side) |
|
||||
| Dates | `"2006-01-02T15:04:05+00:00"` UTC | wire/response.go:41-43 | Format with `Intl.DateTimeFormat('pl-PL')` |
|
||||
| Category listing | `GET /categories[?include_empty=1]` tree `{id,name,slug,description,parent_id,nest_depth,post_count,children?}`; **empty categories omitted by default** | posts_helpers.go:155-216 | Always pass `include_empty=1` when resolving `/:category` (`amanita-muscaria` has 0 published posts but is in the live sitemap) |
|
||||
| Category filter | `category=<slug or id>` matches the **host** slug column, includes nested children | posts.go:65-71, 495-516 | OK with pl default |
|
||||
| Tags | `GET /tags` `{id,name,slug,post_count}`; `tag=` filter | posts.go:176-190, 518-528 | Layer tag route; disabled for grzyby (no tags) |
|
||||
| Author page | **No endpoint.** `author=` filter only for editors (posts.go:58-64). Author object `{id, first_name, last_name, login}` (posts.go:576-581): no slug/bio/avatar/subtitle | — | **Gap: Open Question 1** |
|
||||
| RSS | `GET /rss` RSS 2.0; item link `site + "/blog/" + p.Slug`; `site` = config `url`, else `scheme://Host` with `scheme := "http"` unless TLS | rss.go:74, 122-133 | Set `SUMMER_URL=https://<host>` on the Go process or RSS links come out `http://` behind nginx (Pitfall 6) |
|
||||
| Rate limit | `journal-public-api` 120/min keyed on `surf.ClientIP`; `127.0.0.1/32` is a trusted proxy in host `config/http.yaml:15-16` | plugin.go:69-82; surf/clientip.go:16-46 | SSR fetches must forward `X-Forwarded-For` or all visitors share one 120/min bucket (Pitfall 5) |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (layer and site app; versions pinned as in `vue-summercmsio-app/package.json`)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| nuxt | 4.4.8 (published 2026-06-08) | SSR framework, layers, Nitro node-server | Project standard (D-01) [VERIFIED: vue-summercmsio-app/package.json; npm registry] |
|
||||
| vue | 3.5.35 | UI runtime | Pinned with Nuxt in the summercms.io app |
|
||||
| @nuxtjs/i18n | 10.4.0 | pl/en UI strings, `prefix_except_default` | D-16 [VERIFIED: package.json] |
|
||||
| @nuxtjs/seo | 5.2.1 | bundles `@nuxtjs/sitemap` 8.6.1, `@nuxtjs/robots` 6.2.4, `nuxt-seo-utils` 8.6.1, `nuxt-schema-org`, `nuxt-og-image`, `nuxt-link-checker`, `nuxt-site-config` | D-13 [VERIFIED: node_modules/@nuxtjs/seo/package.json dependencies] |
|
||||
| @nuxt/fonts | 0.14.0 | self-hosted fonts (grzyby used Open Sans + Jost + Poppins from Google) | D-01 |
|
||||
| typescript | 5.9.3 (dev) | types | Pinned |
|
||||
| Node | 22.22.0 (`.nvmrc`), `engines >=22.18` | runs `node --test` on `.ts` and the Nitro server | [VERIFIED: vue-summercmsio-app/.nvmrc, package.json] |
|
||||
| pnpm | 11.3.0 locally; lockfile v9 | install, git dependency | [VERIFIED: `pnpm --version`] |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `nuxt/kit` (re-export of `@nuxt/kit`, ships inside `nuxt`) | 4.4.8 | `defineNuxtModule`, `extendPages`, `createResolver`, `addServerHandler` in the layer's local module | Always (no new dependency) [VERIFIED: node_modules/nuxt/kit.d.ts `export * from '@nuxt/kit'`; @nuxt/kit index.d.mts:301 `declare function extendPages(...)`] |
|
||||
| `sanitize-html` | 2.17.7 (pin; avoid 2.18.0, 9 days old) | full HTML sanitizer | **Only if** the user rejects the "port Go's 3-regex gate" recommendation (A4). `[ASSUMED]` name source: training knowledge. [WARNING: flagged as suspicious (`too-new` on latest) — verify before using.] |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Page components + `extendPages` from configurable paths | Layer `app/pages/blog/[slug].vue` overridden by app files | Overriding by filename cannot move `/blog/:post` to `/posts/:post` without copying page files, which fails D-06 |
|
||||
| Direct `$fetch` to Go (internal URL on the server, same-origin in the browser) | A Nitro BFF (`server/api/journal/**`) | A BFF adds a hop and caching layer; swr page caching already covers load; the BFF stays an option if sanitization moves server-only |
|
||||
| nginx serving `/storage/app/**` from disk | Mount `attach.StaticHandlerPublic` in an app-local plugin | The handler exists (attach/static.go:26-36) but no host mounts it; nginx static is the Winter precedent and needs no Go code |
|
||||
| `@nuxt/image` for resizing | Use the original `featured_image_url` with `loading="lazy"` and explicit sizes | Go has no public thumb route; `@nuxt/image` is a new dependency; defer |
|
||||
|
||||
**Installation (layer `package.json`):** dependencies `@nuxtjs/i18n@10.4.0`, `@nuxtjs/seo@5.2.1`, `@nuxt/fonts@0.14.0`; devDependencies `nuxt@4.4.8`, `vue@3.5.35`, `typescript@5.9.3`. Per the Nuxt docs, anything the layer imports must be in its `dependencies`, and `nuxt` itself goes in `devDependencies` [CITED: nuxt.com/docs/4.x/guide/going-further/layers].
|
||||
|
||||
**Site app `package.json`:**
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"nuxt": "4.4.8",
|
||||
"vue": "3.5.35",
|
||||
"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
Do not list `@nuxtjs/i18n`/`@nuxtjs/seo` in the app at different versions; if listed, pin them identically to the layer (Pitfall 4).
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age (pinned version) | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| nuxt | npm | 4.4.8: 4 mo | 2.3M/wk | github.com/nuxt/nuxt | SUS (`too-new`, refers to latest 4.6.0) | Approved: pinned 4.4.8 is already locked in `vue-summercmsio-app/pnpm-lock.yaml` |
|
||||
| @nuxtjs/i18n | npm | 10.4.0 | 735k/wk | github.com/nuxt-modules/i18n | OK | Approved |
|
||||
| @nuxtjs/seo | npm | 5.2.1 | 120k/wk | github.com/harlan-zw/nuxt-seo | SUS (`too-new` on latest) | Approved: 5.2.1 already locked in the summercms.io app |
|
||||
| @nuxt/fonts | npm | 0.14.0 | (in use) | github.com/nuxt/fonts | not re-run | Approved: already locked in the summercms.io app |
|
||||
| sanitize-html | npm | 2.17.7: 2 mo | 11.6M/wk | github.com/apostrophecms/apostrophe | SUS (`too-new` on 2.18.0) | **Conditional**: only if A4 rejected; planner adds `checkpoint:human-verify` before install |
|
||||
|
||||
Postinstall: `sanitize-html` `postinstall: null` [VERIFIED: seam signals]. No new Go dependencies are proposed.
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** `sanitize-html` (conditional); nuxt/@nuxtjs/seo SUS flags apply to latest tags, not the pinned versions already in the lockfile.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Browser ──HTTPS──> nginx (staging vhost on the PHP server)
|
||||
│
|
||||
├─ /gfadmin/**, /_journal/** ──> Go binary (grzybyfunkcjonalne serve, 127.0.0.1:PORT_GO)
|
||||
│ ├─ cabana admin SPA (/gfadmin)
|
||||
│ └─ Journal API ──> Postgres
|
||||
├─ = /rss.xml (rewrite) ──> Go /_journal/api/v1/rss
|
||||
├─ /storage/app/uploads/public/**,
|
||||
│ /storage/app/media/** ──> disk (Go host storage/app/...), images only, nosniff
|
||||
└─ everything else ──> Nuxt Nitro (node .output/server/index.mjs, 127.0.0.1:PORT_NUXT)
|
||||
├─ routeRules swr cache hit? ──yes──> cached HTML
|
||||
└─ miss: SSR page (layer route module decides page by path)
|
||||
├─ $fetch ${journal.apiBase} (internal http://127.0.0.1:PORT_GO/_journal/api/v1,
|
||||
│ forwards X-Forwarded-For) ──> Go Journal API
|
||||
├─ 404 from API / unknown category / page out of range ──> error.vue (404)
|
||||
└─ HTML + payload (content_html after TS html gate)
|
||||
Browser hydration / client navigation ──> same-origin /_journal/api/v1 (nginx ──> Go)
|
||||
/sitemap.xml ──> Nuxt @nuxtjs/sitemap ──> layer server source ──> Go /posts (paged) + /categories?include_empty=1
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
vue-journal-layer/ # new repo; neutral, never names a site
|
||||
├── nuxt.config.ts # modules: ['@nuxtjs/i18n','@nuxtjs/seo', resolve('./modules/journal')]
|
||||
│ # i18n locales pl/en (langDir via import.meta.url-resolved path), css tokens
|
||||
├── modules/journal.ts # configKey 'journal': routes, perPage, apiBase; extendPages + sitemap source
|
||||
├── app/
|
||||
│ ├── app.config.ts # UI defaults (date format, related count, show author/reading time)
|
||||
│ ├── assets/css/journal-tokens.css # --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*
|
||||
│ ├── assets/css/journal-base.css # neutral styles using only tokens
|
||||
│ ├── components/Journal/ # PostCard, PostList, Pagination, PostBody, PostMeta, FeaturedImage,
|
||||
│ │ # RelatedPosts, CategoryHeader, AuthorCard, RssLink (overridable by name)
|
||||
│ ├── composables/ # useJournalApi (typed client), useJournalLinks (route-name based URLs), useJournalPage
|
||||
│ ├── journal-pages/ # NOT app/pages: PostIndexPage, PostPage, CategoryPage, TagPage, AuthorPage
|
||||
│ └── utils/ # pure TS, node --test friendly: types.ts, client.ts, query.ts,
|
||||
│ # pagination.ts, htmlGate.ts, routes.ts (path config validation)
|
||||
├── server/api/__sitemap__/journal.ts # defineSitemapEventHandler -> posts + categories
|
||||
├── i18n/locales/{pl,en}.json # journal.* UI strings
|
||||
├── tests/*.test.ts # node --test
|
||||
└── README.md # neutral (acme/blog)
|
||||
|
||||
sm-grzybyfunkcjonalne-app/ # existing Go host
|
||||
├── config/backend.yaml # uri: /gfadmin
|
||||
├── vue-grzybyfunkcjonalne-app/ # new submodule (D-08)
|
||||
│ ├── nuxt.config.ts # extends ['vue-journal-layer'], journal.routes, routeRules, site, i18n pl default
|
||||
│ ├── app/assets/css/tokens.css # grzyby token overrides (refreshed palette: #0D705D/#27AE60/#138C74 family)
|
||||
│ ├── app/pages/{index,kontakt,trwaja-prace}.vue
|
||||
│ ├── app/components/ # Hero, About, Cta, Services, Faq, SiteHeader(menu), SiteFooter, Journal/* overrides
|
||||
│ ├── app/data/ # menu.ts (typed), faq.pl.json/md, categories/<slug>.md (pillar content, A3)
|
||||
│ ├── app/error.vue
|
||||
│ ├── i18n/locales/pl.json # site copy
|
||||
│ ├── public/ # las.webp, cta/blog images, logo, favicon
|
||||
│ └── tests/*.test.ts
|
||||
├── deploy/{nginx,supervisor,env.example}
|
||||
└── scripts/{build.sh,smoke.sh,check-deploy.sh,urls.txt}
|
||||
```
|
||||
|
||||
### Pattern 1: Configurable routes via a layer module (D-06)
|
||||
**What:** Page components live outside `app/pages`. A local module registers them with `extendPages`, using paths from the `journal.routes` config that the app overrides (defu merge, project wins). Links are built by route **name**, so changing a path never breaks links.
|
||||
**When:** Always, for every Journal view.
|
||||
**i18n compatibility:** Nuxt 4.4.8 defaults `experimental.scanPageMeta` to `"after-resolve"` [VERIFIED: @nuxt/schema@4.4.8 dist/index.mjs:618-620 `return typeof val === "boolean" || val === "after-resolve" ? val : "after-resolve";`]. @nuxtjs/i18n 10.4.0 then localizes in `pages:resolved` [VERIFIED: @nuxtjs/i18n dist/module.mjs:682-683 `nuxt.options.experimental.scanPageMeta === "after-resolve" ? "pages:resolved" : "pages:extend"`], which runs after every `pages:extend`, so routes added by the module are localized whatever the module order. Route names gain a `___pl` suffix, so links must go through `useLocalePath()`/`useLocaleRoute()` [ASSUMED: suffix format].
|
||||
```ts
|
||||
// vue-journal-layer/modules/journal.ts (sketch; option names are recommendations)
|
||||
import { defineNuxtModule, createResolver, extendPages } from 'nuxt/kit'
|
||||
|
||||
export interface JournalRoutes {
|
||||
index: string | false // e.g. '/blog'
|
||||
post: string | false // e.g. '/blog/:slug'
|
||||
category: string | false // e.g. '/blog/category/:category' (neutral default)
|
||||
tag: string | false // e.g. '/blog/tag/:tag'
|
||||
author: string | false // e.g. '/blog/author/:author'
|
||||
}
|
||||
|
||||
export default defineNuxtModule<{ routes: JournalRoutes }>({
|
||||
meta: { name: 'journal', configKey: 'journal' },
|
||||
defaults: {
|
||||
routes: {
|
||||
index: '/blog', post: '/blog/:slug', category: '/blog/category/:category',
|
||||
tag: '/blog/tag/:tag', author: '/blog/author/:author',
|
||||
},
|
||||
},
|
||||
setup(options) {
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
const pages: Array<[keyof JournalRoutes, string]> = [
|
||||
['index', 'PostIndexPage'], ['post', 'PostPage'], ['category', 'CategoryPage'],
|
||||
['tag', 'TagPage'], ['author', 'AuthorPage'],
|
||||
]
|
||||
extendPages((routes) => {
|
||||
for (const [key, file] of pages) {
|
||||
const path = options.routes[key]
|
||||
if (path === false) continue
|
||||
routes.push({ name: `journal-${key}`, path, file: resolve(`../app/journal-pages/${file}.vue`) })
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
```ts
|
||||
// vue-grzybyfunkcjonalne-app/nuxt.config.ts (excerpt)
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-journal-layer'],
|
||||
journal: {
|
||||
routes: { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false },
|
||||
},
|
||||
routeRules: {
|
||||
'/**': { swr: 300 },
|
||||
'/blog/**': { swr: 120 },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 2: Typed client with an internal server base and XFF forwarding
|
||||
**What:** `runtimeConfig.journal.apiBase` (server-only, e.g. `http://127.0.0.1:8096/_journal/api/v1`, overridable with `NUXT_JOURNAL_API_BASE`) and `runtimeConfig.public.journal.apiBase` (default `/_journal/api/v1`, same origin). On the server, forward the visitor's `x-forwarded-for` (Go trusts `127.0.0.1/32` and reads the rightmost untrusted XFF hop). Runtime config is overridden by `NUXT_`-prefixed env vars only for keys declared in `nuxt.config` [CITED: nuxt.com/docs/4.x/guide/going-further/runtime-config].
|
||||
```ts
|
||||
// app/composables/useJournalApi.ts (sketch)
|
||||
export function useJournalApi() {
|
||||
const cfg = useRuntimeConfig()
|
||||
const base = import.meta.server ? cfg.journal.apiBase : cfg.public.journal.apiBase
|
||||
const fwd = import.meta.server ? useRequestHeaders(['x-forwarded-for']) : {}
|
||||
return <T>(path: string, query?: Record<string, unknown>) =>
|
||||
$fetch<T>(path, { baseURL: base, query, headers: fwd })
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Category route as the root catch-all with a real 404
|
||||
**What:** The category page resolves `:category` against `GET /categories?include_empty=1` (flattened tree), throws `createError({ statusCode: 404, fatal: true })` when absent, then lists `GET /posts?category=<slug>&per_page=10&page=N`. The site app injects pillar content per slug (A3) through a slot or override component.
|
||||
**Why:** Live soft-200s every unknown root path (`/rss`, `/feed`, `/nieistnieje` render a "Kategoria" page). The successor must not.
|
||||
|
||||
### Pattern 4: Token theming (D-03)
|
||||
The layer defines every visual value as `--journal-*` custom properties in `:root` and uses only those tokens. The app overrides them in its own CSS, which loads after the layer CSS because the project's `css` array is appended after the layer's (defu array merge) [ASSUMED: CSS order; verify in the Wave 0 spike]. To be safe, scope app overrides with a selector of equal or higher specificity (`:root:root`) or put them on `html`.
|
||||
|
||||
### Pattern 5: Sitemap source from the API (D-13)
|
||||
```ts
|
||||
// vue-journal-layer/server/api/__sitemap__/journal.ts (sketch)
|
||||
export default defineSitemapEventHandler(async () => {
|
||||
// page through /posts?per_page=30 until meta.last_page; add categories from /categories?include_empty=1
|
||||
// return [{ loc: '/blog/<slug>', lastmod: post.updated_at }, { loc: '/<category>' }, ...]
|
||||
})
|
||||
```
|
||||
with `sitemap: { sources: ['/api/__sitemap__/journal'] }`. Each entry needs `loc`; `lastmod/changefreq/priority/_sitemap` are optional. The resolved sitemap is cached `cacheMaxAgeSeconds` (default 10 min) in production and not cached in dev [CITED: nuxtseo.com/docs/sitemap/guides/dynamic-urls]. Build each `loc` from the route config (the same `journal.routes`), not hard-coded `/blog/`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Layer pages in `app/pages/`:** they get fixed paths and collide with the app's routes, which breaks D-06.
|
||||
- **`~/` or `@/` imports inside layer files:** these resolve to the consuming project. Use relative imports or a `#layers/<name>` alias with `$meta.name` [CITED: nuxt.com layers doc].
|
||||
- **Relative paths in the layer `nuxt.config` (css, langDir):** they resolve against the project. Build them from `fileURLToPath(new URL('.', import.meta.url))` [CITED: nuxt.com layers doc].
|
||||
- **Calling Go's public URL from SSR:** it hairpins through nginx/TLS. Use the internal base.
|
||||
- **`isr` route rules on node-server:** CDN-only semantics (Netlify/Vercel); use `swr: <seconds>` [CITED: nuxt.com/docs/4.x/guide/concepts/rendering].
|
||||
- **Hard-coding `/blog/` in the layer:** RSS in Go hard-codes it (fine for this site), but the layer must use route names.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| sitemap.xml | XML writer | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) dynamic source | chunking, lastmod, i18n, caching |
|
||||
| robots.txt / meta robots | static file | `@nuxtjs/robots` + `useRobotsRule`/route rules | per-route `noindex` (D-13 `robots_noindex`) |
|
||||
| canonical/OG/twitter tags | manual `useHead` | `useSeoMeta` + `nuxt-seo-utils` site config | absolute URLs from `site.url` |
|
||||
| SSR response cache | custom LRU | `routeRules` `swr` (Nitro cache) | built-in; per-route TTL |
|
||||
| Route localization | manual prefixing | `@nuxtjs/i18n` `prefix_except_default` | future locales (D-16) |
|
||||
| Image partition paths | custom path math | `attach.PartitionDirectory`/`BlobKey` (Go) | Winter-exact layout [VERIFIED: attach/thumb.go:54-69] |
|
||||
| Admin auth for any write | new token scheme | existing cabana backend JWT | Phase 15 D-15 |
|
||||
| Font hosting | Google Fonts `<link>` | `@nuxt/fonts` | privacy, same as summercms.io |
|
||||
|
||||
**Key insight:** The only new logic this phase owns is the URL mapping, the content port and the migration. Everything SEO- or cache-related is a configured module.
|
||||
|
||||
## Runtime State Inventory (content migration)
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | PHP DB on the server (posts, translations, `system_files` featured images, categories with pillar `content`); local `gf_old.sql` is a 2024 Golem15.Blog dump (7 categories with 4-14 KB `content`, FAQ with 16 entries, sitemanager settings) | Data migration: import posts (pl default first); featured-image migration (needs server SQL); category names/descriptions; pillar content → app files (A3) |
|
||||
| Live service config | Live sitemanager settings (hero/about/cta/services/footer copy) live in the PHP DB, not in git; repo `content/blocks/*` are `*.en.htm` arsha demo copy | Copy the **current** Polish copy from the live HTML (authority) into app i18n/markdown; `gf_old.sql` is a fallback |
|
||||
| OS-registered state | Staging: new supervisor programs (Go + Nuxt), new nginx vhost; the PHP site's own nginx/php-fpm stay untouched | New config files only (D-10) |
|
||||
| Secrets/env vars | `SUMMER_DATABASE__DSN`, `SUMMER_APP__KEY`, `SUMMER_ADMIN__JWT__SECRET`, `SUMMER_GOLEM15__USER__JWT__SECRET` (pattern from summercms.io `deploy/env.example`), plus `SUMMER_URL` (RSS origin) and `NUXT_JOURNAL_API_BASE` | New `.env` on the server; never committed |
|
||||
| Build artifacts | `.output/` (Nuxt server bundle) and `bin/grzybyfunkcjonalne`; Google Analytics `G-MLSV7WJ6W4` in the live layout | Decide analytics successor (Open Question 5) |
|
||||
|
||||
## Content Inventory (D-11, D-12)
|
||||
|
||||
Sizes measured on live HTML and on `gf_old.sql` sitemanager settings [VERIFIED: this session]:
|
||||
|
||||
| Block | Live? | Source | Size |
|
||||
|---|---|---|---|
|
||||
| Hero | yes | title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam", bg `/las.webp` (full-size, darken 50%) | tiny |
|
||||
| Latest posts (3) + "Czytaj więcej" → `/blog` | yes | Journal | layer component |
|
||||
| About ("Czym są grzyby funkcjonalne?") | yes | 2 rich-text columns (574 + 780 chars), button "Poznaj naszą misję" | ~1.2 KB text |
|
||||
| CTA ("Skorzystaj z wiedzy ekspertów") | yes | text + image `65f9f2e96287d085855960.png` | tiny |
|
||||
| Services ("Dlaczego warto?") | yes | 4 icon boxes (~400 chars each) | ~1.6 KB |
|
||||
| FAQ (`what-mushrooms-can-give-you`) | yes | intro + 16 Q&A (HTML answers, 200-700 chars) | ~5.7 KB text |
|
||||
| Footer | yes | name, address "Wróblewskiego, 1A/1U, Namysłów", phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, links (Artykuły blogowe; Golem15, Ciemność.com, Dobra bomba), social (x.com/ophiel118), copyright | small |
|
||||
| Menu (`g15_menu` main) | yes | "Blog" → `/blog`; dropdown "Grzyby Funkcjonalne" listing the 7 categories (type `blog_categories`) | typed config; category list can come from `/categories?include_empty=1` |
|
||||
| Blog page header | yes | hero with blog image (`65f9f2e96287d085855960.png`), title/description from page meta ("Artykuły", "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych") | tiny |
|
||||
| Category pillar content | yes | 6 categories × 12-14 KB HTML (amanita empty) + 7 short descriptions (53-85 chars) | **~80 KB**: largest content item |
|
||||
| Kontakt | live 404 | repo: "Kontakt" / "Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych" + footer details | small |
|
||||
| Disabled blocks (clients, portfolio, pricing, team, whyus, newsletter, contact) | no | arsha demo copy | **drop** |
|
||||
|
||||
Theme visual baseline: Bootstrap 5 arsha, fonts Open Sans/Jost/Poppins. Colours from sitemanager: main `#000000`, secondary `#0D705D`, accent `#27AE60`, text `#444444`, link `#138C74`, link hover `#1AAC8F`, background `#FFFFFF` [VERIFIED: gf_old.sql sitemanager JSON; may differ from live]. The refresh means a new design using these as seeds, not a Bootstrap port. A UI-SPEC (`/msd-ui-phase`) before planning is recommended.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Importing with `en` as the default locale
|
||||
**What goes wrong:** Every live post URL (`/blog/blog...`) 404s; the API serves stale base titles and markdown.
|
||||
**Why:** The seeded default is `en` (`SELECT 'en', 'English', TRUE, TRUE, 1`). `GET /posts/{slug}` uses `q.Where("slug = ?", slug)` on the host column only.
|
||||
**How to avoid:** After `migrate`, set `pl` as default (admin Settings → Translate → Locales → make default, or `UPDATE golem15_translate_locales SET is_default = (code = 'pl')`), **then** import. Optionally disable `en`.
|
||||
**Warning signs:** `GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem` → 404 after import.
|
||||
|
||||
### Pitfall 2: Editing an imported post in `/gfadmin` wipes its body
|
||||
**What goes wrong:** `content_html` becomes `<!-- raw HTML omitted -->`.
|
||||
**Why:** The pl `content` is HTML. `FormatHTML` uses goldmark without `html.WithUnsafe()` [VERIFIED: format_html.go:15-18]. Falsification probe run this session on `10-faktow-na-temat-cordycepsu.json` pl content:
|
||||
```
|
||||
zz_probe_test.go:20: pl HTML content -> err=<nil> out="<!-- raw HTML omitted -->\n"
|
||||
```
|
||||
**How to avoid in this phase:** Document "do not edit imported posts in the admin until fixed". Record a follow-up todo against `sm-journal-plugin` (convert HTML content to markdown at import, or pass raw HTML through); do not patch it here (D-17).
|
||||
**Warning signs:** Post page shows an empty body after an admin save.
|
||||
|
||||
### Pitfall 3: `journal:import-posts --force` is not idempotent once pl is default
|
||||
**What goes wrong:** The second run looks up the base slug, misses the row (host now has the pl slug), creates a duplicate, then violates `golem15_journal_posts_slug_unique` and the whole transaction rolls back.
|
||||
**How to avoid:** Import once into a freshly migrated DB. To redo: drop the database, `migrate`, set pl default, import again. Put this in the runbook.
|
||||
|
||||
### Pitfall 4: Duplicate Nuxt modules from the layer and the app
|
||||
**What goes wrong:** Two `@nuxtjs/i18n` copies at different versions, so locale state is split or the build warns.
|
||||
**How to avoid:** Modules are declared once, in the layer `nuxt.config`. The app either omits them or pins the same exact versions. Keep the lazy-load locale format identical across layers ("Mixing locale configuration such as lazy loading objects and strings may not work as expected") [CITED: i18n.nuxtjs.org/docs/guide/layers].
|
||||
|
||||
### Pitfall 5: All SSR traffic shares one rate-limit bucket
|
||||
**What goes wrong:** Cache misses from many visitors all come from 127.0.0.1, exceed 120/min, and Go answers 429. Pages then error.
|
||||
**How to avoid:** Forward `x-forwarded-for` from the incoming request in the SSR fetch (Pattern 2); keep swr TTLs ≥ 60 s; the sitemap source pages at `per_page=30`.
|
||||
|
||||
### Pitfall 6: RSS links come out `http://` and with the wrong host
|
||||
**Why:** `requestOrigin` uses config `url`, else `"http"` + `r.Host` (TLS is terminated at nginx) [VERIFIED: rss.go:122-133].
|
||||
**How to avoid:** Set `SUMMER_URL=https://<staging host>` in the Go `.env` (compass maps `SUMMER_URL` to the top-level `url` key [VERIFIED: compass README line 96 mapping rule]), and `proxy_set_header Host $host`.
|
||||
|
||||
### Pitfall 7: Nothing serves `/storage/app/**`
|
||||
**Why:** The framework has `attach.StaticHandler`/`StaticHandlerPublic` but no host mounts them [VERIFIED: grep usage; attach/static.go:26-36]. The static.go comment warns: "Do not mount the ungated handler on the app origin — same-origin Content-Type from uploads is XSS-relevant."
|
||||
**How to avoid:** nginx `location ^~ /storage/app/uploads/public/` and `/storage/app/media/` aliasing the Go host's storage dirs. Allow only image extensions (`jpe?g|png|gif|webp`), set `add_header X-Content-Type-Options nosniff`, `expires 30d`.
|
||||
|
||||
### Pitfall 8: Category pages for empty categories 404
|
||||
**Why:** `/categories` omits categories with 0 published posts unless `include_empty=1`. `amanita-muscaria` (in the live sitemap) has 0 published posts.
|
||||
**How to avoid:** Always `include_empty=1` when resolving a slug and building the sitemap.
|
||||
|
||||
### Pitfall 9: Layer CSS/i18n paths resolved against the app
|
||||
**How to avoid:** In the layer config use `const currentDir = dirname(fileURLToPath(import.meta.url))` and absolute `css`/`langDir` paths [CITED: nuxt.com layers doc]. Test by building the site app against the layer installed from git, not only via `link:`.
|
||||
|
||||
### Pitfall 10: pnpm git dependency from Gitea
|
||||
**What goes wrong:** pnpm cannot resolve a tag on a non-GitHub host, the lockfile pins a branch, or a `prepare` script never runs.
|
||||
**How to avoid:** The layer ships source and has no `prepare`/build script ("A Git-hosted dependency that ships sources rather than a built package has to be prepared": avoid needing it) [CITED: pnpm.io/package-sources]. Use the full form `git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0`. Wave 0 spike: install it in a scratch app and run `nuxt build` [ASSUMED: works against Gitea 1.25.4 over anonymous HTTPS].
|
||||
|
||||
### Pitfall 11: Repos do not exist yet
|
||||
`https://git.golem15.com/golem15/vue-journal-layer` and `/vue-grzybyfunkcjonalne-app` return 404 [VERIFIED: curl this session]. Creating them on Gitea is a human step (credentials); plan a checkpoint.
|
||||
|
||||
### Pitfall 12: Node and Postgres availability on the PHP server
|
||||
The PHP server runs Winter (likely MySQL/MariaDB per `gf_old.sql`). The Go host needs Postgres (database.yaml asks for a pl-PL ICU database), and Nuxt needs Node ≥ 22.18. Neither is verified on that server (Open Question 4). The ICU template note in host `config/database.yaml`: `CREATE DATABASE grzybyfunkcjonalne TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL';` requires Postgres ≥ 15.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### nginx staging vhost (sketch; adapt from `sm-summercmsio-app/deploy/nginx/summercms.io.conf`)
|
||||
```nginx
|
||||
# Go: admin, Journal API, RSS alias
|
||||
location ^~ /gfadmin { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
|
||||
location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
|
||||
location = /rss.xml { proxy_pass http://127.0.0.1:8096/_journal/api/v1/rss; include /etc/nginx/proxy_params; }
|
||||
location ^~ /backend { return 404; } # D-09: old admin path is not served by the new stack
|
||||
|
||||
# Uploads and media from disk, images only
|
||||
location ~ ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ {
|
||||
root /srv/grzybyfunkcjonalne-go; # holds storage/app/...
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
expires 30d;
|
||||
}
|
||||
location ^~ /storage/ { return 404; }
|
||||
|
||||
# Everything else: Nuxt SSR
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3016;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
Ports and paths are placeholders [ASSUMED]. `proxy_params` may not exist on the server; inline the headers as summercms.io does.
|
||||
|
||||
### supervisor: two programs (adapt `deploy/supervisor/summercms-io.conf`)
|
||||
```ini
|
||||
[program:grzyby-go]
|
||||
command=/srv/grzybyfunkcjonalne-go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096
|
||||
directory=/srv/grzybyfunkcjonalne-go
|
||||
environment=SUMMER_ENV="production"
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=15
|
||||
|
||||
[program:grzyby-nuxt]
|
||||
command=/usr/bin/node /srv/grzybyfunkcjonalne-go/nuxt/.output/server/index.mjs
|
||||
directory=/srv/grzybyfunkcjonalne-go/nuxt
|
||||
environment=NODE_ENV="production",NITRO_HOST="127.0.0.1",NITRO_PORT="3016",NUXT_JOURNAL_API_BASE="http://127.0.0.1:8096/_journal/api/v1"
|
||||
stopsignal=TERM
|
||||
```
|
||||
`NODE_ENV=production node .output/server/index.mjs`; `NITRO_PORT`/`PORT` and `NITRO_HOST`/`HOST` (default 0.0.0.0, so bind to loopback explicitly) [CITED: nuxt.com/docs/4.x/getting-started/deployment].
|
||||
|
||||
### HTML gate (TS port of Journal's rules, layer `utils/htmlGate.ts`)
|
||||
```ts
|
||||
// Same three patterns as sm-journal-plugin classes/format_html.go:20-22 (verbatim regex sources)
|
||||
const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
const EVENT_HANDLER = /\son[a-z]+\s*=/i
|
||||
const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
|
||||
export function isSafeJournalHtml(html: string): boolean {
|
||||
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(html))
|
||||
}
|
||||
```
|
||||
The scan this session found 0 matches in all export `content`/`content_html` (base and pl), so the live corpus passes [VERIFIED]. Note that `DANGEROUS_URL` also matches the plain-text Polish word "data:" ("date:"); an unsafe result should render a neutral notice, not throw.
|
||||
|
||||
### Migration runbook (order matters)
|
||||
1. `bin/grzybyfunkcjonalne migrate` (fresh Postgres DB, pl-PL ICU).
|
||||
2. Create the admin user (cabana runtime command) and log in at `/gfadmin`.
|
||||
3. Make `pl` the default locale (Locales → make default) [Pitfall 1].
|
||||
4. `bin/grzybyfunkcjonalne journal:import-posts --path journal-export --dry-run`, then without `--dry-run` (once).
|
||||
5. Fix the 7 categories (names e.g. "Lion's Mane", descriptions from live hero text).
|
||||
6. rsync PHP `storage/app/media/` → Go `storage/app/media/`; run the featured-image migration (option a/b).
|
||||
7. Smoke every URL in `scripts/urls.txt` (live sitemap's 71 + `/kontakt`, `/trwaja-prace`, `/rss.xml`, `/robots.txt`, `/sitemap.xml`, `/blog?page=2..6`, `/<cat>?page=2`, negative cases).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Winter CMS pages + Twig components | Nuxt 4 SSR layer + JSON API | this phase | URL logic moves to Nuxt |
|
||||
| eagle_seo + Golem15.Sitemap | `@nuxtjs/seo` 5 (sitemap 8, robots 6) | D-13 | runtime sitemap from API |
|
||||
| `isr` for "rebuild-free" pages | `swr` on node-server (`isr` = CDN-only) | Nuxt 3+ hybrid rendering | use `swr` TTLs |
|
||||
| Pages in layer `pages/` | `extendPages` from module config | — | configurable paths |
|
||||
|
||||
**Deprecated/outdated:** sitemanager `enable_*` toggles (dropped, D-11); arsha demo blocks (drop); `/backend` path (→ `/gfadmin`).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | pnpm 11 installs `git+https://git.golem15.com/...git#tag` from Gitea anonymously and Nuxt `extends: ['vue-journal-layer']` resolves it from node_modules | Stack, Pitfall 10 | Distribution model (D-04) needs a fallback (tarball URL / `link:` / submodule); Wave 0 spike |
|
||||
| A2 | Vue Router ranks static `/blog`, `/kontakt` above `/:category` | URL Inventory | Static pages shadowed; caught by smoke test |
|
||||
| A3 | Category pillar content and descriptions belong in the site app (not a Journal API change) | Summary, Content | If the user wants them editable in admin, an `sm-journal-plugin` change is needed (breaks frozen-API scope) |
|
||||
| A4 | Porting Go's 3-regex gate is enough sanitization (no `sanitize-html`) | Security, Code Examples | If stricter sanitizing is wanted, add `sanitize-html` 2.17.7 behind a checkpoint |
|
||||
| A5 | Out-of-range/non-numeric `?page=` → 404 (live returns 200) | URL Inventory | Minor SEO/UX difference; confirm |
|
||||
| A6 | Post `<title>` = `<post title> \| Grzyby Funkcjonalne`, description = excerpt, og:image = featured image (no per-post SEO override migration) | Export table | Lost custom SEO titles if eagle_seo overrides exist for posts |
|
||||
| A7 | `nuxt-seo-utils` keeps `page` in canonical query whitelist by default | URL Inventory | Paginated canonicals collapse to page 1; set the whitelist explicitly |
|
||||
| A8 | Project CSS loads after layer CSS so token overrides win | Pattern 4 | Tokens not overridden; use higher-specificity selector |
|
||||
| A9 | i18n route-name suffix is `___pl` | Pattern 1 | Links by name fail; use `localePath` regardless |
|
||||
| A10 | `/sitemap.xsl` can be dropped | URL Inventory | Cosmetic only |
|
||||
| A11 | nginx/supervisor ports and paths in examples | Code Examples | Placeholders; fix on server |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All six were resolved by the user on 2026-10-09 and recorded in 16-CONTEXT.md. The decisions below override the original recommendations.
|
||||
|
||||
1. **Author page (D-05) has no API support.**
|
||||
- What we know: no author endpoint; the `author` filter is editor-only; the author payload has no slug/bio/avatar; imported posts have no author; `/autor/admin` is the only live author URL, unlinked and not in the sitemap.
|
||||
- Options: (a) layer author page backed by a Nitro route that pages `/posts` and filters by `author.login`, with bio/avatar from app config (no Go change); (b) additive `sm-journal-plugin` change (anonymous `author=<slug>` filter + `author.slug` in payload), which reopens the frozen API, so it needs the user's explicit OK; (c) ship the route but 404 until authors exist, keeping `/autor/:author` reserved.
|
||||
- Recommendation: (a) for the layer, plus assigning authors in `/gfadmin` after import if the user wants author pages populated.
|
||||
- **RESOLVED → D-20:** option (b). A pre-execution `/msd-quick` task adds an anonymous author endpoint to `sm-journal-plugin`; the layer consumes it. The recommendation (a) above is superseded.
|
||||
2. **Category pillar content location** (A3): app markdown per slug (recommended) or a Journal schema change?
|
||||
- **RESOLVED → D-21:** Journal schema change. A pre-execution quick task adds a rich content field to Category (admin + categories API); plan 16-03 fills it from the PHP DB. The app-markdown recommendation is superseded.
|
||||
3. **Featured images**: who runs the SQL on the PHP DB to produce the slug → disk_name map, and are live images under `Golem15\Blog\Models\Post` or `Golem15\Journal\Models\Post` morphs on the server? Option (a) Go command vs (b) API upload.
|
||||
- **RESOLVED → D-22:** option (a), an app-local Go command that keeps the Winter disk names, fed by a SQL export run on the PHP server.
|
||||
4. **Server environment**: hostname, staging domain (e.g. `staging.grzybyfunkcjonalne.pl`?) and TLS, Postgres ≥15 with ICU, Node ≥22.18, supervisor and nginx layout on the PHP server. These must be confirmed before the deploy plan.
|
||||
- **RESOLVED → D-19 / plan 16-04:** confirmed at the 16-04 Task 1 human checkpoint before deploy.
|
||||
5. **Analytics**: the live layout loads GA `G-MLSV7WJ6W4`. Carry it over (needs a consent decision), or drop it on staging?
|
||||
- **RESOLVED → D-23:** carry over `G-MLSV7WJ6W4` through Nuxt runtime config; staging may leave it empty.
|
||||
6. **Draft posts and `amanita-muscaria`**: 42 drafts are imported as drafts; `/amanita-muscaria` shows 0 posts live. Keep it (it is in the sitemap) or drop it?
|
||||
- **RESOLVED → D-24:** keep `/amanita-muscaria`; the layer always requests categories with `include_empty=1`. Drafts are imported as drafts.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available (this machine) | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | host build/tests | ✓ | go1.27.0 | — |
|
||||
| Node | Nuxt build, `node --test` | ✓ | v24.21.0 (project pins 22.22.0 via `.nvmrc`) | nvm 22 |
|
||||
| pnpm | install, git dep | ✓ | 11.3.0 | — |
|
||||
| Postgres | Go host, import rehearsal | ✓ (local, accepting on 127.0.0.1:5432) | — | — |
|
||||
| Docker | `scripts/smoke.sh` pattern (postgres:15 container) | ✗ (permission denied on docker.sock for this user) | 29.7.2 client | local Postgres via `SMOKE_DSN`, or run as a user in the docker group |
|
||||
| nginx | `check-deploy.sh` (`nginx -t` with temp prefix) | ✓ | — | — |
|
||||
| supervisord | config parse check | ✓ | — | — |
|
||||
| git.golem15.com | layer git dep, repos | ✓ reachable (Gitea 1.25.4); target repos 404 | — | human creates repos |
|
||||
| PHP server (Node, Postgres, nginx, supervisor) | staging | unknown | — | **blocking until confirmed (OQ4)** |
|
||||
| PHP storage `storage/app/uploads/public` | featured images | ✗ locally (permission denied) | — | copy on the server |
|
||||
|
||||
**Missing dependencies with no fallback:** confirmed server environment (OQ4); Gitea repos (human creation).
|
||||
**Missing dependencies with fallback:** Docker for smoke (use local Postgres DSN).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (layer, site app) | `node --test` on `.ts` (Node ≥22.18 type stripping), as `vue-summercmsio-app` (`"test": "node --test --test-reporter=tap tests/*.test.ts"`) |
|
||||
| Framework (Go host) | `go test ./...` (stdlib testing; existing `boot_test.go` `TestBootUserTranslateJournal`, `TestCORS`) |
|
||||
| Config file | none (package.json script); Go none |
|
||||
| Quick run command | `pnpm test` (layer or app dir); `go test ./...` (host) |
|
||||
| Full suite command | `pnpm -C vue-journal-layer test && pnpm -C vue-grzybyfunkcjonalne-app build && pnpm -C vue-grzybyfunkcjonalne-app test && go vet ./... && go test ./...` |
|
||||
| Smoke | `scripts/smoke.sh <base-url>` over `scripts/urls.txt` (expected status per URL) |
|
||||
|
||||
Keep testable logic in pure TS under `app/utils/` (no `.vue` imports), because `node --test` cannot load SFCs. Component rendering is checked through the built server (`.output/server/index.mjs` started against a fixture API), the same idea as summercms.io's `tests/output.test.ts` but for SSR. Vitest + `@nuxt/test-utils` would be a new dependency; avoid it unless the user asks.
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-2 | route config: defaults, overrides, `false` disables, invalid path rejected | unit | `node --test tests/routes.test.ts` (layer) | ❌ Wave 0 |
|
||||
| SC-2 | client builds query (`page`,`per_page` clamp ≤30, `category`, `include_empty`), parses envelope | unit | `node --test tests/client.test.ts` | ❌ |
|
||||
| SC-2 | pagination math: page validity, prev/next, 404 on out-of-range | unit | `node --test tests/pagination.test.ts` | ❌ |
|
||||
| SC-2 | HTML gate: same 3 patterns as Go | unit | `node --test tests/htmlGate.test.ts` | ❌ |
|
||||
| SC-3 | sitemap entries from posts+categories use route config | unit | `node --test tests/sitemap.test.ts` | ❌ |
|
||||
| SC-3 | every old URL → expected status (200/404), canonical keeps `?page=N`, `/rss.xml` is RSS | smoke | `scripts/smoke.sh https://<staging>` | ❌ |
|
||||
| SC-1 | host boots with `backend.uri: /gfadmin` (admin at `/gfadmin`, `/backend` 404) | unit/boot | `go test ./... -run TestBoot` | ✅ extend `boot_test.go` |
|
||||
| SC-1 | featured-image migration command (if option a): map parse, blob key, row fields, idempotent skip | unit | `go test ./... -run FeaturedImages` | ❌ |
|
||||
| SC-1 | deploy configs: `nginx -t`, supervisor parse | config check | `scripts/check-deploy.sh` | ❌ (adapt) |
|
||||
| SC-1 | built Nuxt server renders `/`, `/blog`, a post, a category against a fixture API | integration | `node --test tests/ssr.test.ts` (app; starts `.output/server/index.mjs`) | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `pnpm test` in the touched JS repo; `go vet ./... && go test ./...` in the host.
|
||||
- **Per wave merge:** full suite above plus `nuxt build` of the site app against the layer **installed from the git tag**.
|
||||
- **Phase gate:** full suite green plus `scripts/smoke.sh` against staging before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (A1, Pitfall 10).
|
||||
- [ ] `vue-journal-layer/tests/` and `package.json` test script.
|
||||
- [ ] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests.
|
||||
- [ ] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | no new surface | existing cabana backend JWT for `/gfadmin` |
|
||||
| V3 Session Management | no new surface | cabana cookie; `backend.cookie_secure` must stay `true` in production |
|
||||
| V4 Access Control | yes (deploy) | nginx: only `GET/HEAD` to Nuxt and public storage; `/storage/**` limited to image extensions |
|
||||
| V5 Input Validation | yes | `?page`/`:category`/`:slug` validated in the layer (numeric page; slug regex `[a-z0-9][a-z0-9\-/]*` as the Go route) |
|
||||
| V6 Cryptography | no | — |
|
||||
| V14 Config | yes | secrets only in server `.env`; `NUXT_` runtime keys declared in config |
|
||||
|
||||
### Known Threat Patterns
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS via `content_html` rendered with `v-html` | Tampering | Go `FormatHTML` gate on admin/API save; layer re-check with the same regexes; imported corpus scanned clean |
|
||||
| Same-origin XSS via uploaded files served with sniffed types | Tampering | nginx image-extension allowlist + `nosniff` (attach/static.go warning) |
|
||||
| SSRF via configurable API base | Tampering | `journal.apiBase` from server config only, never from request input |
|
||||
| Rate-limit bypass or self-DoS through the SSR proxy | DoS | forward XFF to Go (127.0.0.1 trusted); swr caching |
|
||||
| Admin exposure | Elevation | `/gfadmin` behind cabana auth; `/backend` returns 404 |
|
||||
|
||||
No new Go endpoints or auth surface are planned, so the security-review agent is not required (CONTEXT discretion). If option (b) of Open Question 1 is chosen (an API change), it is.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo reads: `sm-journal-plugin/routes.go`, `controllers/api/{posts.go,posts_helpers.go,rss.go}`, `console/{import_posts.go,export_posts.go}`, `classes/format_html.go`, `README.md`; `sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go`, `classes/translatable.go`; `summercms.go/modules/{lagoon/paginate.go,lagoon/attach/*.go,cabana/README.md,compass/README.md,surf/clientip.go}`; `sm-grzybyfunkcjonalne-app/config/*.yaml`, `go.work`, `summer.yaml`; `sm-summercmsio-app/{deploy,scripts,vue-summercmsio-app}`
|
||||
- Legacy PHP: `grzybyfunkcjonalne.pl/themes/arsha/{pages,partials}`, `plugins/golem15/journal/{components,models,console}`, `plugins/golem15/{sitemap,seo}/routes.php`, `vendor/winter/storm/src/Router/Router.php`, `sources/gf_old.sql`, `journal-export/*.json`
|
||||
- Live site probes (curl, 2026-10-09): sitemap.xml, status codes, canonical tags, link forms, navbar, footer
|
||||
- Probe: `go test` run of `FormatHTML` on pl HTML content (output pasted)
|
||||
- Installed node_modules: Nuxt 4.4.8 schema (`scanPageMeta`), @nuxtjs/i18n 10.4.0 module (`pages:resolved`), `nuxt/kit` re-export
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- nuxt.com/docs/4.x/guide/going-further/layers (layer priority, deps, path pitfalls)
|
||||
- nuxt.com/docs/4.x/guide/concepts/rendering (routeRules, swr vs isr)
|
||||
- nuxt.com/docs/4.x/getting-started/deployment (node server, ports)
|
||||
- nuxt.com/docs/4.x/guide/going-further/runtime-config (`NUXT_` overrides)
|
||||
- i18n.nuxtjs.org/docs/guide/layers (locale/message merge)
|
||||
- nuxtseo.com/docs/sitemap/guides/dynamic-urls (dynamic sources, cache)
|
||||
- pnpm.io/package-sources (git URL forms, git-hosted builds, blockExoticSubdeps)
|
||||
- router.vuejs.org route matching (order independence)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Assumptions A1-A11 above
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- URL inventory / data gaps: HIGH (live probes + source reads + export scan)
|
||||
- Journal API shape: HIGH (source read)
|
||||
- Standard stack: HIGH (versions from the existing lockfile)
|
||||
- Layer mechanics over pnpm git: MEDIUM (docs + reasoning; spike required)
|
||||
- Deploy topology: MEDIUM (pattern from summercms.io; server unknown)
|
||||
- Pitfalls: HIGH for 1-3, 5-8 (verified in code/probe); MEDIUM for 4, 9-10
|
||||
|
||||
**Research date:** 2026-10-09
|
||||
**Valid until:** 2026-11-08 (live-site facts can drift sooner; re-pull `sitemap.xml` before the smoke list is frozen)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Phase 16: User Setup
|
||||
|
||||
**Status:** Complete (verified 2026-10-09 during 16-01)
|
||||
|
||||
## git.golem15.com (Gitea 1.25.4)
|
||||
|
||||
Why: D-02, D-04 and D-08. The layer and the site app each live in their own repository.
|
||||
|
||||
| Task | Location | State |
|
||||
|------|----------|-------|
|
||||
| Create the repo `golem15/vue-journal-layer` as **public**, default branch `main` | https://git.golem15.com/repo/create (owner golem15) | Done. Anonymous `git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git` lists `main`, `v0.0.1` and `v0.1.0`. |
|
||||
| Create the repo `golem15/vue-grzybyfunkcjonalne-app`, default branch `main` | https://git.golem15.com/repo/create (owner golem15) | Done, as **private** by the user's choice. Check it with `git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git` (SSH). |
|
||||
|
||||
The layer repo has to be public, because consumers install it anonymously over `git+https` (D-04). The site app is only mounted as a submodule over SSH (D-08), so it can stay private.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git
|
||||
git ls-remote git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
phase: "16"
|
||||
slug: "grzybyfunkcjonalne-pl-on-reusable-blog-views"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-09"
|
||||
---
|
||||
|
||||
# Phase 16 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | `node --test` on `.ts` (Node ≥22.18 type stripping) for `vue-journal-layer` and `vue-grzybyfunkcjonalne-app`; `go test` (stdlib) for `sm-grzybyfunkcjonalne-app` |
|
||||
| **Config file** | none — `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` (Wave 0 adds it to both JS repos); Go none |
|
||||
| **Quick run command** | `pnpm test` (in the touched JS repo); `go vet ./... && go test ./...` (host) |
|
||||
| **Full suite command** | `pnpm -C vue-journal-layer test && pnpm -C vue-grzybyfunkcjonalne-app build && pnpm -C vue-grzybyfunkcjonalne-app test && go vet ./... && go test ./...` |
|
||||
| **Smoke** | `scripts/smoke.sh <base-url>` over `scripts/urls.txt` (expected status per URL) |
|
||||
| **Estimated runtime** | ~90 seconds (dominated by `nuxt build`) |
|
||||
|
||||
Testable logic lives in pure TS under `app/utils/` (no `.vue` imports), because `node --test` cannot load SFCs. Rendering is checked through the built SSR server (`.output/server/index.mjs`) started against a fixture API. No Vitest / `@nuxt/test-utils` dependency.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `pnpm test` in the touched JS repo; `go vet ./... && go test ./...` in the host
|
||||
- **After every plan wave:** Run the full suite command, with the site app's `nuxt build` against the layer **installed from the git tag**
|
||||
- **Before `/msd-verify-work`:** Full suite must be green and `scripts/smoke.sh` must pass against staging
|
||||
- **Max feedback latency:** 90 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Seeded from RESEARCH.md § Validation Architecture; the planner binds task IDs.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| TBD | TBD | TBD | SC-2 | — | route config: defaults, overrides, `false` disables, invalid path rejected | unit | `node --test tests/routes.test.ts` (layer) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-2 | — | client builds query (`page`, `per_page` clamp ≤30, `category`, `include_empty`), parses envelope | unit | `node --test tests/client.test.ts` (layer) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-2 | — | pagination: page validity, prev/next, 404 on out-of-range | unit | `node --test tests/pagination.test.ts` (layer) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-2 | stored XSS | HTML gate rejects the same 3 patterns as Go `FormatHTML` | unit | `node --test tests/htmlGate.test.ts` (layer) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-3 | — | sitemap entries from posts + categories use route config | unit | `node --test tests/sitemap.test.ts` (layer) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-1 | admin exposure | host boots with `backend.uri: /gfadmin`; `/backend` 404 | unit/boot | `go test ./... -run TestBoot` (host) | ✅ extend `boot_test.go` | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-1 | — | featured-image migration: map parse, blob key, row fields, idempotent skip | unit | `go test ./... -run FeaturedImages` (host) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-1 | uploaded-file XSS | deploy configs: `nginx -t`, supervisor parse, storage extension allowlist | config check | `scripts/check-deploy.sh` (host) | ❌ adapt | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-1 | — | built Nuxt server renders `/`, `/blog`, a post, a category against fixture API | integration | `node --test tests/ssr.test.ts` (app) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-3 | — | every old URL → expected status; canonical keeps `?page=N`; `/rss.xml` is RSS | smoke | `scripts/smoke.sh https://<staging>` (host) | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (RESEARCH A1, Pitfall 10)
|
||||
- [ ] `vue-journal-layer/tests/` and `package.json` test script
|
||||
- [ ] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests
|
||||
- [ ] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Staging deploy on the PHP site's server (Go + Nuxt + imported posts) | SC-1, D-18 | Needs server access, DNS/TLS for the staging host | Deploy per `deploy/` README, then run `scripts/smoke.sh https://<staging>` |
|
||||
| Refreshed visual design matches intent | D-01, D-03 | Subjective visual review | Open staging `/`, `/blog`, a post, a category; compare with the live site |
|
||||
| Creating the Gitea repos `vue-journal-layer`, `vue-grzybyfunkcjonalne-app` | D-02, D-08 | External service account action | Create public repos on git.golem15.com |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 90s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,27 @@
|
||||
# API Coverage — Journal API (`/_journal/api/v1`, sm-journal-plugin)
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
The phase consumes the internal Journal API that Phase 15 shipped (frozen contract, Phase 15 D-14) plus the two pre-execution additions D-20 and D-21. The consumer is `vue-journal-layer` (16-01) through its typed client, the sitemap source and the `/rss.xml` route. Google Analytics (D-23) is a script tag configured through Nuxt runtime config, not an API integration, so it has no row here.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| posts list (GET posts: page, per_page, sort) | INTEGRATE | |
|
||||
| posts list filtered by category (GET posts?category=) | INTEGRATE | |
|
||||
| posts list filtered by tag (GET posts?tag=) | INTEGRATE | |
|
||||
| post by slug (GET posts/{slug}) | INTEGRATE | |
|
||||
| post previous_post / next_post | INTEGRATE | |
|
||||
| post related_posts | INTEGRATE | |
|
||||
| post featured_images / featured_image_url | INTEGRATE | |
|
||||
| categories tree with include_empty=1 (GET categories) | INTEGRATE | |
|
||||
| category rich content field (D-21, on categories rows) | INTEGRATE | |
|
||||
| tags (GET tags) | INTEGRATE | |
|
||||
| rss (GET rss, proxied at /rss.xml) | INTEGRATE | |
|
||||
| author profile by slug (D-20 anonymous author endpoint) | INTEGRATE | |
|
||||
| author posts listing (D-20) | INTEGRATE | |
|
||||
| posts full-text search (GET posts?search=) | OPT-OUT | explicitly out of scope: the PHP site has no search page or form (D-05 lists no search view); the client can add it without an API change |
|
||||
| posts pinned_first ordering (GET posts?pinned_first=) | OPT-OUT | not needed: every PHP listing sorts by published_at desc only (blog.htm, index.htm, autor.htm) |
|
||||
| editor draft preview on GET (Authorization Bearer) | OPT-OUT | explicitly out of scope: the public layer never sends credentials, so drafts stay 404 to visitors (Phase 15 JOURNAL-005) |
|
||||
| create / update / delete posts (POST, PUT, DELETE posts) | OPT-OUT | explicitly out of scope: backend-JWT admin and importer surface; the layer calls anonymous GET routes only (D-05) |
|
||||
| featured-image upload / delete (POST, DELETE posts/{id}/featured-images) | OPT-OUT | explicitly out of scope: featured images migrate through the app-local grzyby:import-featured-images command (D-22), not the HTTP write route |
|
||||
| media upload (POST media/upload) | OPT-OUT | explicitly out of scope: media files are copied from PHP storage by the migration runbook (D-17); uploads stay an admin action |
|
||||
@@ -0,0 +1,7 @@
|
||||
# Phase 16 deferred items
|
||||
|
||||
## From 16-02 (vue-grzybyfunkcjonalne-app)
|
||||
|
||||
- **vue-journal-layer: visitor address lost under Nitro swr route caching.** `routeRules` swr renders a page from a copy of the request that keeps only `varies` headers, so the layer's `useJournalApi` reads no `x-forwarded-for` and forwards only the Nuxt peer. Every SSR cache miss would then share one Go rate-limit bucket (RESEARCH Pitfall 5). The site app works around it with `server/plugins/client-address.ts` (records the address in `event.context` on the Nitro `request` hook) and an app-level `useJournalApi` override. Fix it in the layer (a layer Nitro plugin plus the composable reading `event.context`), release a new tag, bump the app and delete the app override. The layer's `/rss.xml` and sitemap source have the same gap on their cache misses (once per TTL).
|
||||
- **vue-journal-layer smoke tool sends no Accept header.** Undici's `fetch` sends `sec-fetch-mode: cors`, so Nuxt answers errors with JSON and an expectation such as `404 /404 contains=...` cannot see the error page. The app works around it with `server/plugins/html-errors.ts`, which treats page paths as HTML requests. An `Accept: text/html` default in `testing/smoke-ssr.mjs` would let other apps check error copy without such a plugin.
|
||||
- **`public/images/cta.png` is 5.5 MB** (1792×1024 PNG, kept byte for byte from the live site as the plan required). It is the CTA band and blog hero background. A WebP derivative (the hero photo is 0.47 MB as WebP) would cut page weight; decide before the production cutover.
|
||||
163
.planning/phases/17-blog-companions/17-CONTEXT.md
Normal file
163
.planning/phases/17-blog-companions/17-CONTEXT.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Phase 17: Blog companions - Context
|
||||
|
||||
**Gathered:** 2026-10-09
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Port `Golem15.Sitemap`, `Golem15.Menu` and `Golem15.Faq` as shared plugins in their own repos: `sm-sitemap-plugin`, `sm-menu-plugin` and `sm-faq-plugin` under `git.golem15.com/golem15/`. The phase also adds four things:
|
||||
|
||||
- **Framework (`summercms.go`):** a typed successor of the `pages.menuitem.listTypes`, `getTypeInfo` and `resolveItem` events, plus the cabana field and list features the three plugins' admin screens need.
|
||||
- **`sm-journal-plugin`:** registers its menu item types through the new registry, as an additive change.
|
||||
- **Nuxt:** two new layers, `vue-menu-layer` and `vue-faq-layer`.
|
||||
- **grzybyfunkcjonalne.pl:** the site moves its menu, FAQ and sitemap from app code to the plugins.
|
||||
|
||||
Out of scope: a pages plugin (Winter.Pages), the sitemanager and blocks plugins, and the production cutover.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Menu-item type registry (framework)
|
||||
- **D-01:** The successor of `pages.menuitem.*` is an **optional capability interface**. It follows the `pact.Has*` pattern and could be named something like `HasMenuItemTypes`. A plugin returns typed `MenuItemType` values that carry their own info (label, reference options, nesting support) and a `Resolve` method. A framework registry collects them at boot. There are no stringly festival events for list, info or resolve. — **Reversibility:** costly — Journal, menu, sitemap and every later content plugin (pages, blog) implement this contract.
|
||||
- **D-02:** **Headless URLs come from patterns** such as `/blog/:post` and `/:category`, resolved in three layers. Each type declares a default pattern. The host config can override the pattern per type, and that override is how grzyby keeps its P16 D-15 URLs. A menu item or sitemap item can override it once more. — **Reversibility:** costly — the config keys and item fields become a published plugin contract.
|
||||
- **D-03:** **Resolved items carry per-locale alternates.** `Resolve` returns title, URL, mtime and a locale→URL alternates map, built from translate-plugin slugs. Nested `items` are returned for types that expand, such as `all-journal-categories`. Sitemap hreflang and localized menus both read these alternates. A single-locale site gets a single entry. — **Reversibility:** costly — the shape of the resolve result.
|
||||
- **D-04:** **Journal registers its 5 PHP types under the PHP keys:** `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. The source is `Plugin.php` `extendWinterPagesPlugin` in the frozen Journal tree. This is an additive change to `sm-journal-plugin` and leaves its existing API untouched.
|
||||
- **D-05:** **Menu types are menu built-ins plus registry types.**
|
||||
- `url` (link) and `divider` stay menu built-ins.
|
||||
- Every content-backed type comes from the registry.
|
||||
- PHP `blog_categories` maps to Journal's `all-journal-categories`.
|
||||
- PHP `page` (Winter.Pages) is dropped.
|
||||
- The import or DB copy maps the stored type values to the new ones.
|
||||
- **Reversibility:** costly — stored type values in the menu tables.
|
||||
|
||||
### Admin editing (framework first)
|
||||
- **D-06:** Close the cabana gaps **in `summercms.go` this phase**, following the P15 markdown-field pattern: generic features with fail-loud schema validation, the README and `docs/` updated, the admin OpenAPI and generated TS types refreshed, and `dist/` committed in the same change. — **Reversibility:** costly — new field types in the typed admin schema that later plugins will use.
|
||||
- **D-07:** **`repeater` field type.** It edits a JSON list stored in a jsonable column, with a nested `form.fields`. FAQ `content` (question/answer) uses it, and so do the sitemap Definition items (`data`).
|
||||
- **D-08:** **A dropdown with options that depend on a sibling field** (Winter's `dependsOn`). Its options come from a provider keyed on the value of the sibling `type` field and are served through the registry's type info. The sitemap repeater rows and the menu entry form use this same type/reference binding, so the registry has exactly one admin binding.
|
||||
- **D-09:** **Drag-and-drop tree reorder page.** It succeeds Winter's ReorderController and works on any `parent_id` + `sort_order` model, writing both columns. Menu entries use it, scoped to one menu. It replaces the PHP reorder screen and the "Children" partial.
|
||||
- **D-10:** **`richeditor` field type (HTML WYSIWYG)** in cabana and the SPA, which folds in the todo `cabana-richeditor-field-type.md`. The server sanitizes on save, the way Journal's `classes.CheckHTML` does, and the field must not bypass that gate. FAQ `description` and the repeater `answer` use it, matching PHP. Journal's category `content` switches from `textarea` to `richeditor`, as that todo asks. This decision replaces an earlier choice of the markdown field: the markdown preview drops raw HTML, so it can't show the imported PHP FAQ HTML. — **Reversibility:** costly — a new field type plus a JS editor dependency in the admin SPA.
|
||||
|
||||
### Sitemap headless adaptation
|
||||
- **D-11:** **One definition per app.** The `theme` column stays so the schema is 1:1, filled with a fixed key. `/sitemap.xml` serves that single definition. The admin opens it directly, like a settings page, the same way PHP auto-created one definition per theme.
|
||||
- **D-12:** **Item types are URL plus registry types only.** Static Page, All Static Pages and the CMS Page field are dropped. In the user's words: "no static pages or cms pages, just URL items, we configure it like that in wintercms sitemap plugin on horoskopia etc". `updates_manifest_path` is ported 1:1 as an optional setting.
|
||||
- **D-13:** **Priority tiers stay, with configurable patterns.** `enable_priority_tiers` and the tier values port unchanged. The tier path patterns move from code to settings or host config. The PHP golem15.com patterns (`/uslugi`, `/realizacje`, legal pages and so on) become documented defaults or an example, not hardcoded behavior. A site configured the same way must produce the same output as PHP.
|
||||
- **D-14:** `sitemap.xml` and `sitemap.xsl` keep their PHP behavior:
|
||||
- the `frontend_url` rewrite of `<loc>`, of `xhtml:link href` and of the XSL href
|
||||
- `frontend_default_locale`
|
||||
- the 50,000 URL limit
|
||||
- XML escaping
|
||||
|
||||
The PHP `tests/security` cases (`XmlEscapingTest`, `MassAssignmentTest`) are a behavioral spec. The `golem15.sitemap.beforeAddItem` and `addItem` hooks become typed festival events (planner's call on shape).
|
||||
- **D-15:** **grzybyfunkcjonalne.pl switches to the plugin's sitemap.** nginx routes `/sitemap.xml` and `/sitemap.xsl` to Go, the Nuxt `@nuxtjs/seo` sitemap module is disabled in the app (or layer config), and editors manage the sitemap in `/gfadmin`. This reverses **P16 D-13** for the sitemap only; per-post meta, OG, canonical and robots stay on `@nuxtjs/seo`. — **Reversibility:** reversible — the nginx route and the Nuxt module toggle.
|
||||
|
||||
### Public APIs + Nuxt consumption
|
||||
- **D-16:** **Menu API: `GET /_menu/api/v1/menus/{code}`**, mirroring `/_journal/api/v1`, anonymous. It returns the published, nested tree with registry types already resolved: URLs built, expanding types expanded into child items, plus title, target, css_class, icon and code. The Nuxt side only renders, and computes the active state from the current route. — **Reversibility:** one-way — a published API that the layers and later sites consume.
|
||||
- **D-17:** **FAQ API: `GET /_faq/api/v1/faqs/{slug}?locale=pl`**, active rows only. It returns name, slug, description and content `[{question, answer}]`, with HTML sanitized server-side. The `locale_id` → translate `Locale` relation stays 1:1 (one FAQ row per locale), and the locale defaults to the app default. — **Reversibility:** one-way — published API.
|
||||
- **D-18:** **Sibling Nuxt layers `vue-menu-layer` and `vue-faq-layer`**, new repos under `git.golem15.com/golem15/`. They use the same distribution as `vue-journal-layer` (P16 D-02 to D-04: pnpm git dependency pinned to a tag, a neutral design driven by CSS tokens, components the app can override). Each one mirrors one plugin, so a site without a blog can use them without Journal. Each ships a typed client and render components. — **Reversibility:** costly — repo and distribution layout.
|
||||
- **D-19:** **grzyby data is copied from the PHP DB.** Tables keep their PHP names and columns, so rows for the menu, the FAQ (slug `what-mushrooms-can-give-you`) and the sitemap definition are copied from the live Winter DB as a scripted, ordered migration step, the same way P16 handles its import. Menu and sitemap type values are mapped (D-05). After the copy, the P16 typed menu config and the static FAQ content are removed from `vue-grzybyfunkcjonalne-app`, which then uses the new layers.
|
||||
|
||||
### Claude's Discretion
|
||||
- Go package, module and identifier names for the registry, and whether it lives in `pact` (interface) plus an existing or new module (registry), provided D-01 to D-03 hold and the module README and docs are updated.
|
||||
- Config key names for the per-type URL patterns and for the priority tier patterns.
|
||||
- The exact JSON field names in the menu and FAQ responses, provided they cover the fields listed in D-16 and D-17.
|
||||
- Which JS WYSIWYG editor the SPA uses for `richeditor`. It must be stdlib-first-compatible and add no Go dependency; if an npm dependency is needed, name it in research.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". The framework work (registry plus cabana features) likely lands first and gets a framework tag.
|
||||
- Whether the PHP sitemap `ImportCommand` JSON format is kept byte-compatible. Default: yes, port 1:1.
|
||||
- Admin permissions and navigation for the three plugins follow the PHP `Plugin.php` registrations.
|
||||
- Run the security-review agent: the phase adds public HTTP endpoints and an HTML-accepting field type.
|
||||
|
||||
### Folded Todos
|
||||
- `sitemap-plugin-port.md`: the Golem15.Sitemap 1:1 port, adapted per D-11 to D-15.
|
||||
- `menu-plugin-port.md`: the Golem15.Menu port with a public read API, per D-05, D-09 and D-16.
|
||||
- `faq-plugin-port.md`: the Golem15.Faq port with a read API by slug, per D-07, D-10 and D-17.
|
||||
- `cabana-richeditor-field-type.md`: the cabana `richeditor` field, plus switching the Journal category content to it (D-10).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Roadmap and decisions
|
||||
- `.planning/ROADMAP.md` § Phase 17: goal, repos, port sources, success criteria
|
||||
- `.planning/notes/core-plugins-own-repos.md`: the `sm-<name>-plugin` repo, module path, submodule and `go.work` layout, and the submodule commit workflow
|
||||
- `.planning/notes/core-plugin-port-inventory.md`: port order and plugin sizes
|
||||
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md`: D-02 to D-04 (layer distribution), D-09 (`/gfadmin`, nginx split), D-12 (menu and FAQ in the app for now), D-13 (sitemap via `@nuxtjs/seo`, reversed for the sitemap by D-15 here), D-15 (URLs), D-17 and D-25 (migration ordering)
|
||||
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-11 (the markdown-field framework-first pattern), D-14 (the `/_journal/api/v1` convention), D-21 to D-23 (local checkouts)
|
||||
|
||||
### Todos (folded)
|
||||
- `.planning/todos/pending/sitemap-plugin-port.md`
|
||||
- `.planning/todos/pending/menu-plugin-port.md`
|
||||
- `.planning/todos/pending/faq-plugin-port.md`
|
||||
- `.planning/todos/pending/cabana-richeditor-field-type.md`
|
||||
|
||||
### PHP port sources
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/`: `models/Definition.php` (generation, events, frontend rewrite), `classes/DefinitionItem.php`, `routes.php`, `models/settings/fields.yaml`, `console/ImportCommand.php`, `assets/sitemap.xsl`, `tests/security/*`, `README.md`
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/`: `models/{Menu,MenuEntry}.php`, `models/*/fields.yaml`, `updates/*` (schema), `components/Menu.php`, `controllers/menuentries/reorder_config.yaml`
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/`: `models/FAQ.php` (jsonable `content`, `locale` belongsTo), `models/faq/fields.yaml`, `updates/v1.0.1/*`
|
||||
- `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu/default.htm`, `g15_faq/` and `g15_faq_whyus/`: the rendered markup the layers replace
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php` `extendWinterPagesPlugin()`, plus `Category::getMenuTypeInfo/resolveMenuItem` and `Post::getMenuTypeInfo/resolveMenuItem`: the Journal type semantics (frozen tree, P15 D-01)
|
||||
|
||||
### Framework
|
||||
- `modules/pact/capabilities.go`: the `Has*` capability-interface pattern (D-01)
|
||||
- `modules/festival/README.md`: typed events for the sitemap add-item hooks (D-14)
|
||||
- `modules/cabana/README.md` and `modules/cabana/form_schema.go`: the field type set (`formFieldTypes`), widget and action contracts, fail-loud schema rules (D-06 to D-10)
|
||||
- `modules/cabana/field_markdown.go`: the precedent for adding a field type
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `pact` `Has*` capability interfaces (`HasRoutes`, `HasAdminControllers`, `HasSettings`, `HasLang`, …): the model for the registry interface and for the plugins' wiring.
|
||||
- `festival.Bus` (`Fire`, `Collect`, `UntilHandled`): for the sitemap `beforeAddItem`/`addItem` successors.
|
||||
- cabana `relation`, `dropdown`, `switch`, `fileupload`, `markdown` and `mltext` fields, `HasSettings` settings pages, and the `type: widget` plus action contract.
|
||||
- `sm-journal-plugin/classes/format_html.go` `CheckHTML`: the HTML sanitize gate that `richeditor` must keep.
|
||||
- `sm-translate-plugin/models/locale.go` `Locale`: the FAQ `locale_id` relation and the slug alternates for D-03.
|
||||
- `sm-journal-plugin/routes.go`: the `/_journal/api/v1` route, limiter and CORS pattern for `/_menu` and `/_faq`.
|
||||
- `vue-journal-layer`: the layer structure and token conventions to copy for `vue-menu-layer` and `vue-faq-layer`.
|
||||
|
||||
### Established Patterns
|
||||
- Framework first: a new cabana field type updates the module README, `docs/`, admin OpenAPI, generated TS types and the committed SPA `dist/` in one change (P15 D-11).
|
||||
- Plugins keep PHP table names, columns, permission codes and YAML, so the ports and DB copies stay 1:1.
|
||||
- Framework READMEs never name a consuming application.
|
||||
|
||||
### Integration Points
|
||||
- `sm-grzybyfunkcjonalne-app` (`/media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/`) mounts the three new plugins as submodules at `plugins/golem15/{sitemap,menu,faq}`, adds them to `go.work`, `go.mod` and `summer.yaml`, and adds the nginx routes for `/sitemap.xml`, `/sitemap.xsl`, `/_menu` and `/_faq`.
|
||||
- `vue-grzybyfunkcjonalne-app` extends `vue-menu-layer` and `vue-faq-layer`, and removes its P16 menu config, static FAQ and Nuxt sitemap module.
|
||||
- `sm-journal-plugin` implements the registry interface (D-04) and switches the category content field to `richeditor` (D-10).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Sitemap setup mirrors how the Winter sitemap plugin is configured on Horoskopia and the other PHP sites: URL items plus plugin types, with no CMS or static pages.
|
||||
- Dependency note: Phase 16 is planned but not yet executed. D-15 and D-19 change Phase 16 deliverables (the Nuxt sitemap, the app menu config, the static FAQ) after it lands, so Phase 17 plans must run after Phase 16 execution.
|
||||
- Concurrency note: during this discussion, another session was editing `modules/cabana` and `modules/lagoon` for quick task 261009-mys (backend user subtitle and avatar). Framework plans here touch `modules/cabana` too and should start from a clean tree.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Pages plugin (Winter.Pages successor) and its Static Page menu and sitemap types: dropped here per D-12. A future pages port would register through the D-01 registry.
|
||||
- Sitemap definitions keyed per host, for multi-domain apps.
|
||||
- FAQ index listing endpoint (`GET /faqs`).
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- `journal-author-profile-fields.md`: belongs to the Journal author API work and quick task 261009-mys, not this phase.
|
||||
- `nest-framework-packages-under-modules.md`: keyword match only; Phase 10.2 covers it.
|
||||
- The remaining matches (READMEs, scaffold, wristband, lagoon, benchmark, translate JSON editor): keyword matches only.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 17-blog-companions*
|
||||
*Context gathered: 2026-10-09*
|
||||
141
.planning/phases/17-blog-companions/17-DISCUSSION-LOG.md
Normal file
141
.planning/phases/17-blog-companions/17-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Phase 17: Blog companions - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Phase:** 17-blog-companions
|
||||
**Areas discussed:** Menu-item type registry, Admin editing gaps, Sitemap headless adaptation, Public APIs + Nuxt consumption
|
||||
|
||||
---
|
||||
|
||||
## Menu-item type registry
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Capability interface | Optional `Has*` interface returning typed MenuItemType with Info()/Resolve(); registry collects at boot | ✓ |
|
||||
| Typed festival events | Three typed events on the festival bus, the closest mirror of PHP | |
|
||||
| Both | Interface plus filter events | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Config default + item override | Type default pattern, host-config override per type, then per-item override | ✓ |
|
||||
| Per-item pattern only | Every item stores its own pattern | |
|
||||
| Host config only | No per-item override | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Built-ins + registry | url/divider built in; content types from the registry; blog_categories → all-journal-categories; page dropped | ✓ |
|
||||
| Registry only | Even url and divider registered | |
|
||||
| Keep PHP type values | Map at read time | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Yes, in the contract | Resolve returns locale→URL alternates | ✓ |
|
||||
| No, sitemap computes it | Prefixing as in PHP | |
|
||||
|
||||
---
|
||||
|
||||
## Admin editing gaps
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework first | Generic cabana repeater and tree reorder | ✓ |
|
||||
| Plugin widgets | Bespoke custom elements per plugin | |
|
||||
| Simplify screens | Relation managers, FAQ schema change | |
|
||||
|
||||
Rich text, first answer:
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Markdown field, keep HTML data | Reuse the P15 markdown field | ✓ (later revised) |
|
||||
| New richeditor field | WYSIWYG field in cabana | |
|
||||
| Convert HTML → markdown on import | Lossy risk | |
|
||||
|
||||
Rich text, revisited after finding that the markdown preview drops raw HTML (todo `cabana-richeditor-field-type.md`):
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Add richeditor this phase | Fold the todo; FAQ and the Journal category use it | ✓ |
|
||||
| Markdown + HTML→md on import | Convert during the DB copy | |
|
||||
| Textarea for now | Raw HTML textarea | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Generic repeater + dependent dropdown | One type/reference binding shared by sitemap and menu | ✓ |
|
||||
| Dedicated sitemap widget | Plugin custom element | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Drag-and-drop tree reorder | ReorderController successor with re-parenting | ✓ |
|
||||
| Flat reorder + parent dropdown | Sibling-only reorder | |
|
||||
| Nested repeater on Menu | Whole menu as one repeater | |
|
||||
|
||||
---
|
||||
|
||||
## Sitemap headless adaptation
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One definition per app | Keep the theme column with a fixed key | ✓ |
|
||||
| Keyed by site/host | Multi-domain | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Configurable pattern list | Tier patterns from settings/config | ✓ |
|
||||
| Port verbatim | Hardcoded golem15.com patterns | |
|
||||
| Drop tiers | Per-item priority only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep @nuxtjs/seo for grzyby | Plugin proven by golden tests only | |
|
||||
| Switch grzyby to the plugin | nginx routes the sitemap to Go; reverses P16 D-13 | ✓ |
|
||||
| Mount, decide at cutover | Both on staging | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| URL items + updates manifest | | |
|
||||
| URL items only | | |
|
||||
| App-registered type | | |
|
||||
|
||||
**User's choice:** free text: "no static pages or cms pages, just URL items, we configure it like that in wintercms sitemap plugin on horoskopia etc"
|
||||
|
||||
---
|
||||
|
||||
## Public APIs + Nuxt consumption
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Resolved tree by code | GET /_menu/api/v1/menus/{code}, server-resolved | ✓ |
|
||||
| Raw entries | Nuxt resolves types | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| By slug + locale | GET /_faq/api/v1/faqs/{slug}?locale= | ✓ |
|
||||
| By slug, locale via header | Accept-Language | |
|
||||
| List + by slug | Add an index endpoint | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Sibling layers | vue-menu-layer, vue-faq-layer | ✓ |
|
||||
| Inside vue-journal-layer | | |
|
||||
| One vue-companions-layer | | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Copy from PHP DB | Scripted copy with type mapping; remove the P16 app content | ✓ |
|
||||
| Seed from P16 app content | | |
|
||||
| Re-enter in admin | | |
|
||||
|
||||
**Notes:** The grzyby sitemap definition is copied in the same step (grzyby runs the PHP sitemap plugin).
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Registry package and identifier names, config key names, JSON field names, the WYSIWYG JS editor choice, plan split, the ImportCommand format (default: 1:1), permissions and navigation following PHP.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Pages plugin and the Static Page types; per-host sitemap definitions; the FAQ index endpoint.
|
||||
- Todos reviewed but not folded: `journal-author-profile-fields.md`, `nest-framework-packages-under-modules.md`, other keyword-only matches.
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
phase: quick-261009-i6e
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- ../sm-journal-plugin/controllers/api/authors_test.go
|
||||
- ../sm-journal-plugin/journal_api_authors_test.go
|
||||
- ../sm-journal-plugin/routes.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/todos/pending/journal-author-profile-fields.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261009-i6e]
|
||||
|
||||
estimate:
|
||||
tokens: 90000
|
||||
raw_tokens: 90000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-20/D-05: anonymous GET /_journal/api/v1/authors/{author} answers 200 with {\"data\":{\"id\",\"slug\",\"first_name\",\"last_name\"}}, exactly those four keys. {author} resolves first against backend_users.golem15_bloghub_author_slug, then against login, but the login match counts only when that user has at least one published post. Soft-deleted backend users never resolve. data.slug is the author slug when set, otherwise the login."
|
||||
- "D-20: an unknown author, a login without published posts, a soft-deleted user and a malformed value (empty after trim, over 255 bytes, invalid UTF-8 or containing a control character) all answer 404 with body {\"error\":\"Author not found\"}: the plugin's existing {error} shape, the same shape as Post not found."
|
||||
- "D-20: anonymous GET /_journal/api/v1/authors/{author}/posts answers 200 with the lagoon.Paginate envelope {data, meta{current_page,last_page,per_page,total}}. Each row is built by serializePost, the same row shape as GET /posts. Rows are only that author's published posts (published true, published_at set and in the past). page defaults to 1, per_page defaults to 9 and is clamped to 1..30, and sort uses the /posts allow-list (published_at desc by default). A Bearer token is ignored, so an editor also gets published posts only. An unresolvable author answers the same 404 as the profile route."
|
||||
- "The author payload never carries the backend user's contact address, credential hash, activation, persist or reset codes, permissions or role, and it omits login. The handler selects only id, first_name, last_name, login and golem15_bloghub_author_slug from backend_users."
|
||||
- "D-20 Reversibility costly (published API on a shared plugin): the change is additive only. Both routes sit in the existing throttle:journal-public-api group. controllers/api/posts.go, posts_helpers.go, auth.go and rss.go, go.mod and go.sum are unchanged, so every existing route and response shape stays the same."
|
||||
- "README.md documents both routes, the resolution rule, the payload fields, the query keys, the envelope and the 404 body, and names no consuming application. go vet ./... and go test ./... (with Docker) are green in sm-journal-plugin."
|
||||
artifacts:
|
||||
- path: ../sm-journal-plugin/controllers/api/authors.go
|
||||
provides: "AuthorShow and AuthorPosts handlers, resolveAuthor (slug, then login with a published post), validAuthorParam, the authorRow public-column projection and errAuthorNotFound"
|
||||
contains: "func AuthorPosts"
|
||||
- path: ../sm-journal-plugin/routes.go
|
||||
provides: "GET /authors/{author} and GET /authors/{author}/posts in the public throttled group"
|
||||
contains: "/authors/{author}/posts"
|
||||
- path: ../sm-journal-plugin/journal_api_authors_test.go
|
||||
provides: "Postgres integration tests for the profile route, the posts route, the 404 cases and the private-field absence"
|
||||
contains: "TestJournalAuthorPosts"
|
||||
- path: ../sm-journal-plugin/controllers/api/authors_test.go
|
||||
provides: "table test for validAuthorParam"
|
||||
contains: "TestValidAuthorParam"
|
||||
- path: ../sm-journal-plugin/README.md
|
||||
provides: "Public author API section and updated Public HTTP feature line"
|
||||
contains: "/authors/{author}"
|
||||
key_links:
|
||||
- from: ../sm-journal-plugin/routes.go
|
||||
to: ../sm-journal-plugin/controllers/api/authors.go
|
||||
via: "g.Get inside the r.Group(\"/_journal/api/v1\", surf.Use(\"throttle:journal-public-api\"), ...) block"
|
||||
pattern: "authors/\\{author\\}"
|
||||
- from: ../sm-journal-plugin/controllers/api/authors.go
|
||||
to: "backend_users.golem15_bloghub_author_slug (updates/202610060007_add_author_slug_to_backend_users.go)"
|
||||
via: "resolveAuthor slug lookup"
|
||||
pattern: "golem15_bloghub_author_slug"
|
||||
- from: ../sm-journal-plugin/controllers/api/authors.go
|
||||
to: ../sm-journal-plugin/controllers/api/posts.go
|
||||
via: "reuses applyPublished, clampPerPage, atoiDefault, listOrder and serializePost unchanged, plus lagoon.Paginate"
|
||||
pattern: "applyPublished\\("
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add an anonymous, read-only author API to the shared Journal plugin. It is the D-20 prerequisite that Phase 16 consumes for the author page (D-05, route `/autor/:author` per D-15). The plugin gets two GET routes: one returns an author's public profile, the other a paginated list of that author's published posts.
|
||||
|
||||
**Repository.** All code changes land in `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, a separate git repo (branch `main`) next to `summercms.go`. Commit the code there. Only planning documents (this plan, the SUMMARY and one todo) live in `summercms.go/.planning`.
|
||||
|
||||
Purpose: Phase 16 plan 16-01 checkpoints until `routes.go` has an author route. Its layer client then calls `getAuthor(slug)` and `listAuthorPosts(slug, {page, perPage})` against these routes. Plan 16-03's `migrate.sh --author-slug` writes `backend_users.golem15_bloghub_author_slug`, which this endpoint reads.
|
||||
|
||||
Output: two commits in sm-journal-plugin (`feat(api)` profile route, `feat(api)` author posts route, each with tests and README), one docs commit in summercms.go (follow-up todo), and the SUMMARY with an "Author endpoint contract" section for 16-01 Task 1.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@../sm-journal-plugin/README.md
|
||||
@../sm-journal-plugin/routes.go
|
||||
@../sm-journal-plugin/controllers/api/posts.go
|
||||
@../sm-journal-plugin/controllers/api/auth.go
|
||||
@../sm-journal-plugin/admin_harness_test.go
|
||||
@../sm-journal-plugin/journal_public_list_smoke_test.go
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- **Endpoint shape (chosen to match the 16-01 layer assumptions):** `GET /_journal/api/v1/authors/{author}` returns `{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`. `GET /_journal/api/v1/authors/{author}/posts` returns `lagoon.Paginate(rows, page, perPage, total)`, which is `{"data":[...],"meta":{"current_page","last_page","per_page","total"}}`, the same envelope as `GET /posts`. A miss is 404 `{"error":"Author not found"}` through the existing `writeAPIError`. Do not add an anonymous `author=` filter to `GET /posts`: that would change an existing route (D-20 says additive only).
|
||||
- **The author slug column already exists.** `updates/202610060007_add_author_slug_to_backend_users.go` adds `backend_users.golem15_bloghub_author_slug TEXT UNIQUE`. No migration is needed. The framework struct `cabana.BackendUser` has no field for it, so query `backend_users` through a plugin-local projection struct.
|
||||
- **PHP reference** (`grzybyfunkcjonalne.pl/plugins/golem15/journal/components/Author.php`, `journalAuthor`). It loads the author by `golem15_bloghub_author_slug`, then by `login`. It refuses the login when the user has a slug (canonical URL rule). It renders `first_name`, `last_name` and `metadata['subtitle']`, and lists that user's posts (10 per page, `published_at desc`). Its SEO add-on also reads `avatar` and a description.
|
||||
- **Two deliberate deviations from PHP, both to keep in the README:**
|
||||
1. A login resolves even when the user has a slug. The existing post `author` object is `{id, first_name, last_name, login}` and has no slug (posts.go serializeAuthor). Changing it would alter an existing response shape, so a consumer can only link by login. The response's `slug` names the canonical identifier, and the consumer sets the canonical URL from it.
|
||||
2. A login resolves only when that user has at least one published post. This stops anonymous callers from enumerating backend accounts: a published author's login is already public through `GET /posts`. A slug-only author, an explicit opt-in, resolves with zero posts (PHP renders an empty list).
|
||||
- **Profile fields.** The Go `backend_users` table (framework DDL in `summercms.go/modules/lagoon/backend_admin_migrations.go`) has `first_name`, `last_name` (both nullable TEXT) and `login`, but no `metadata` column and no avatar attachment. Subtitle, description and avatar therefore have no Go storage. The 16-03 import also creates its author with none of them. Exposing them needs a framework change to `backend_users`, which this quick task does not make: all code changes stay in the plugin. Task 3 records this as a todo. Never emit a key without a source.
|
||||
- **Routing.** `surf` `Where` attaches only to the last registered route, and a constraint mismatch answers with `http.NotFound` (plain text), not the plugin's JSON. Do not add a `Where` for `{author}`. Validate in the handler so every miss returns the JSON 404. `{author}` is one path segment, and the empty `/authors/` path never reaches the handler.
|
||||
- **Reusable helpers in package `api`** (posts.go, use them, never edit them): `gdb`, `writeAPIError`, `applyPublished`, `clampPerPage` (default 9, max 30), `atoiDefault`, `listOrder`, `serializePost`, and `wire.WriteJSON`. `lagoon.Paginate` comes from `summercms/modules/lagoon`. `postQuery` in posts_helpers.go preloads User, Categories and Tags.
|
||||
- **Test harness** (package `journal_test`): `newPostsEnv(t)` boots translate + journal on a dedicated testcontainers Postgres DB and runs migrations, including the author slug column. `env.call(t, method, path, token, body)` returns an httptest recorder. `env.admin(t, perms...)` creates a backend user with a role and returns a backend Bearer token. `phpErrorString(t, body)` returns the `error` string and the decoded map. `ptrTime` is defined. Permission constants: `controllers.PermissionAccessPosts` and `controllers.PermissionAccessOtherPosts`. Create posts with `env.gdb.Create(&models.Post{...})`, setting `UserID` to the author's id. Missing Docker fails the root package unless `-short`. This planning sandbox could not reach the Docker socket, so run the integration tests where Docker is reachable (outside the sandbox if needed). Never pass `-short` to the acceptance runs.
|
||||
- **Concurrent prerequisite.** The D-21 quick task (category content) also edits `README.md` in this repo. Work on top of the current HEAD and keep any D-21 README text intact.
|
||||
- **Repo hygiene.** `go.work.sum` is untracked in sm-journal-plugin: never stage it. In summercms.go, `.planning/state.json` and `.msd/` are dirty or untracked: never stage them. Stage by explicit path. Commit messages carry no co-author or attribution trailer (the user's global and project CLAUDE.md override the session reminder). No tag, push or require bump: Phase 16 plan 16-03 moves the host app's journal gitlink.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Anonymous author profile at GET /_journal/api/v1/authors/{author}, end to end</name>
|
||||
<files>../sm-journal-plugin/controllers/api/authors.go, ../sm-journal-plugin/controllers/api/authors_test.go, ../sm-journal-plugin/journal_api_authors_test.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/posts.go (lines 1-160 and 430-583), ../sm-journal-plugin/controllers/api/auth.go (lines 1-50, 115-130), ../sm-journal-plugin/admin_harness_test.go (lines 155-334), ../sm-journal-plugin/journal_api_writes_test.go (lines 1-35), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-journal-plugin/README.md</read_first>
|
||||
<behavior>
|
||||
- TestValidAuthorParam (package api): "jane-doe", "jan.kowalski", "a@b", and a 255-byte ASCII string are valid. The empty string, a whitespace-only string (after TrimSpace), a 256-byte string, "a\x00b", "a\nb", "a\tb" and "\xff" are invalid.
|
||||
- TestJournalAuthorProfile (package journal_test, real Postgres): insert backend user A (first Jane, last Doe, login "jdoe", author slug "jane-doe", contact address jdoe@example.test, credential hash column set to the marker string `$2y$10$authorSecretMarker`, persist_code "persist-marker"). GET /_journal/api/v1/authors/jane-doe answers 200, and data has exactly the keys id, slug, first_name and last_name, with slug "jane-doe", first_name "Jane", last_name "Doe" and id equal to A's id. The raw body contains none of: jdoe@example.test, authorSecretMarker, persist-marker, or the login "jdoe" as a JSON value.
|
||||
- Login fallback: give A one published post. GET /authors/jdoe answers 200 with data.slug "jane-doe" (canonical). User B (login "ewa", no slug, first and last name NULL) has one published post. GET /authors/ewa answers 200 with slug "ewa", first_name "" and last_name "".
|
||||
- Enumeration guard: user C (login "carol", no slug) has only a draft and a future-dated published post. GET /authors/carol answers 404.
|
||||
- 404 cases, each answering status 404 with a JSON body whose only key is error, equal to "Author not found": an unknown slug "nobody"; user D with slug "gone-author" and deleted_at set; and a 300-character value. A slug-only user E (slug "fresh-author", no posts) answers 200 (explicit opt-in).
|
||||
</behavior>
|
||||
<action>Implement the profile route per D-20, D-05 and D-15.
|
||||
|
||||
1. Create controllers/api/authors.go in package api. It holds:
|
||||
- The const errAuthorNotFound with the text Author not found, and the const maxAuthorParamBytes set to 255.
|
||||
- An unexported struct authorRow with fields ID uint, FirstName string, LastName string, Login string and AuthorSlug string.
|
||||
- validAuthorParam(s string) bool: true only when s is non-empty, len(s) is at most maxAuthorParamBytes, utf8.ValidString(s) holds, and no rune satisfies unicode.IsControl. The caller trims first with strings.TrimSpace.
|
||||
- resolveAuthor(db *gorm.DB, param string) (*authorRow, error). A not-found result is (nil, nil), and only real database errors return an error. It runs one query shape through db.Table("backend_users").Select(...) with an explicit column list: id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, login, and COALESCE(golem15_bloghub_author_slug, '') AS author_slug. Never use a select-all or the cabana.BackendUser struct, so the private columns are never read. It always filters deleted_at IS NULL. The first lookup is golem15_bloghub_author_slug = param. On a miss, it looks up login = param. That match is accepted only when applyPublished(db.Model(&models.Post{})).Where("user_id = ?", row.ID).Count(&n) gives n > 0, which reuses the exact published predicate that /posts uses. Use Limit(1) with Find into a slice, or Take with errors.Is(err, gorm.ErrRecordNotFound), so a miss is not a 500.
|
||||
- authorProfile(row *authorRow) map[string]any with exactly the keys id, slug (AuthorSlug when non-empty, else Login), first_name and last_name.
|
||||
- AuthorShow(app *backpack.App) http.HandlerFunc: get the db through gdb, falling back to 500 Internal server error as Index does. Read strings.TrimSpace(r.PathValue("author")). An invalid value, or a resolveAuthor result of nil, writes writeAPIError(w, http.StatusNotFound, errAuthorNotFound). A resolve error writes 500. Success writes wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(row)}).
|
||||
- The handler does not call optionalBackendPrincipal: the route is anonymous and ignores Bearer tokens.
|
||||
- Write doc comments in the style of posts.go: one line naming the route and the resolution rule.
|
||||
2. routes.go: inside the existing public group (the one with surf.Use("throttle:journal-public-api")), after the /rss line, add g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app)). Do not add a Where, because handler validation keeps the JSON 404. Extend the Routes doc comment to mention the public author routes. Leave the write group untouched.
|
||||
3. Tests first (RED, then GREEN):
|
||||
- controllers/api/authors_test.go: table-driven TestValidAuthorParam covering the behavior cases.
|
||||
- journal_api_authors_test.go (package journal_test): add a helper insertBackendUser(t, env, first, last, login, slug string, deleted bool) uint. It runs env.gdb.Raw with an INSERT ... RETURNING id into backend_users, setting first_name and last_name through NULLIF(?, '') so empty strings store NULL, login, the contact address login+"@example.test", the credential hash marker, persist_code "persist-marker", is_activated TRUE, golem15_bloghub_author_slug through NULLIF(?, ''), deleted_at NOW() when deleted is true, and created_at/updated_at NOW(). Add a helper insertAuthorPost(t, env, userID uint, slug string, published bool, at *time.Time) that creates a models.Post with Title "Post "+slug, Content "body "+slug, ContentHTML "<p>body "+slug+"</p>" and UserID set. Then write TestJournalAuthorProfile covering every behavior bullet. Decode data into map[string]any and compare its sorted key list to [first_name id last_name slug]. Check 404 bodies with phpErrorString plus len(map) == 1.
|
||||
4. README.md, same commit:
|
||||
- Extend the Features "Public HTTP." line with `/authors/{author}`.
|
||||
- Extend the "Author slug." line: the column stores the public author slug that the author routes resolve.
|
||||
- Add GET /_journal/api/v1/authors/jane-doe to the anonymous http example block.
|
||||
- Add a "### Public author API" subsection right after that block. It states the resolution order (author slug, then login only for a user with at least one published post; soft-deleted users never resolve), the four response keys with a JSON example for Jane Doe, that slug is the canonical identifier, and the 404 body. It also states that the author object carries no contact, credential or login data.
|
||||
- Update the API reference row for journal.Plugin.Routes to "Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`."
|
||||
- Use only neutral example names (jane-doe, acme) and never name a consuming application.
|
||||
5. Run gofmt. Commit in sm-journal-plugin, staging explicit paths only: feat(api): anonymous author profile at /_journal/api/v1/authors/{author}. The subject must contain the word author (Task 3 checks it).</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && unit=$(go test ./controllers/api -count=1 -run '^TestValidAuthorParam$' -v 2>&1) && test "$(printf '%s\n' "$unit" | grep -c -- '^--- PASS: TestValidAuthorParam ')" -ge 1 && integ=$(go test . -count=1 -run '^TestJournalAuthorProfile$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorProfile ')" -ge 1 && grep -qF 'g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app))' routes.go && grep -qF '/authors/{author}' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q author && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && git diff --quiet HEAD -- go.mod go.sum</automated>
|
||||
</verify>
|
||||
<done>Both tests show `--- PASS` (not SKIP) and there is no FAIL. go vet and gofmt are clean. The route is registered in the public throttled group, and the README documents it. The commit touches only the five allowed files, so posts.go, posts_helpers.go, auth.go, rss.go, go.mod and go.sum are unchanged. One `feat(api)` commit containing the word author is on sm-journal-plugin `main`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Paginated published posts at GET /_journal/api/v1/authors/{author}/posts</name>
|
||||
<files>../sm-journal-plugin/controllers/api/authors.go, ../sm-journal-plugin/journal_api_authors_test.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/controllers/api/authors.go (as written in Task 1), ../sm-journal-plugin/controllers/api/posts.go (Index, lines 28-114), ../sm-journal-plugin/journal_public_list_smoke_test.go</read_first>
|
||||
<behavior>
|
||||
- TestJournalAuthorPosts: author A (slug "jane-doe") has three published posts a1, a2 and a3, published 3h, 2h and 1h ago, plus one draft, a-draft, and one published post dated an hour in the future, a-future. Author B has one published post, b1.
|
||||
- GET /authors/jane-doe/posts answers 200. Slugs come in order [a3 a2 a1], and meta is {current_page 1, last_page 1, per_page 9, total 3}. Neither a-draft, a-future nor b1 appears. The sorted key set of each row equals the sorted key set of a row from GET /_journal/api/v1/posts for the same post, which proves the row shape matches.
|
||||
- per_page=2&page=2 gives one row (a1) with meta {current_page 2, last_page 2, per_page 2, total 3}. per_page=100 gives meta.per_page 30. page=abc gives current_page 1. sort=published_at asc gives [a1 a2 a3].
|
||||
- A Bearer token from env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts) still gets total 3, with no draft or future post (published only, D-20).
|
||||
- GET /authors/jdoe/posts (the login of slugged author A, who has published posts) answers 200 with total 3. GET /authors/fresh-author/posts (slug only, no posts) answers 200 with data [] and meta {current_page 1, last_page 1, per_page 9, total 0}.
|
||||
- GET /authors/nobody/posts and GET /authors/carol/posts (login with no published post) answer 404 with error "Author not found".
|
||||
</behavior>
|
||||
<action>Add the posts route per D-20 and D-05.
|
||||
|
||||
1. In controllers/api/authors.go, add AuthorPosts(app *backpack.App) http.HandlerFunc. It resolves the author exactly as AuthorShow does: the same validAuthorParam, resolveAuthor, 404 errAuthorNotFound and 500 paths. Then, mirroring Index without its editor branch:
|
||||
- perPage := clampPerPage(query per_page) and page := max(1, atoiDefault(query page, 1)).
|
||||
- q := applyPublished(db.WithContext(r.Context()).Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags")).Where("user_id = ?", author.ID).
|
||||
- Count into total (500 on error), then Order(listOrder(strings.TrimSpace(query sort), false)).Offset((page-1)*perPage).Limit(perPage).Find.
|
||||
- Build each row with serializePost(&posts[i], db.WithContext(r.Context())), and write wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)).
|
||||
- No optionalBackendPrincipal call and no published, search, category, tag or author query keys: the list is always published-only and Bearer tokens are ignored.
|
||||
- Pull the shared resolve-or-respond step into one unexported helper used by both handlers, so the 404 and 500 paths stay identical.
|
||||
2. routes.go: add g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app)) right after the Task 1 author line, in the same public group.
|
||||
3. Tests first: add TestJournalAuthorPosts to journal_api_authors_test.go covering every behavior bullet. Reuse the Task 1 helpers, but create this test's own users (jane-doe/jdoe, carol, fresh-author and author B), because newPostsEnv gives every test a fresh database. Give each post a distinct published_at. Decode meta as in journal_public_list_smoke_test.go.
|
||||
4. README.md, same commit: add GET /_journal/api/v1/authors/jane-doe/posts?page=2 to the anonymous http example block. In "### Public author API", document:
|
||||
- the posts route and its query keys: page (default 1), per_page (default 9, max 30) and sort (same columns as /posts, default published_at desc);
|
||||
- that rows have the same shape as GET /posts rows and only include that author's published posts;
|
||||
- that Bearer tokens are ignored;
|
||||
- the {data, meta{current_page, last_page, per_page, total}} envelope, with a trimmed JSON example;
|
||||
- the same 404 body for an unknown author.
|
||||
5. Run gofmt. Commit in sm-journal-plugin with explicit paths: feat(api): list an author's published posts at /authors/{author}/posts.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && integ=$(go test . -count=1 -run '^TestJournalAuthor(Profile|Posts)$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorProfile ')" -ge 1 && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalAuthorPosts ')" -ge 1 && grep -qF 'g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app))' routes.go && grep -qF '/authors/jane-doe/posts' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q author && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && git diff --quiet HEAD -- go.mod go.sum</automated>
|
||||
</verify>
|
||||
<done>TestJournalAuthorProfile and TestJournalAuthorPosts both show `--- PASS` with no SKIP or FAIL. The posts route answers with the /posts envelope and row shape, published only, with Bearer ignored, and it 404s like the profile route. The README documents query keys, envelope and 404. A second `feat(api)` commit containing the word author is on sm-journal-plugin `main`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Full regression gate, additive-only proof, contract hand-off and profile-field todo</name>
|
||||
<files>.planning/todos/pending/journal-author-profile-fields.md</files>
|
||||
<read_first>../sm-journal-plugin/README.md (Public author API section as committed), ../sm-journal-plugin/controllers/api/authors.go, .planning/todos/pending/menu-plugin-port.md (todo frontmatter format), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md (Task 1: what the Prerequisite contract must record)</read_first>
|
||||
<action>1. In sm-journal-plugin, run the complete suite without -short, with Docker reachable: go vet ./... and go test ./... -count=1. Every package must pass, including the existing journal_api_* tests. They prove that /posts, /posts/{slug}, /categories, /tags, /rss and the write routes behave exactly as before (D-20 additive only). If something fails, fix the new code and never the existing tests or handlers. A fix goes into a separate fix(api) commit whose subject contains the word author.
|
||||
2. Additive-only proof: the files changed by this task's commits must be a subset of routes.go, README.md, controllers/api/authors.go, controllers/api/authors_test.go and journal_api_authors_test.go. Check this over the plugin's commits since the planning-time HEAD f5cd5b1 whose subject contains author (see verify). Confirm that the README names no consuming application.
|
||||
3. In summercms.go, create .planning/todos/pending/journal-author-profile-fields.md with the frontmatter used by the other pending todos: created (ISO timestamp), title "Expose author subtitle, description and avatar on the Journal author API", area plugins, severity minor, and files listing ../sm-journal-plugin/controllers/api/authors.go and modules/lagoon/backend_admin_migrations.go.
|
||||
- Problem: the WinterCMS author component shows metadata.subtitle, and its SEO add-on reads avatar and a description. The framework's backend_users has no metadata column and no avatar attachment, so the D-20 endpoint exposes id, slug, first_name and last_name only.
|
||||
- Solution: a framework decision on backend user profile storage (a metadata column plus an avatar attachment on backend users, or a journal-owned author profile table edited in the admin). Then add the keys to the author payload additively.
|
||||
- Commit it alone, staging the explicit path only: docs: todo for Journal author profile fields.
|
||||
4. In the SUMMARY, write an "Author endpoint contract" section that 16-01 Task 1 copies into its Prerequisite contract. It lists:
|
||||
- both exact route paths;
|
||||
- the profile keys (id, slug, first_name, last_name), with slug canonical;
|
||||
- the resolution rule, including that a post's author.login resolves when that author has published posts, so the layer can link by login and set the canonical URL from data.slug;
|
||||
- the posts query keys and the paginated envelope;
|
||||
- the 404 body {"error":"Author not found"};
|
||||
- the slug storage column backend_users.golem15_bloghub_author_slug, which 16-03 migrate.sh writes;
|
||||
- the profile fields not exposed, pointing to the new todo;
|
||||
- both plugin commit SHAs.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && go vet ./... && go test ./... -count=1 && shas=$(git log --format=%H --grep=author f5cd5b1..HEAD) && test "$(printf '%s\n' "$shas" | grep -c .)" -ge 2 && files=$(for s in $shas; do git show --name-only --format= "$s" || exit 1; done) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | sort -u | grep -vxE 'routes.go|README.md|controllers/api/authors.go|controllers/api/authors_test.go|journal_api_authors_test.go')" && ! grep -Eiq 'grzyby|fonoteka|plytarium|płytarium' README.md && git diff --quiet HEAD -- go.mod go.sum && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qF 'first_name' .planning/todos/pending/journal-author-profile-fields.md && todo_subj=$(git log -1 --format=%s -- .planning/todos/pending/journal-author-profile-fields.md) && printf '%s\n' "$todo_subj" | grep -q 'todo'</automated>
|
||||
</verify>
|
||||
<done>The full sm-journal-plugin suite is green without -short, and go vet is clean. The author commits touch only the five allowed files. go.mod and go.sum are unchanged. The README names no consuming application. The todo is committed alone in summercms.go. The SUMMARY holds the Author endpoint contract for 16-01.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| anonymous client → /_journal/api/v1/authors/* | Untrusted path segment {author} and query keys page, per_page and sort cross here, with no authentication |
|
||||
| plugin handler → backend_users | A table holding admin credentials and contact data, read by a public route |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-i6e-01 | Information Disclosure | resolveAuthor / authorProfile | high | mitigate | Explicit five-column projection (id, first_name, last_name, login, author slug) into a plugin-local struct. The payload is built from exactly four keys and omits login. TestJournalAuthorProfile asserts the exact key set and that the fixture's contact address, credential hash marker, persist code and login are absent from the raw body. |
|
||||
| T-i6e-02 | Information Disclosure | login fallback (backend account enumeration) | medium | mitigate | A login resolves only for a user with at least one published post, whose login GET /posts already exposes. Every other login gets the same 404 as an unknown value. Soft-deleted users never resolve. Both routes sit in the throttle:journal-public-api bucket. The enumeration-guard and soft-delete cases pin this. |
|
||||
| T-i6e-03 | Information Disclosure | AuthorPosts (drafts and scheduled posts) | high | mitigate | applyPublished (the /posts predicate) is always applied, and there is no optionalBackendPrincipal call. The editor-token case proves drafts and future posts stay hidden. |
|
||||
| T-i6e-04 | Denial of Service | AuthorPosts paging | low | mitigate | clampPerPage caps per_page at 30, page is clamped to at least 1, and the routes are rate-limited by the existing public bucket. The per_page=100 case pins the cap. |
|
||||
| T-i6e-05 | Tampering | {author} and sort input | medium | mitigate | GORM parameter binding only. validAuthorParam rejects empty, over-255-byte, invalid UTF-8 and control-character values with the JSON 404. sort goes through the existing listOrder column allow-list. TestValidAuthorParam pins this. |
|
||||
| T-i6e-06 | Tampering | shared plugin API contract (D-20 costly) | medium | mitigate | Routes are additive only. posts.go, posts_helpers.go, auth.go, rss.go, go.mod and go.sum are unchanged (diff gates in every task), and the full existing suite passes in Task 3. |
|
||||
| T-i6e-SC | Tampering | npm/pip/cargo/go installs | low | accept | No package installs and no new Go requirement. The go.mod and go.sum diff gates prove it. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test ./... -count=1 (without -short, Docker reachable) are green in sm-journal-plugin.
|
||||
- TestValidAuthorParam, TestJournalAuthorProfile and TestJournalAuthorPosts pass and are not skipped.
|
||||
- routes.go registers both author routes inside the throttle:journal-public-api group. The write group and the existing public routes are unchanged.
|
||||
- The author commits touch only routes.go, README.md, controllers/api/authors.go, controllers/api/authors_test.go and journal_api_authors_test.go.
|
||||
- The README documents both routes and names no consuming application.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Phase 16 plan 16-01 Task 1's gate (`grep -qi 'author' ../sm-journal-plugin/routes.go`) passes for the D-20 half, and its client can call getAuthor(slug) and listAuthorPosts(slug, {page, perPage}) against these routes.
|
||||
- /autor/admin on staging (16-03 sets slug admin) resolves through the slug lookup. Post author links built from author.login also resolve, and the canonical identifier comes from data.slug.
|
||||
- Unknown authors answer 404 {"error":"Author not found"}. No private backend user data and no unpublished post appears in any response.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/261009-i6e-SUMMARY.md` when done, including the "Author endpoint contract" section (Task 3) and the two sm-journal-plugin commit SHAs.
|
||||
</output>
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
phase: quick-261009-i6e
|
||||
plan: 01
|
||||
subsystem: sm-journal-plugin public API
|
||||
tags: [journal, api, authors, d-20, phase-16-prerequisite]
|
||||
status: complete
|
||||
requires:
|
||||
- backend_users.golem15_bloghub_author_slug (updates/202610060007_add_author_slug_to_backend_users.go)
|
||||
provides:
|
||||
- GET /_journal/api/v1/authors/{author}
|
||||
- GET /_journal/api/v1/authors/{author}/posts
|
||||
affects:
|
||||
- Phase 16 plan 16-01 Task 1 (layer client getAuthor / listAuthorPosts)
|
||||
- Phase 16 plan 16-03 (migrate.sh --author-slug writes the slug column)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- plugin-local projection struct over backend_users (explicit column list, never select-all)
|
||||
- handler-side path validation instead of surf Where, so misses stay JSON 404
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- ../sm-journal-plugin/controllers/api/authors_test.go
|
||||
- ../sm-journal-plugin/journal_api_authors_test.go
|
||||
- .planning/todos/pending/journal-author-profile-fields.md
|
||||
modified:
|
||||
- ../sm-journal-plugin/routes.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
decisions:
|
||||
- "Journal author API resolves {author} by golem15_bloghub_author_slug, then by login only when that user has a published post; payload is exactly id, slug, first_name, last_name (D-20)"
|
||||
- "Author profile fields subtitle, description and avatar are not exposed until the framework decides backend user profile storage (todo journal-author-profile-fields)"
|
||||
metrics:
|
||||
duration: ~25min
|
||||
completed: 2026-10-09
|
||||
actuals:
|
||||
tokens: 6256
|
||||
tasks: 3
|
||||
commits: 2
|
||||
commits: 2
|
||||
plan_head_before: f5cd5b16fe444c4f9ea40d060218a9f1396ee380
|
||||
plan_head_after: 57a347c97bafcc99fb83f09d5ddc1bfd8df32280
|
||||
---
|
||||
|
||||
# Quick 261009-i6e Plan 01: Anonymous Journal author API Summary
|
||||
|
||||
The Journal plugin now has two anonymous, rate-limited GET routes: an author profile (`{id, slug, first_name, last_name}`) and that author's published posts in the `/posts` pagination envelope. `{author}` resolves by author slug first, then by login, and a login only resolves for a user who has at least one published post. The change is additive only.
|
||||
|
||||
`commits`, `plan_head_before` and `plan_head_after` are measured in the sm-journal-plugin repo (branch `main`), where all code landed.
|
||||
|
||||
## Commits (sm-journal-plugin, branch main)
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | `908ade9` | feat(api): anonymous author profile at /_journal/api/v1/authors/{author} |
|
||||
| 2 | `57a347c` | feat(api): list an author's published posts at /authors/{author}/posts |
|
||||
| 3 | none | Regression gate and todo only. No fix commit was needed. |
|
||||
|
||||
The todo `.planning/todos/pending/journal-author-profile-fields.md` is written in summercms.go and left **uncommitted**, as the orchestrator instructed. That overrides the plan's own "commit it alone" step, so Task 3's last verify clause (`git log -- <todo>`) does not hold yet. It will hold after the orchestrator's docs commit.
|
||||
|
||||
## Test results
|
||||
|
||||
**Docker was not reachable.** User `golem` is not in the `docker` group, so `/var/run/docker.sock` answers "permission denied" both inside and outside the sandbox. A plain `go test ./... -count=1` therefore fails in TestMain for the three testcontainers packages (root, `models`, `updates`) before any test runs. The literal acceptance command, with testcontainers and `postgres:16-alpine`, was **not** run green here.
|
||||
|
||||
To exercise the real-Postgres suites anyway:
|
||||
|
||||
- I started a private, throwaway PostgreSQL **18.6** cluster with `initdb` in the session scratchpad (127.0.0.1:55491, no unix socket).
|
||||
- I made a temporary, **uncommitted** edit to each package's `start*Postgres`, so it used a DSN from an env var instead of testcontainers.
|
||||
- I ran the suites, then reverted the three harness files with `git checkout --` and stopped the cluster. The working tree is clean except for the pre-existing untracked `go.work.sum`.
|
||||
|
||||
Results on that cluster:
|
||||
|
||||
| Command | Result |
|
||||
|---------|--------|
|
||||
| `gofmt -l .` | clean |
|
||||
| `go vet ./...` | green, run after the harness revert |
|
||||
| `go test ./controllers/api -run '^TestValidAuthorParam$' -v` | `--- PASS: TestValidAuthorParam` (no Docker needed) |
|
||||
| `go test . -run '^TestJournalAuthor(Profile\|Posts)$' -v` | `--- PASS: TestJournalAuthorProfile`, `--- PASS: TestJournalAuthorPosts` |
|
||||
| `go test . ./controllers/api ./classes -count=1 -v` | ok on all three: 35 top-level PASS, 0 SKIP, 0 FAIL. This includes every existing `journal_api_*` test, so the existing routes still behave as before. |
|
||||
| `go test ./models -count=1` | ok |
|
||||
| `go test ./updates -count=1` | ok |
|
||||
|
||||
Every package passed. Before relying on this for release, re-run `go test ./... -count=1` with no overrides on a machine where Docker is reachable, to confirm on Postgres 16 via testcontainers.
|
||||
|
||||
**RED evidence:** `TestValidAuthorParam` failed to build before `authors.go` existed (`undefined: validAuthorParam`). `TestJournalAuthorPosts` failed with `404 page not found` before the posts route was registered.
|
||||
|
||||
## Additive-only proof
|
||||
|
||||
- `git log --grep=author f5cd5b1..HEAD` finds 2 commits. Together they touch only `routes.go`, `README.md`, `controllers/api/authors.go`, `controllers/api/authors_test.go` and `journal_api_authors_test.go`.
|
||||
- `go.mod`, `go.sum`, `controllers/api/posts.go`, `posts_helpers.go`, `auth.go` and `rss.go` are byte-identical to `f5cd5b1`.
|
||||
- The README does not name any consuming application: grep finds none of grzyby, fonoteka or plytarium.
|
||||
|
||||
## Author endpoint contract
|
||||
|
||||
This is for 16-01 Task 1's Prerequisite contract.
|
||||
|
||||
**Routes.** Both are anonymous, sit in the `throttle:journal-public-api` group and ignore Bearer tokens.
|
||||
|
||||
- `GET /_journal/api/v1/authors/{author}`
|
||||
- `GET /_journal/api/v1/authors/{author}/posts`
|
||||
|
||||
**Profile response:** `200 {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}`.
|
||||
|
||||
- `data` has exactly these four keys.
|
||||
- `slug` is the canonical identifier: the author slug when set, otherwise the login.
|
||||
- A missing first or last name is `""`.
|
||||
- There is no `login`, contact address, credential, permission or role key.
|
||||
|
||||
**Resolution of `{author}`** (the value is trimmed first):
|
||||
|
||||
1. `backend_users.golem15_bloghub_author_slug = {author}` resolves even when the author has no posts.
|
||||
2. Otherwise `backend_users.login = {author}`, but only when that user has at least one published post (`published = true`, `published_at` set and in the past).
|
||||
3. Soft-deleted users (`deleted_at` set) never resolve.
|
||||
|
||||
A slugged author's login also resolves. The post `author` object from `/posts` carries `{id, first_name, last_name, login}` and no slug, so the layer can link by `author.login` and set the canonical URL from `data.slug`.
|
||||
|
||||
**Posts response.** It uses the `lagoon.Paginate` envelope, the same one as `GET /posts`:
|
||||
|
||||
```json
|
||||
{"data":[...],"meta":{"current_page":1,"last_page":1,"per_page":9,"total":3}}
|
||||
```
|
||||
|
||||
- Rows are built by `serializePost`, so they have the same row shape as `GET /posts`.
|
||||
- Only that author's published posts appear. A Bearer token does not unlock drafts or scheduled posts.
|
||||
|
||||
Query keys for the posts route:
|
||||
|
||||
| Key | Default | Rule |
|
||||
|-----|---------|------|
|
||||
| `page` | `1` | Clamped to at least 1. A non-numeric value means 1. |
|
||||
| `per_page` | `9` | Clamped to 1..30. |
|
||||
| `sort` | `published_at desc` | The `/posts` allow-list: `published_at`, `created_at`, `updated_at`, `title`, `id`, each with `asc` or `desc`. |
|
||||
|
||||
A slug-only author with no posts gets `data: []` and `meta {1,1,9,0}`.
|
||||
|
||||
**404** comes from both routes, with the body `{"error":"Author not found"}` and no other keys. It covers:
|
||||
|
||||
- an unknown value;
|
||||
- a login without a published post;
|
||||
- a soft-deleted user;
|
||||
- a malformed value (empty after trim, over 255 bytes, invalid UTF-8 or containing a control character).
|
||||
|
||||
**Slug storage.** The column is `backend_users.golem15_bloghub_author_slug` (TEXT UNIQUE, plugin migration `202610060007`). 16-03's `migrate.sh --author-slug` writes it, so the staging URL `/autor/admin` resolves through the slug lookup.
|
||||
|
||||
**Not exposed:** subtitle (`metadata.subtitle`), description and avatar. The Go `backend_users` table has no storage for them. See `.planning/todos/pending/journal-author-profile-fields.md`.
|
||||
|
||||
**Plugin commits:** `908ade9` (profile), `57a347c` (posts).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None in the code. The plan's code steps were followed as written.
|
||||
|
||||
### Execution adjustments
|
||||
|
||||
1. **[Rule 3 - Blocking] Docker unreachable.** The integration tests ran on a private, throwaway Postgres 18.6 cluster through temporary, uncommitted harness overrides, and those overrides were reverted afterwards. None of this touched a commit. Details are under Test results above.
|
||||
2. **The resolve-or-respond helper (`authorOrRespond`) was introduced in Task 1, not Task 2.** Task 1 needed the same 404 and 500 paths, and Task 2 reuses the helper unchanged.
|
||||
3. **Extra test cases beyond the behavior list:**
|
||||
- A slugged author's login answers 404 before their first published post and 200 after it.
|
||||
- A soft-deleted user with a published post is 404 by login as well as by slug.
|
||||
- A `%09` (tab) path value answers the JSON 404.
|
||||
- Post rows from the author route carry `author.login`.
|
||||
4. **The todo is not committed.** This follows the orchestrator constraint, which overrides plan Task 3 step 3's "commit it alone".
|
||||
5. **Commits are on `main` in sm-journal-plugin**, as both the plan and the orchestrator direct. That repo has no per-agent branch workflow.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The new surface (two anonymous GETs that read `backend_users`) is the subject of the plan's threat model. Each item in the register has a test:
|
||||
|
||||
- T-i6e-01: the exact key set, and the absence of the email, hash marker, persist code and login from the response.
|
||||
- T-i6e-02: the enumeration guard and the soft-delete cases.
|
||||
- T-i6e-03: the editor-token case.
|
||||
- T-i6e-04: `per_page=100` gives 30.
|
||||
- T-i6e-05: `TestValidAuthorParam`, plus the oversized and control-character path cases.
|
||||
- T-i6e-06: the additive-only diff gate.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../sm-journal-plugin/controllers/api/authors.go, controllers/api/authors_test.go, journal_api_authors_test.go
|
||||
- FOUND: .planning/todos/pending/journal-author-profile-fields.md (uncommitted by instruction)
|
||||
- FOUND: commits 908ade9 and 57a347c in sm-journal-plugin
|
||||
- Caveat, not a missing item: the testcontainers-based `go test ./...` was not run because Docker is unreachable. The equivalent real-Postgres run passed, as described under Test results.
|
||||
@@ -0,0 +1,366 @@
|
||||
---
|
||||
phase: quick-261009-mgk
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/updates/202610090001_add_content_to_golem15_journal_categories.go
|
||||
- ../sm-journal-plugin/updates/category_content_migration_test.go
|
||||
- ../sm-journal-plugin/models/category.go
|
||||
- ../sm-journal-plugin/models/fillable_test.go
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
- ../sm-journal-plugin/controllers/api/categories_test.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/check_html_test.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- .planning/todos/pending/cabana-richeditor-field-type.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261009-mgk]
|
||||
|
||||
estimate:
|
||||
tokens: 85000
|
||||
raw_tokens: 85000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-21: anonymous GET /_journal/api/v1/categories, with and without include_empty=1, gives every serialized category row (children included) a new key content. Its value is the stored HTML string, byte for byte, or JSON null when unset. The existing keys id, name, slug, description, parent_id, nest_depth, post_count and children keep their values, and the rule that omits empty categories without include_empty=1 is unchanged (D-24 still needs include_empty=1 for an empty category)."
|
||||
- "D-21: golem15_journal_categories gains a nullable TEXT column content. The migration 202610090001_add_content_to_golem15_journal_categories is the last entry of updates.All(). RollbackLast drops only that column and keeps the rows, and Migrate adds it back. models.Category.Content (*string, column content) maps it."
|
||||
- "D-21: the admin Categories form shows a content field (textarea, size huge, span full, right after description) with en and pl label and comment phrases. models.Category.Fillable includes content. An administrator with golem15.journal.access_categories can create and update it, and the categories API then serves the saved value."
|
||||
- "An admin create or update whose content fails the Journal FormatHTML three-pattern gate (raw script, iframe, object or embed tags; event handler attributes; javascript, vbscript or data URL schemes), exposed as classes.CheckHTML, answers 422 validation_failed with details on content and writes nothing. Blank or whitespace-only content is stored as NULL. Any other content is stored exactly as submitted."
|
||||
- "content is a host column and is not translatable, matching the WinterCMS Golem15.Blog category where content was never in $translatable. Category.Translatable() stays name, description and slug."
|
||||
- "The change is additive only, because the plugin is shared. Rows in GET /posts keep categories entries with exactly id, name and slug. No route is added or removed. go.mod and go.sum are unchanged. The diffs of controllers/api/posts_helpers.go and classes/format_html.go against 57a347c contain no removed lines. Every existing test passes unedited, except the one expected Fillable list in models/fillable_test.go."
|
||||
- "README.md documents the categories row shape including content, its stored-as-is HTML semantics, the admin save gate, that consumers must still gate it before rendering, and the new Fillable list. It names no consuming application. go vet ./... and go test ./... are green in sm-journal-plugin, and 16-01 Task 1's D-21 gate passes."
|
||||
artifacts:
|
||||
- path: ../sm-journal-plugin/updates/202610090001_add_content_to_golem15_journal_categories.go
|
||||
provides: "ALTER TABLE golem15_journal_categories ADD COLUMN content TEXT NULL, plus its rollback"
|
||||
contains: "202610090001_add_content_to_golem15_journal_categories"
|
||||
- path: ../sm-journal-plugin/models/category.go
|
||||
provides: "Category.Content *string and Fillable with content"
|
||||
contains: "column:content"
|
||||
- path: ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
provides: "serializeCategoryNode emits the content key"
|
||||
contains: "\"content\":"
|
||||
- path: ../sm-journal-plugin/controllers/categories.go
|
||||
provides: "FormBeforeCreate and FormBeforeUpdate on the Categories admin controller: blank to NULL, classes.CheckHTML gate"
|
||||
contains: "FormBeforeUpdate"
|
||||
- path: ../sm-journal-plugin/classes/format_html.go
|
||||
provides: "exported CheckHTML wrapping the existing rejectUnsafe gate"
|
||||
contains: "func CheckHTML"
|
||||
- path: ../sm-journal-plugin/models/category/fields.yaml
|
||||
provides: "content textarea field in the admin form"
|
||||
contains: "lang.category.content"
|
||||
- path: ../sm-journal-plugin/journal_api_categories_test.go
|
||||
provides: "Postgres integration tests for the categories API content key and the admin form save path"
|
||||
contains: "TestCategoriesAdminContent"
|
||||
- path: ../sm-journal-plugin/README.md
|
||||
provides: "Public categories API subsection, updated Categories feature line and API reference rows"
|
||||
contains: "### Public categories API"
|
||||
key_links:
|
||||
- from: ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
to: ../sm-journal-plugin/models/category.go
|
||||
via: "serializeCategoryNode reads c.Content the way it reads c.Description"
|
||||
pattern: "c\\.Content"
|
||||
- from: ../sm-journal-plugin/models/category/fields.yaml
|
||||
to: ../sm-journal-plugin/models/category.go
|
||||
via: "cabana binds the content field to the model column at boot; a save fills it only because Fillable lists content"
|
||||
pattern: "\"content\""
|
||||
- from: ../sm-journal-plugin/controllers/categories.go
|
||||
to: ../sm-journal-plugin/classes/format_html.go
|
||||
via: "prepareCategoryWrite calls classes.CheckHTML and returns *cabana.ValidationError on content"
|
||||
pattern: "classes\\.CheckHTML"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add the D-21 category content field to the shared Journal plugin. A category gets an HTML body, edited in the admin and served on every row of the public categories API. Phase 16 renders it on `/:category` pages above the post list, and plan 16-03 fills it from the PHP database.
|
||||
|
||||
**Repository.** All code changes land in `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, a separate git repo (branch `main`) next to `summercms.go`. Commit the code there. Only planning documents (this plan, the SUMMARY and one todo) live in `summercms.go/.planning`. No `summercms.go` code or `docs/` page changes. At planning time, `grep -rli journal docs README.md` in summercms.go found no page that names the Journal plugin, so the plugin README is the only document to update.
|
||||
|
||||
Purpose: 16-01 Task 1 checkpoints until `models/category.go` has a content field that `serializeCategoryNode` emits. Its layer then reads the D-21 key (assumed `content`, an HTML string or null) and renders it through `gateJournalHtml`. 16-03's `grzyby:import-categories` writes the column with one UPDATE. This plan delivers exactly the key and format those plans assume.
|
||||
|
||||
Output: two `feat` commits in sm-journal-plugin, one for the API and one for the admin, each with tests and README. One todo in summercms.go. A SUMMARY with a "Category content contract" section for 16-01 Task 1 and 16-03 Task 2.
|
||||
|
||||
Source coverage: the quick task description (migration and model, serializer, admin form field, README and docs) and CONTEXT D-21 (admin form plus categories API) are covered by Tasks 1 and 2. D-24's include_empty behaviour is pinned by Task 1's tests. 16-RESEARCH's format finding (the live `category.content` is HTML of about 13 KB, rendered raw) sets the HTML-as-is format. No deferred idea from 16-CONTEXT is touched.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
@.planning/quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/261009-i6e-SUMMARY.md
|
||||
@../sm-journal-plugin/README.md
|
||||
@../sm-journal-plugin/models/category.go
|
||||
@../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
@../sm-journal-plugin/controllers/categories.go
|
||||
@../sm-journal-plugin/classes/format_html.go
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- **Key and format (chosen to match 16-01 and 16-03).** The API key is `content`. Its value is the stored HTML string or `null`. It sits on every row that `serializeCategoryNode` builds, so children carry it too. It is stored and served as is: no markdown rendering, no trimming, no FormatHTML conversion. Reasons:
|
||||
- The WinterCMS source column is `golem15_blog_categories.content longtext NULL` (`grzybyfunkcjonalne.pl/sources/gf_old.sql` line 917).
|
||||
- The theme printed it with `{{ category.content|raw }}` (arsha `partials/category.htm:23`).
|
||||
- 16-01 assumes "an HTML string or null on every categories row".
|
||||
- Running FormatHTML over HTML would wipe it (16-RESEARCH Pitfall 2).
|
||||
- **Not translatable.** WinterCMS `Golem15\Blog\Models\Category::$translatable` is name, description and slug (with index). content was never translated, and the PHP dump has no translated category content. The Go categories API reads host columns only. So keep `Category.Translatable()` unchanged and use a plain `textarea` admin field, not an `ml*` type. A side effect: the save gate then covers every stored value, because the non-default-locale writes that bypass the Form hooks never apply to this field.
|
||||
- **No rich editor exists.** cabana's field types (summercms.go `docs/backend/forms.md` Field types) are text, textarea, number, checkbox, switch, dropdown, relation, relation-manager, widget, partial, fileupload, datepicker, permissioneditor, password, markdown, mltext, mltextarea and mlmarkdown. A `markdown` field would preview HTML as `<!-- raw HTML omitted -->`. So the field is a `textarea` that edits HTML source, with `size: huge` (valid sizes: tiny, small, large, huge, giant; `modules/cabana/form_schema.go:35`) and `comment` (supported by `cabana.FormField.Comment`). Task 3 records the missing WYSIWYG type as a todo. A framework field type is out of scope here, because summercms.go is touched only when a phase needs a missing framework feature.
|
||||
- **Admin save path** (summercms.go `modules/cabana/crud.go` save):
|
||||
- The body is filled through `lagoon.Fill`, but only for fields that are in the model's `Fillable`.
|
||||
- Validation runs next, then `pact.FormBeforeCreate` / `pact.FormBeforeUpdate` with the filled `*models.Category`.
|
||||
- The row is then written with `tx.Create` or `tx.Unscoped().Save`. Save writes every column, so a nil pointer stores NULL.
|
||||
- A hook error of type `*cabana.ValidationError` reaches the client as 422 `validation_failed` with its Details. The posts controller (`controllers/posts.go` `preparePostWrite`) is the pattern: it returns `&cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}}`.
|
||||
- Admin routes are `POST <prefix>/api/v1/golem15/journal/categories`, `PUT .../categories/{id}` and `GET .../categories/schema/form`. The test helper `adminAPI(rel)` builds the prefix.
|
||||
- **Gate scan.** At planning time the 2024 dump's six category rows (about 82 KB of HTML) gave 0 matches for all three gate patterns. Imported pillar content therefore passes `classes.CheckHTML`, and an admin re-save of an imported category is not refused.
|
||||
- **No category write API.** routes.go has no category write route, so admin saves are the only plugin write path. 16-03 writes the column with SQL from its own command, which bypasses the admin gate by design. The layer gate (16-01 T-16-01) covers rendering.
|
||||
- **Migration ordering.** gormigrate rolls back in slice order. `updates.Register` appends in `init()` order, and init order follows source file name order. The new file name `202610090001_...` sorts after `202610060007_add_author_slug_to_backend_users.go`, so it is the last migration and the `RollbackLast()` target. This is the same constraint as STATE Phase 08 P02.
|
||||
- **JSON escaping.** `wire.WriteJSON` uses encoding/json, which may escape `<`, `>` and `&` as `<`, `>` and `&`. Tests must compare decoded values, never raw response bytes of HTML. A JSON null check on decoded data is: the key is present and the value is nil.
|
||||
- **gofmt alignment.** `"content"` is shorter than `"description"`, and `Content` is shorter than `Description`. Adding the map entry in `serializeCategoryNode` and the struct field in `models.Category` therefore realigns no existing line, and the posts_helpers.go diff is pure additions. The Task 1 gate relies on this.
|
||||
- **Test harness (package journal_test).**
|
||||
- `newPostsEnv(t)` boots translate and journal on a fresh testcontainers database. It seeds locales en (default) and pl and runs all migrations, including the new one.
|
||||
- Helpers: `env.call` / `env.expect(t, status, method, path, token, body)`, `env.admin(t, perms...)` (returns a backend Bearer token), `adminDecode(t, rec)` (gives `{Data json.RawMessage; Error{Code, Message}}`) and `ptrTime`.
|
||||
- Permission constants: `controllers.PermissionAccessCategories` and `controllers.PermissionAccessPosts`.
|
||||
- `TestJournalPublicCategories` in journal_api_writes_test.go is the existing categories test. It checks that keys are present, not an exact key set, so it stays green unedited.
|
||||
- Package updates has `dedicatedDB`, `journalMigrator`, `assertUdt` and the BackendAdminMigrations setup in migrations_test.go.
|
||||
- **Docker.** At planning time Docker is unreachable: user golem is not in the docker group and `/var/run/docker.sock` answers permission denied. testcontainers fails in TestMain for the root, `models` and `updates` packages. Packages `classes` and `controllers/api` have no TestMain and need no Docker.
|
||||
- The harness's `testShort()` matches only the literal argument `-test.short`, so `go test -short` does NOT skip the container. Docker-free model unit tests run with `go test ./models -run '^TestFillable$' -count=1 -v -args -test.short`, which was verified green at planning time.
|
||||
- Integration fallback when Docker is unreachable (proven by 261009-i6e, see its SUMMARY "Test results"):
|
||||
1. `initdb -D <scratch>/pg -U postgres --auth=trust`.
|
||||
2. `pg_ctl -D <scratch>/pg -l <scratch>/pg.log -o "-p 55493 -k '' -c listen_addresses=127.0.0.1" start`.
|
||||
3. Make a temporary, uncommitted edit to `startAdminPostgres` (admin_harness_test.go) and to both `startJournalPostgres` functions (updates/postgres_test.go and models/post_translatable_smoke_test.go). When env `JOURNAL_PG_DSN` is set, each opens that DSN with `sql.Open("pgx", dsn)`, pings it, sets the package's SQL handle (and DSN variable where one exists) and returns without a container. Each stop function must tolerate a nil container.
|
||||
4. Run the exact `go test` selections with `JOURNAL_PG_DSN=postgres://postgres@127.0.0.1:55493/postgres?sslmode=disable`.
|
||||
5. Revert the three harness files with `git checkout --` and stop the cluster, all before any `git add`.
|
||||
6. Record this in the SUMMARY.
|
||||
Never pass `-short` to an acceptance run. Never commit a harness override.
|
||||
- **Planning-time HEAD.** sm-journal-plugin is at `57a347c` on `main` (D-20 landed). The only untracked file is `go.work.sum`. The diff gates compare against `57a347c`. Keep the D-20 README text intact.
|
||||
- **Repo hygiene.**
|
||||
- Never stage `go.work.sum` in sm-journal-plugin.
|
||||
- In summercms.go, `.planning/STATE.md`, `.planning/state.json` and `.msd/` are dirty or untracked at planning time. Never stage them.
|
||||
- Stage by explicit path.
|
||||
- Commit messages carry no co-author or attribution trailer. The user's global and project CLAUDE.md override the session reminder.
|
||||
- No tag, push or require bump. 16-03 Task 1 moves the host app's journal gitlink.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: content column to model to GET /categories, end to end</name>
|
||||
<files>../sm-journal-plugin/updates/202610090001_add_content_to_golem15_journal_categories.go, ../sm-journal-plugin/updates/category_content_migration_test.go, ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/controllers/api/categories_test.go, ../sm-journal-plugin/journal_api_categories_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<precondition>sm-journal-plugin is on main at 57a347c or a descendant, with no tracked changes (git status shows only the untracked go.work.sum).</precondition>
|
||||
<reversibility rating="costly">Adds a column and a published API key to a plugin shared across projects; additive, but once consumers read content it cannot be removed without breaking them.</reversibility>
|
||||
<read_first>../sm-journal-plugin/models/category.go, ../sm-journal-plugin/controllers/api/posts_helpers.go (lines 150-220), ../sm-journal-plugin/controllers/api/posts.go (Categories handler, lines 155-175), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-journal-plugin/updates/202610060002_create_golem15_journal_categories.go, ../sm-journal-plugin/updates/migrations_test.go, ../sm-journal-plugin/updates/postgres_test.go (dedicatedDB, lines 100-140), ../sm-journal-plugin/journal_api_writes_test.go (TestJournalPublicCategories, lines 150-205), ../sm-journal-plugin/controllers/api/authors_test.go (unit test style), ../sm-journal-plugin/README.md</read_first>
|
||||
<behavior>
|
||||
- Notation: in these behavior blocks, < and > stand for the literal angle brackets of the HTML test strings, and \n is a newline.
|
||||
- TestSerializeCategoryNodeContent (package api, no Docker). Setup: root Category{ID 1, Name "News", Slug "news", Content set to "<h2>Acme</h2>\n<p>Pillar copy</p>\n" (with the trailing newline)}; child Category{ID 2, Name "Quiet", Slug "quiet", ParentID 1, Content nil}; counts {1: 2}. With includeEmpty true:
|
||||
- row["content"] equals the root string exactly (no trimming or escaping).
|
||||
- The root's sorted key list is [children content description id name nest_depth parent_id post_count slug].
|
||||
- The child row has the key content with a nil value, and its sorted key list is [content description id name nest_depth parent_id post_count slug].
|
||||
- With includeEmpty false, the child (post_count 0) is omitted and the root has no children key, as before.
|
||||
- TestJournalCategoryContentAPI (package journal_test, real Postgres). Setup: category news (Content "<h2>Acme news</h2>\n<p>Pillar copy</p>", nest values as in TestJournalPublicCategories) with one published post; category quiet with no content and no posts.
|
||||
- GET /_journal/api/v1/categories?include_empty=1 answers 200. Flattening the tree (data plus children), news has content equal to the exact string, quiet has the key content with nil, and the seeded uncategorized row has content nil (D-24 empty categories still listed).
|
||||
- GET /_journal/api/v1/categories without include_empty lists news with its content and omits quiet.
|
||||
- GET /_journal/api/v1/posts: the post's categories[0] has the sorted key list [id name slug], so content does not leak into post rows.
|
||||
- TestCategoryContentMigration (package updates, real Postgres). Setup: migrate the backend admin migrations, then journalMigrator(gdb).Migrate().
|
||||
- The ID of the last element of All() is 202610090001_add_content_to_golem15_journal_categories.
|
||||
- golem15_journal_categories.content exists, its udt is text and information_schema.columns.is_nullable is YES.
|
||||
- After setting content on the uncategorized row, RollbackLast() leaves the table present and the uncategorized row present, and removes the content column.
|
||||
- Migrate() again restores the column, and the uncategorized row's content is NULL.
|
||||
</behavior>
|
||||
<action>Implement the API half of D-21.
|
||||
|
||||
1. Migration. Create updates/202610090001_add_content_to_golem15_journal_categories.go, modelled on 202610060007:
|
||||
- A var addCategoryContent holding one gormigrate.Migration with ID 202610090001_add_content_to_golem15_journal_categories.
|
||||
- Migrate runs ALTER TABLE golem15_journal_categories ADD COLUMN IF NOT EXISTS content TEXT NULL.
|
||||
- Rollback runs ALTER TABLE golem15_journal_categories DROP COLUMN IF EXISTS content.
|
||||
- init() calls Register(addCategoryContent...).
|
||||
- The file name must sort last among the updates files (see the Migration ordering fact).
|
||||
|
||||
2. Model. In models/category.go, add the field Content *string with gorm tag column:content directly after Description. Give it a doc comment: the category body as HTML, stored and served as is, not translatable (per D-21). Do not change Translatable, TranslatableIndexes, Fillable or Rules in this task. Fillable belongs to Task 2.
|
||||
|
||||
3. Serializer. In serializeCategoryNode (controllers/api/posts_helpers.go):
|
||||
- Next to the existing desc block, declare a local content of type any, set from *c.Content when c.Content is non-nil.
|
||||
- Add the map entry "content": content to the row literal, directly after "description".
|
||||
- Change nothing else in the file. No existing line may change (see the gofmt alignment fact).
|
||||
- serializePost's categories entries stay {id, name, slug}.
|
||||
|
||||
4. Tests first (RED, then GREEN), as in the behavior block:
|
||||
- controllers/api/categories_test.go: TestSerializeCategoryNodeContent in package api, using a sorted-keys helper local to that file.
|
||||
- journal_api_categories_test.go: TestJournalCategoryContentAPI in package journal_test.
|
||||
- Build categories with env.gdb.Create, and link the post with models.PostCategory, as TestJournalPublicCategories does.
|
||||
- Decode responses into struct{ Data []map[string]any } and flatten children recursively.
|
||||
- Compare decoded values only (see the JSON escaping fact).
|
||||
- updates/category_content_migration_test.go: TestCategoryContentMigration in package updates.
|
||||
- Reuse dedicatedDB, journalMigrator and assertUdt.
|
||||
- Set up BackendAdminMigrations exactly as TestJournalMigrationsRollbackAndRemigrate does.
|
||||
- Use a database name of the form journal_catcontent_ plus time.Now().UnixNano().
|
||||
- Do not edit any existing test file in this task.
|
||||
|
||||
5. README.md, same commit:
|
||||
- Add a "### Public categories API" subsection right after the anonymous http example block and before "### Public author API". It states:
|
||||
- GET /_journal/api/v1/categories returns {"data":[...]}, a tree of rows with the keys id, name, slug, description, content, parent_id, nest_depth and post_count, plus children only when a category has listed children.
|
||||
- A category with no published posts and no listed children is omitted unless include_empty=1.
|
||||
- content is the category's HTML body for a category landing page. It is stored and returned as is (not markdown, not rendered), it is null when unset, and it is the same in every locale (a host column, not translatable).
|
||||
- A consumer that renders it as HTML must apply its own gate or sanitizer.
|
||||
- Include one JSON example with neutral names (acme, news).
|
||||
- Change the API reference row for models.Category to: Category row; TableName is golem15_journal_categories; Content holds the HTML body served as content.
|
||||
- Never name a consuming application.
|
||||
|
||||
6. Run gofmt. Commit in sm-journal-plugin, staging the seven explicit paths only, with the subject: feat(api): serve the category content field on GET /_journal/api/v1/categories. The subject must contain the words "category content", because Task 3 greps for them.
|
||||
|
||||
If Docker is unreachable, run the integration selections through the fallback in the context block, and revert the harness overrides before staging.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && unit=$(go test ./controllers/api -count=1 -run '^TestSerializeCategoryNodeContent$' -v 2>&1) && test "$(printf '%s\n' "$unit" | grep -c -- '^--- PASS: TestSerializeCategoryNodeContent ')" -ge 1 && integ=$(go test . -count=1 -run '^(TestJournalCategoryContentAPI|TestJournalPublicCategories)$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalCategoryContentAPI ')" -ge 1 && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalPublicCategories ')" -ge 1 && mig=$(go test ./updates -count=1 -run '^(TestCategoryContentMigration|TestJournalMigrationsRollbackAndRemigrate|TestJournalTables)$' -v 2>&1) && test "$(printf '%s\n' "$mig" | grep -c -- '^--- PASS: TestCategoryContentMigration ')" -ge 1 && test "$(printf '%s\n' "$mig" | grep -c -- '^--- PASS: TestJournalMigrationsRollbackAndRemigrate ')" -ge 1 && grep -qF 'gorm:"column:content"' models/category.go && awk '/^func serializeCategoryNode/,/^}/' controllers/api/posts_helpers.go | grep -qF '"content":' && d=$(git diff 57a347c HEAD -- controllers/api/posts_helpers.go) && test -n "$d" && test -z "$(printf '%s\n' "$d" | grep -E '^-[^-]')" && grep -qF '### Public categories API' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q 'category content' && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'updates/202610090001_add_content_to_golem15_journal_categories.go|updates/category_content_migration_test.go|models/category.go|controllers/api/posts_helpers.go|controllers/api/categories_test.go|journal_api_categories_test.go|README.md')" && git diff --quiet HEAD -- go.mod go.sum && st=$(git status --porcelain -- admin_harness_test.go updates/postgres_test.go models/post_translatable_smoke_test.go) && test -z "$st"</automated>
|
||||
</verify>
|
||||
<done>The three new tests and the existing TestJournalPublicCategories, TestJournalMigrationsRollbackAndRemigrate and TestJournalTables all show `--- PASS`, with no SKIP and no FAIL. GET /categories serves content (an HTML string or null) on every row. The posts_helpers.go diff is pure additions. One `feat(api)` commit containing "category content" touches only the seven allowed files. go.mod, go.sum and the harness files are clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Admin form field for category content with the HTML save gate</name>
|
||||
<files>../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/fillable_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/journal_api_categories_test.go, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/controllers/posts.go (FormBeforeCreate, FormBeforeUpdate and preparePostWrite, lines 160-222), ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/format_html_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/models/fillable_test.go (category_excludes_nest subtest), ../sm-journal-plugin/lang/en/lang.yaml (category section), ../sm-journal-plugin/lang/pl/lang.yaml (category section), ../sm-journal-plugin/posts_admin_smoke_test.go (TestPostsFormCompiles, TestPostsAdminCreateSmoke), ../sm-journal-plugin/admin_harness_test.go (lines 160-334), ../sm-journal-plugin/journal_api_categories_test.go (as written in Task 1)</read_first>
|
||||
<behavior>
|
||||
- Notation as in Task 1: < and > stand for literal angle brackets, and \n is a newline.
|
||||
- TestCheckHTML (package classes, no Docker).
|
||||
- These return nil: the empty string; "<h2>Cordyceps</h2><p>Plain <a href=\"https://example.test/a\">link</a></p>"; a table with thead and tbody.
|
||||
- These return an error: "<script>alert(1)</script>", an iframe, "<object data=x>", "<embed src=x>", "<img src=x onerror=\"alert(1)\">", "<a href=\"javascript:alert(1)\">", "<a href=\"VBScript:x\">" and "<img src=\"data:image/png;base64,AAAA\">".
|
||||
- TestFillable category subtest. The expected Category.Fillable is name, slug, code, description, content, parent_id. lagoon.Fill with a content value assigns cat.Content.
|
||||
- TestCategoriesAdminContent (package journal_test, real Postgres). The token comes from env.admin(t, controllers.PermissionAccessCategories).
|
||||
- The form schema (GET adminAPI("/golem15/journal/categories/schema/form")) has a field named content with type textarea and size huge, directly after description.
|
||||
- POST with name {en "Acme news", pl "Wiadomości Acme"}, slug {en "acme-news", pl "wiadomosci-acme"} and content "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>" answers 201. The stored Content equals that string exactly, and GET /_journal/api/v1/categories?include_empty=1 serves it on acme-news.
|
||||
- POST with slug unsafe-acme and content "<p onclick=\"steal()\">x</p>" answers 422 with error code validation_failed and details that name content. No row has slug unsafe-acme.
|
||||
- PUT on acme-news with the same name and slug maps and content "<a href=\"javascript:alert(1)\">x</a>" answers 422, and the stored content is still the safe string.
|
||||
- PUT on acme-news with content " " succeeds (2xx). Stored Content is nil, and the API serves content null.
|
||||
- A token with only controllers.PermissionAccessPosts gets 403 on the create (existing permission, pinned for T-mgk-06).
|
||||
</behavior>
|
||||
<action>Implement the admin half of D-21.
|
||||
|
||||
1. classes/format_html.go: add the exported func CheckHTML(html string) error, which returns rejectUnsafe(html). Its doc comment: CheckHTML applies the FormatHTML safety gate to HTML that is stored as is, such as category content; it refuses raw script, iframe, object and embed tags, event handler attributes and javascript, vbscript or data URL schemes. Do not change FormatHTML, rejectUnsafe or the three regexes, so the file diff is pure additions. Add classes/check_html_test.go with a table-driven TestCheckHTML covering the behavior cases. Leave format_html_test.go untouched.
|
||||
|
||||
2. models/category.go: Fillable returns name, slug, code, description, content, parent_id. In models/fillable_test.go, category_excludes_nest subtest, update the expected slice to that list and add "content" to the Fill map, then assert cat.Content holds the value. This is the only edit to an existing test in this plan, and it is required because the subtest pins the exact allow-list.
|
||||
|
||||
3. models/category/fields.yaml: after description, add a content field with label golem15.journal::lang.category.content, comment golem15.journal::lang.category.content_comment, type textarea, size huge and span full. Use the same 4-space indentation as the file.
|
||||
|
||||
4. Phrasebooks. Quote every new value with double quotes:
|
||||
- lang/en/lang.yaml, category section: content "Content"; content_comment "HTML shown on the category page above its post list. Unsafe HTML (scripts, frames, event handlers, javascript, vbscript and data URLs) is refused."
|
||||
- lang/pl/lang.yaml, category section: content "Treść"; content_comment "Kod HTML wyświetlany na stronie kategorii nad listą wpisów. Niebezpieczny HTML (skrypty, ramki, atrybuty zdarzeń, adresy javascript, vbscript i data) jest odrzucany."
|
||||
|
||||
5. controllers/categories.go: add FormBeforeCreate and FormBeforeUpdate (signature ctx context.Context, model any, returning error) to categoriesAdminController.
|
||||
- Each type-asserts *models.Category. On a mismatch it returns errors.New("categories admin: form model is not a category"), like the posts controller.
|
||||
- Both call an unexported prepareCategoryWrite(cat *models.Category) error, which works as follows (per D-21):
|
||||
- When Content is non-nil and strings.TrimSpace of it is empty, set Content to nil.
|
||||
- Otherwise, when Content is non-nil and classes.CheckHTML(*cat.Content) fails, return &cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}}.
|
||||
- Never trim or rewrite non-blank content.
|
||||
- Add _ pact.FormBeforeCreate and _ pact.FormBeforeUpdate assertions to the existing var block.
|
||||
- A stored value that fails the gate (only possible through SQL) makes any admin save of that category answer 422 on content until it is fixed. That is intended.
|
||||
|
||||
6. Tests first (RED, then GREEN): add TestCategoriesAdminContent to journal_api_categories_test.go, covering every behavior bullet.
|
||||
- Use a local const for "/golem15/journal/categories".
|
||||
- Decode the schema with adminDecode into struct{ Fields []struct{ Name, Type, Size string } }.
|
||||
- Read the created id from the admin envelope, as TestPostsAdminCreateSmoke does.
|
||||
- Check stored values through env.gdb.First on models.Category.
|
||||
- Compare decoded API values only.
|
||||
|
||||
7. README.md, same commit:
|
||||
- Features "Categories and tags." line: Category stores content, an HTML host column that is not translatable, edited in the admin as a textarea. Fillable is name, slug, code, description, content and parent_id.
|
||||
- In "### Public categories API", add one paragraph: the admin form refuses content that fails classes.CheckHTML (422 on content), blank content is stored as null, and rows written by SQL or import commands bypass that gate, so consumers keep their own gate.
|
||||
- API reference: update the models.Category.Fillable row to the new list, and add a row for classes.CheckHTML: the FormatHTML safety gate for HTML stored as is, such as category content.
|
||||
- Name no consuming application.
|
||||
|
||||
8. Run gofmt. Commit in sm-journal-plugin, staging the ten explicit paths only, with the subject: feat(admin): edit category content in the admin form behind the HTML gate. The subject must contain "category content".
|
||||
|
||||
If Docker is unreachable, use the context fallback for the root package run, and revert before staging.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && fmt=$(gofmt -l .) && test -z "$fmt" && go vet ./... && cls=$(go test ./classes -count=1 -run '^(TestCheckHTML|TestFormatHTMLRejectsUnsafeHTML)$' -v 2>&1) && test "$(printf '%s\n' "$cls" | grep -c -- '^--- PASS: TestCheckHTML ')" -ge 1 && mdl=$(go test ./models -count=1 -run '^(TestFillable|TestTranslatable)$' -v -args -test.short 2>&1) && test "$(printf '%s\n' "$mdl" | grep -c -- '^--- PASS: TestFillable ')" -ge 1 && test "$(printf '%s\n' "$mdl" | grep -c -- '^--- PASS: TestTranslatable ')" -ge 1 && integ=$(go test . -count=1 -run '^(TestCategoriesAdminContent|TestJournalCategoryContentAPI|TestPostsFormCompiles)$' -v 2>&1) && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestCategoriesAdminContent ')" -ge 1 && test "$(printf '%s\n' "$integ" | grep -c -- '^--- PASS: TestJournalCategoryContentAPI ')" -ge 1 && grep -qF 'func CheckHTML(' classes/format_html.go && d=$(git diff 57a347c HEAD -- classes/format_html.go) && test -n "$d" && test -z "$(printf '%s\n' "$d" | grep -E '^-[^-]')" && grep -qF 'classes.CheckHTML' controllers/categories.go && grep -qF 'FormBeforeUpdate' controllers/categories.go && grep -qF 'golem15.journal::lang.category.content_comment' models/category/fields.yaml && grep -qF 'content_comment:' lang/en/lang.yaml && grep -qF 'content_comment:' lang/pl/lang.yaml && grep -qF '"content"' models/category.go && grep -qF 'classes.CheckHTML' README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -q 'category content' && files=$(git show --name-only --format= HEAD) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | grep -vxE 'models/category.go|models/fillable_test.go|models/category/fields.yaml|lang/en/lang.yaml|lang/pl/lang.yaml|classes/format_html.go|classes/check_html_test.go|controllers/categories.go|journal_api_categories_test.go|README.md')" && git diff --quiet HEAD -- go.mod go.sum && st=$(git status --porcelain -- admin_harness_test.go updates/postgres_test.go models/post_translatable_smoke_test.go) && test -z "$st"</automated>
|
||||
</verify>
|
||||
<done>TestCheckHTML, TestFillable, TestTranslatable, TestCategoriesAdminContent, TestJournalCategoryContentAPI and TestPostsFormCompiles show `--- PASS`, with no SKIP and no FAIL. The admin form edits content, unsafe HTML is refused with 422 on content, and blank content stores NULL. The format_html.go diff is pure additions. One `feat(admin)` commit containing "category content" touches only the ten allowed files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Full regression gate, additive-only proof, 16-01 gate, contract hand-off and rich-editor todo</name>
|
||||
<files>.planning/todos/pending/cabana-richeditor-field-type.md</files>
|
||||
<read_first>../sm-journal-plugin/README.md (Public categories API section as committed), ../sm-journal-plugin/controllers/categories.go, .planning/todos/pending/journal-author-profile-fields.md (todo frontmatter format), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md (Task 1: what the Prerequisite contract records about D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-PLAN.md (Task 2 import_categories: which Go field and column it writes)</read_first>
|
||||
<action>1. In sm-journal-plugin, run the complete suite without -short: go vet ./... and go test ./... -count=1. Use Docker, or the context fallback for the root, models and updates packages, reverted afterwards.
|
||||
- Every package must pass, including every existing journal_api_* test, TestJournalPublicCategories and the migration tests. They prove the existing routes and response keys are unchanged (D-21 is additive only).
|
||||
- On a failure, fix the new code, never the existing tests or handlers. A fix goes into a separate fix commit whose subject contains "category content".
|
||||
|
||||
2. Additive-only proof over the commits since 57a347c whose subject contains "category content":
|
||||
- There are at least two of them.
|
||||
- The union of files they touch is a subset of the Task 1 and Task 2 allow-lists.
|
||||
- controllers/api/posts_helpers.go and classes/format_html.go have no removed lines.
|
||||
- go.mod and go.sum are unchanged.
|
||||
- The README names no consuming application.
|
||||
|
||||
3. Run 16-01 Task 1's D-21 gate verbatim from summercms.go: grep -qi content ../sm-journal-plugin/models/category.go, then the awk extraction of serializeCategoryNode piped to grep -qi content, then go -C ../sm-journal-plugin vet ./... (see verify).
|
||||
|
||||
4. In summercms.go, create .planning/todos/pending/cabana-richeditor-field-type.md with the frontmatter used by journal-author-profile-fields.md:
|
||||
- created (ISO timestamp), title "Add a richeditor (HTML WYSIWYG) admin field type to cabana", area framework, severity minor, and files listing modules/cabana/form_schema.go and ../sm-journal-plugin/models/category/fields.yaml.
|
||||
- Problem: D-21 asks for a rich content field on Journal categories. cabana has no WYSIWYG field type, so category content is edited as raw HTML in a textarea, and markdown fields cannot hold HTML (their preview omits raw HTML).
|
||||
- Solution: add a richeditor field type (and an ml variant if needed) to the cabana form schema and the admin SPA. Keep the server-side gate on save, then switch the Journal category content field to it.
|
||||
- Commit it alone, staging the explicit path only, with the subject: docs: todo for a cabana richeditor field type. If the orchestrator instructs otherwise (as for 261009-i6e), leave it uncommitted and say so in the SUMMARY.
|
||||
|
||||
5. In the SUMMARY, write a "Category content contract" section that 16-01 Task 1 copies into its Prerequisite contract and 16-03 Task 2 uses. It lists:
|
||||
- The API key content on every GET /_journal/api/v1/categories row, children included, with and without include_empty=1. The value is an HTML string or null, stored and returned as is (not markdown). The JSON may escape angle brackets as <, and a JSON parser restores them.
|
||||
- Not translatable: one host value for every locale.
|
||||
- Go field models.Category.Content (*string), column golem15_journal_categories.content TEXT NULL, migration ID 202610090001_add_content_to_golem15_journal_categories.
|
||||
- The admin field: textarea, size huge. The save gate is classes.CheckHTML (422 on content). Blank content is stored as NULL.
|
||||
- For 16-03: write the column directly with one UPDATE alongside name and description. That path bypasses the admin gate, so the layer gate stays mandatory. Content that fails the gate blocks later admin saves of that category until it is fixed.
|
||||
- That post rows' categories entries stay {id, name, slug}.
|
||||
- The approximate payload growth: all category content is returned on every /categories call (about 80 KB for the six pillar pages), so consumers should cache it (16-01 swr).
|
||||
- Both plugin commit SHAs.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && go vet ./... && go test ./... -count=1 && shas=$(git log --format=%H --grep='category content' 57a347c..HEAD) && test "$(printf '%s\n' "$shas" | grep -c .)" -ge 2 && files=$(for s in $shas; do git show --name-only --format= "$s" || exit 1; done) && test -z "$(printf '%s\n' "$files" | grep -v '^$' | sort -u | grep -vxE 'updates/202610090001_add_content_to_golem15_journal_categories.go|updates/category_content_migration_test.go|models/category.go|models/fillable_test.go|models/category/fields.yaml|lang/en/lang.yaml|lang/pl/lang.yaml|classes/format_html.go|classes/check_html_test.go|controllers/api/posts_helpers.go|controllers/api/categories_test.go|controllers/categories.go|journal_api_categories_test.go|README.md')" && d=$(git diff 57a347c HEAD -- controllers/api/posts_helpers.go classes/format_html.go) && test -n "$d" && test -z "$(printf '%s\n' "$d" | grep -E '^-[^-]')" && git diff --quiet 57a347c HEAD -- go.mod go.sum && ! grep -Eiq 'grzyby|fonoteka|plytarium|płytarium' README.md && st=$(git status --porcelain -- admin_harness_test.go updates/postgres_test.go models/post_translatable_smoke_test.go) && test -z "$st" && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -qi content ../sm-journal-plugin/models/category.go && awk '/^func serializeCategoryNode/,/^}/' ../sm-journal-plugin/controllers/api/posts_helpers.go | grep -qi content && go -C ../sm-journal-plugin vet ./... && grep -qF 'richeditor' .planning/todos/pending/cabana-richeditor-field-type.md && echo d21-gate-ok</automated>
|
||||
</verify>
|
||||
<done>The full sm-journal-plugin suite is green without -short, and go vet is clean. The "category content" commits touch only allowed files. posts_helpers.go and format_html.go have only additions, and go.mod and go.sum are unchanged. The README names no consuming application. 16-01's D-21 gate passes (the command ends with d21-gate-ok). The todo exists. The SUMMARY holds the Category content contract.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| administrator → admin Categories form → golem15_journal_categories.content | HTML authored by a backend user with golem15.journal.access_categories is stored as is |
|
||||
| golem15_journal_categories.content → anonymous GET /_journal/api/v1/categories → consumer v-html | Stored HTML crosses to every public visitor's DOM through the consuming frontend |
|
||||
| operator SQL / host import command → content column | Writes that bypass the admin save gate |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-mgk-01 | Tampering (stored XSS) | categoriesAdminController FormBeforeCreate/FormBeforeUpdate | high | mitigate | prepareCategoryWrite runs classes.CheckHTML (the same three FormatHTML regexes) on every admin create and update and answers 422 on content. TestCheckHTML pins each pattern, and TestCategoriesAdminContent proves the event-handler create and the javascript-URL update are refused with nothing written. |
|
||||
| T-mgk-02 | Tampering (gate bypass) | content written by SQL or a host import command | medium | transfer | No plugin write path exists besides the admin form (routes.go has no category write route). Direct writes are operator actions on trusted input. The README and the SUMMARY contract tell consumers to gate before rendering, and 16-01's gateJournalHtml (T-16-01) applies the same patterns before v-html. |
|
||||
| T-mgk-03 | Information Disclosure | GET /categories content key | low | accept | Category content is public editorial copy by design (D-21). The row adds no user, credential or draft data, and post rows do not gain the key (TestJournalCategoryContentAPI checks the exact key set {id, name, slug}). |
|
||||
| T-mgk-04 | Denial of Service | GET /categories payload size | low | accept | All category bodies (about 80 KB for the target site) return on each call. The route stays in the throttle:journal-public-api bucket (120 per minute per client IP), and the consumer caches with swr of at least 60 s (16-RESEARCH Pitfall 5). The category count is small. |
|
||||
| T-mgk-05 | Tampering | shared plugin API contract (Phase 15 D-14; D-21 exemption) | medium | mitigate | Additive only. The pure-addition diff gates on posts_helpers.go and format_html.go, unchanged go.mod and go.sum, and the commit file allow-lists are checked in Tasks 1 to 3. The existing TestJournalPublicCategories and the full suite pass unedited. |
|
||||
| T-mgk-06 | Elevation of Privilege | Category.Fillable now includes content | low | mitigate | Only the admin form fills it, behind the controller's RequiredPermissions golem15.journal.access_categories. TestCategoriesAdminContent proves a posts-only token gets 403. nest_* and id stay out of Fillable (TestFillable). |
|
||||
| T-mgk-SC | Tampering | npm/pip/cargo/go installs | low | accept | No package installs and no new Go requirement. The go.mod and go.sum diff gates prove it. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test ./... -count=1 (without -short, with Docker or the documented fallback) are green in sm-journal-plugin.
|
||||
- TestSerializeCategoryNodeContent, TestJournalCategoryContentAPI, TestCategoryContentMigration, TestCheckHTML and TestCategoriesAdminContent pass and are not skipped. The existing TestJournalPublicCategories, TestJournalMigrationsRollbackAndRemigrate, TestJournalTables, TestTranslatable and TestPostsFormCompiles still pass.
|
||||
- GET /_journal/api/v1/categories rows carry content (an HTML string or null). Post rows' categories entries are still {id, name, slug}.
|
||||
- The "category content" commits touch only the allowed files, and posts_helpers.go and format_html.go diffs are additions only.
|
||||
- From summercms.go, 16-01 Task 1's D-21 gate passes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 16-01 Task 1's D-21 checks pass: grep -qi content on models/category.go, the serializeCategoryNode awk extraction contains content, and go -C ../sm-journal-plugin vet ./... is green. Its layer reads the key content as an HTML string or null.
|
||||
- 16-03 Task 2's grzyby:import-categories can write golem15_journal_categories.content alongside name and description, and the categories API then serves it for /cordyceps.
|
||||
- An editor can change a category's HTML body in the admin, and unsafe HTML is refused there before it reaches the public API.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261009-mgk-add-the-category-content-field-to-sm-jou/261009-mgk-SUMMARY.md` when done. Include the "Category content contract" section (Task 3), both sm-journal-plugin commit SHAs, and how the Postgres suites were run (Docker or the fallback).
|
||||
</output>
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
phase: quick-261009-mgk
|
||||
plan: 01
|
||||
subsystem: sm-journal-plugin (categories API, admin form, migrations)
|
||||
status: complete
|
||||
tags: [journal, categories, d-21, admin, html-gate, migration]
|
||||
requires:
|
||||
- sm-journal-plugin at 57a347c (D-20 landed)
|
||||
provides:
|
||||
- "content key (HTML string or null) on every GET /_journal/api/v1/categories row"
|
||||
- "golem15_journal_categories.content TEXT NULL (migration 202610090001_add_content_to_golem15_journal_categories)"
|
||||
- "models.Category.Content (*string), content in Category.Fillable"
|
||||
- "admin Categories form content textarea behind classes.CheckHTML"
|
||||
affects:
|
||||
- 16-01 Task 1 (Prerequisite contract, gateJournalHtml layer)
|
||||
- 16-03 Task 2 (grzyby:import-categories writes the column)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Form hooks (FormBeforeCreate/FormBeforeUpdate) gate stored-as-is HTML with the FormatHTML regexes"
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/updates/202610090001_add_content_to_golem15_journal_categories.go
|
||||
- ../sm-journal-plugin/updates/category_content_migration_test.go
|
||||
- ../sm-journal-plugin/controllers/api/categories_test.go
|
||||
- ../sm-journal-plugin/journal_api_categories_test.go
|
||||
- ../sm-journal-plugin/classes/check_html_test.go
|
||||
- .planning/todos/pending/cabana-richeditor-field-type.md
|
||||
modified:
|
||||
- ../sm-journal-plugin/models/category.go
|
||||
- ../sm-journal-plugin/models/fillable_test.go
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/controllers/api/posts_helpers.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/updates/postgres_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
decisions:
|
||||
- "Category content is a non-translatable HTML host column, stored and served as is, and edited as a textarea (size huge) because cabana has no WYSIWYG type"
|
||||
- "The admin save gate is classes.CheckHTML (the FormatHTML three-pattern gate). Blank content is stored as NULL. Any other content is stored exactly as submitted."
|
||||
- "The Category.Content doc note is a trailing line comment, so gofmt does not realign the existing struct fields"
|
||||
metrics:
|
||||
duration: ~10 min
|
||||
completed: 2026-10-09
|
||||
actuals:
|
||||
tokens: 7700
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 57a347c97bafcc99fb83f09d5ddc1bfd8df32280
|
||||
plan_head_after: 3d533d49f00d49c7da051dfcb3520fc725ce3d20
|
||||
---
|
||||
|
||||
# Quick 261009-mgk: Category content field in sm-journal-plugin Summary
|
||||
|
||||
Every Journal category now has an HTML `content` body. It is stored in a nullable TEXT column, edited in the admin as a textarea behind the `classes.CheckHTML` gate, and served as is on every row of the anonymous `GET /_journal/api/v1/categories` response, children included.
|
||||
|
||||
The `commits`, `plan_head_before` and `plan_head_after` values above are measured in **sm-journal-plugin** with `git rev-list --count 57a347c..HEAD` (= 3). The token figure is chars/4 over `git diff 57a347c HEAD`, which comes to 7698. That is well below the plan's 85000 estimate because the estimate covered reading context, not only the diff.
|
||||
|
||||
## Commits (sm-journal-plugin, branch main, not pushed)
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | `740dcd0` (740dcd086d722acdc126d1537b5f983d45c2b2f8) | feat(api): serve the category content field on GET /_journal/api/v1/categories |
|
||||
| 2 | `f22d089` (f22d08902e55fd9826116db90c20480b2486537c) | feat(admin): edit category content in the admin form behind the HTML gate |
|
||||
| 3 (fix) | `3d533d4` (3d533d49f00d49c7da051dfcb3520fc725ce3d20) | test(updates): expect category content in the Category.Fillable list pinned by TestJournalTables |
|
||||
|
||||
No commit carries a co-author or attribution trailer. Nothing was tagged or pushed, and no require was bumped.
|
||||
|
||||
## Category content contract
|
||||
|
||||
This section is for 16-01 Task 1 to copy into its Prerequisite contract, and for 16-03 Task 2 to use.
|
||||
|
||||
- **API key.** `content` appears on every row of `GET /_journal/api/v1/categories`, children included, with and without `include_empty=1`. Its value is an HTML string or JSON `null`. It is stored and returned as is: not markdown, not rendered, not trimmed and not sanitized. The JSON encoder may escape `<`, `>` and `&` as `<`, `>` and `&`, and a JSON parser restores them. The other row keys (`id`, `name`, `slug`, `description`, `parent_id`, `nest_depth`, `post_count`, and `children` when present) and the empty-category rule are unchanged, so an empty category still needs `include_empty=1` (D-24).
|
||||
- **Not translatable.** There is one host value for every locale. `Category.Translatable()` is still name, description and slug.
|
||||
- **Storage.**
|
||||
- Go field: `models.Category.Content` (`*string`, gorm `column:content`).
|
||||
- Column: `golem15_journal_categories.content`, `TEXT NULL`.
|
||||
- Migration ID: `202610090001_add_content_to_golem15_journal_categories`. It is the last entry of `updates.All()`. `RollbackLast()` drops only that column and keeps the rows.
|
||||
- **Admin.**
|
||||
- The field is a `textarea` with size `huge` and span `full`, placed right after description, with en and pl label and comment phrases.
|
||||
- `Category.Fillable` is now name, slug, code, description, content, parent_id.
|
||||
- The save gate is `classes.CheckHTML`. Raw script, iframe, object or embed tags, event-handler attributes, and javascript, vbscript or data URLs answer `422 validation_failed` with details on `content`, and nothing is written.
|
||||
- Blank or whitespace-only content is stored as NULL.
|
||||
- **For 16-03.**
|
||||
- Write the column directly, with one UPDATE alongside name and description.
|
||||
- That path bypasses the admin gate, so the layer gate (16-01 `gateJournalHtml`, T-16-01) stays mandatory.
|
||||
- Stored content that fails the gate makes every later admin save of that category answer 422 on `content` until the content is fixed.
|
||||
- At planning time, the six pillar rows in the 2024 dump had 0 matches for all three patterns.
|
||||
- **Post rows are unchanged.** In `GET /_journal/api/v1/posts`, each `categories` entry still has exactly `id`, `name` and `slug`, and `TestJournalCategoryContentAPI` checks this.
|
||||
- **Payload growth.** Every `/categories` call returns all category content: about 80 KB for the six pillar pages. Consumers should cache it (16-01 swr).
|
||||
- **Commit SHAs.** API: `740dcd0`. Admin: `f22d089`.
|
||||
|
||||
## Test results
|
||||
|
||||
**Docker was not reachable.** User `golem` is not in the `docker` group, so `/var/run/docker.sock` answers permission denied. I therefore used the fallback described in the plan's context:
|
||||
|
||||
- I started a private, throwaway PostgreSQL 18.6 cluster with `initdb` in the session scratchpad (127.0.0.1:55493, no unix socket).
|
||||
- I made temporary, uncommitted `JOURNAL_PG_DSN` overrides to `startAdminPostgres` (admin_harness_test.go) and to both `startJournalPostgres` functions (updates/postgres_test.go and models/post_translatable_smoke_test.go).
|
||||
- I ran the suites, then reverted the three harness files with `git checkout --` before every `git add`, and stopped the cluster at the end.
|
||||
- `git status` in sm-journal-plugin shows only the pre-existing untracked `go.work.sum`.
|
||||
|
||||
| Command | Result |
|
||||
|---------|--------|
|
||||
| `gofmt -l .` | clean (after the harness revert) |
|
||||
| `go vet ./...` | green (after the harness revert) |
|
||||
| Task 1 selections | `--- PASS` for TestSerializeCategoryNodeContent, TestJournalCategoryContentAPI, TestJournalPublicCategories, TestCategoryContentMigration, TestJournalMigrationsRollbackAndRemigrate and TestJournalTables |
|
||||
| Task 2 selections | `--- PASS` for TestCheckHTML, TestFormatHTMLRejectsUnsafeHTML, TestFillable and TestTranslatable (`-args -test.short`), plus TestCategoriesAdminContent (4 subtests), TestJournalCategoryContentAPI and TestPostsFormCompiles |
|
||||
| `go test ./... -count=1 -v` (no `-short`, fresh database `fullrun3`) | all five packages ok: 49 top-level PASS, 0 SKIP, 0 FAIL |
|
||||
| 16-01 Task 1 D-21 gate, run from summercms.go | `d21-gate-ok` |
|
||||
|
||||
**RED evidence:**
|
||||
- TestSerializeCategoryNodeContent failed to build (`unknown field Content`).
|
||||
- TestCategoryContentMigration failed: the last migration was `202610060007_add_author_slug_to_backend_users`.
|
||||
- TestCheckHTML failed to build (`undefined: CheckHTML`).
|
||||
- TestFillable failed on the old allow-list.
|
||||
- TestCategoriesAdminContent failed because the form schema had no content field.
|
||||
|
||||
Before release, re-run `go test ./... -count=1` with no overrides on a machine where Docker is reachable, to confirm the result on Postgres 16 through testcontainers.
|
||||
|
||||
## Additive-only proof (Task 3)
|
||||
|
||||
- 3 commits since 57a347c have "category content" in the subject. The plan required at least 2.
|
||||
- The `controllers/api/posts_helpers.go` and `classes/format_html.go` diffs against 57a347c contain no removed lines.
|
||||
- `go.mod` and `go.sum` are unchanged.
|
||||
- The README names no consuming application.
|
||||
- The harness files are clean.
|
||||
- **One gate check fails on purpose.** The fix commit `3d533d4` touches `updates/postgres_test.go`, which is outside the Task 1 and Task 2 file allow-lists, so the union-of-files check flags it (see deviation 1). Both feat commits stay inside their own allow-lists.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] A second existing test also pins the exact Category.Fillable list**
|
||||
- **Found during:** Task 3, full suite run.
|
||||
- **Issue:** `TestJournalTables` in `updates/postgres_test.go` asserts `Category.Fillable` equals `name, slug, code, description, parent_id`, just as `models/fillable_test.go` does. The plan assumed `models/fillable_test.go` was the only test that pins this list. Task 1's selection passed because Fillable had not changed yet. After Task 2, this test failed.
|
||||
- **Fix:** I updated that one expected slice to include `content`. This is the same edit the plan approved for `models/fillable_test.go`. Removing `content` from Fillable was not an option, because cabana fills only Fillable fields and the admin could then never save content (D-21).
|
||||
- **Committed separately** as `3d533d4`, with "category content" in the subject as Task 3 requires. As a result, the Task 3 allow-list check flags `updates/postgres_test.go`. I left that visible rather than wording the subject to dodge the grep.
|
||||
- **Files modified:** `../sm-journal-plugin/updates/postgres_test.go` (one line).
|
||||
|
||||
**2. [Rule 3 - Blocking] The fallback database kept state between full runs**
|
||||
- **Found during:** Task 3.
|
||||
- **Issue:** The `models` package writes into the shared harness database itself, not a dedicated one. A second full run against the same cluster database failed in `TestPostTranslatableSmoke` with a duplicate `hello` slug. testcontainers starts a fresh container on every run, so the real harness would not hit this.
|
||||
- **Fix:** I ran the acceptance pass against a newly created, empty database (`fullrun3`). No code changes.
|
||||
|
||||
**3. Model doc comment placement.** The plan asked for a doc comment on `Category.Content`. A comment line above the field split gofmt's alignment block and realigned every other field, so I used a trailing line comment instead. The `models/category.go` diff therefore adds only the field line, plus the Fillable change in Task 2.
|
||||
|
||||
**4. README schema line.** "Seven gormigrate steps..." now reads "Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column." This keeps the existing Features text accurate. It was not in the plan's README bullet list.
|
||||
|
||||
**5. Todo not committed.** `.planning/todos/pending/cabana-richeditor-field-type.md` was created and left uncommitted, as the orchestrator instructed. This overrides plan Task 3 step 4's "commit it alone". This SUMMARY is also uncommitted, and STATE.md and ROADMAP.md were not touched.
|
||||
|
||||
**6. Extra test coverage.** TestSerializeCategoryNodeContent also checks that the root keeps its exact content when include_empty is false.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The new surface is the subject of the plan's threat model, and each mitigated item has a test:
|
||||
- **T-mgk-01:** TestCheckHTML covers each pattern. TestCategoriesAdminContent shows the event-handler create and the javascript-URL update both answer 422, with nothing written.
|
||||
- **T-mgk-05:** the additive-only gates.
|
||||
- **T-mgk-06:** a posts-only token gets 403. nest_* and id stay out of Fillable.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: every created file listed in key-files, in sm-journal-plugin and in summercms.go.
|
||||
- FOUND: commits `740dcd0`, `f22d089` and `3d533d4` on sm-journal-plugin `main`.
|
||||
- Caveat, not a missing item: the testcontainers-based `go test ./...` was not run, because Docker is unreachable. The equivalent real-Postgres run passed, as described under Test results.
|
||||
@@ -0,0 +1,617 @@
|
||||
---
|
||||
phase: quick-261009-mys
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/account_test.go
|
||||
- modules/cabana/account_internal_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/app/pageTitle.ts
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/components/shell/UserMenu.vue
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/tests/helpers.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- admin/tests/shell/UserMenu.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- admin/tests/views/AccountView.test.ts
|
||||
- modules/boardwalk/dist/index.html
|
||||
- modules/boardwalk/dist/assets/
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261009-mys]
|
||||
|
||||
estimate:
|
||||
tokens: 170000
|
||||
raw_tokens: 170000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "backend_users gains WinterCMS's nullable TEXT metadata column through a new gormigrate entry 202610090001_backend_users_metadata, appended last to lagoon.BackendAdminMigrations. RollbackLast drops only that column and keeps every row; migrate adds it back. A row copied from WinterCMS with metadata {\"subtitle\":\"Editor\",\"x\":1} loads unchanged."
|
||||
- "The subtitle is stored as metadata.subtitle, the key the WinterCMS Journal author component reads. cabana.BackendUser.Subtitle() returns it and treats NULL, blank, null, [], invalid JSON and a non-string value as an empty subtitle, so no stored metadata can break admin sign-in. Saving a subtitle changes only that key and keeps every other metadata key; saving an empty subtitle removes the key."
|
||||
- "The avatar is a WinterCMS attachOne: one system_files row with attachment_type Backend\\Models\\User (cabana.BackendUserMorphType), field avatar (cabana.BackendUserAvatarField), attachment_id the admin's id and is_public true, so avatar rows copied from WinterCMS are picked up as they are."
|
||||
- "GET <prefix>/api/v1/auth/me always returns subtitle and, when the admin has an avatar, avatar {url, thumb_url}. thumb_url is a 250x250 crop thumbnail. The top bar user menu shows that image in place of the initials."
|
||||
- "PUT <prefix>/api/v1/account changes only the signed-in admin's own row. The row id comes from the principal and never from the request. It accepts first_name, last_name, email, subtitle, current_password, password and password_confirmation, and nothing else. Any other key (login, role_id, is_superuser, permissions, is_activated, id and so on) answers 422 validation_failed with a detail on that key, and nothing is written."
|
||||
- "Email is required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of any other admin, soft-deleted admins included. The admin's own email in a different case is accepted. A unique-index race also answers 422 on email, never 500."
|
||||
- "A password change needs current_password matching the stored bcrypt hash (otherwise 422 on current_password, logged as admin.auth failed, nothing written), a password of at least 4 characters and at most 72 bytes, and an equal password_confirmation. On success the password is rehashed at admin.password.bcrypt_cost and tokens_valid_after is set to the start of the current second, which ends every other session. A cookie-transport request gets a fresh session cookie and stays signed in."
|
||||
- "POST <prefix>/api/v1/account/avatar takes one multipart part, file_data. It accepts only images (extension jpg, jpeg, png, gif or webp, a sniffed image MIME type and the attach.IsAllowedImage decode guard) of at most cabana.AvatarMaxBytes (2 MiB), further capped by http.body_limits.upload_bytes. The disk name is generated by the server and the client file name is never used in a storage key. The upload replaces the previous avatar; the old row is deleted in the same transaction and its blobs after the commit. DELETE <prefix>/api/v1/account/avatar removes the avatar and is idempotent."
|
||||
- "In the admin SPA the avatar menu has a My account item that opens /account. That page edits first name, last name, email, subtitle and avatar (upload and remove), changes the password with the current password, shows 422 errors on their fields, and updates the top bar without a reload. Login is shown read-only."
|
||||
- "All three new routes sit behind the backend guard and requireAjax, and they are in every route inventory: the permission matrix, the CSRF walk (26 becomes 29), the unsafe-route coverage list and the OpenAPI conformance cases. admin.json and schema.d.ts are regenerated from the swag annotations."
|
||||
- "cabana and lagoon READMEs, docs/backend/users-and-permissions.md and docs/backend/admin-spa.md describe the feature in the same commits as the code. They name no consuming application. go vet ./..., go test ./..., TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check and check-admin-dist.sh are green, and go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged."
|
||||
artifacts:
|
||||
- path: modules/lagoon/backend_admin_migrations.go
|
||||
provides: "202610090001_backend_users_metadata: ADD COLUMN IF NOT EXISTS metadata TEXT, rollback DROP COLUMN IF EXISTS metadata"
|
||||
contains: "202610090001_backend_users_metadata"
|
||||
- path: modules/cabana/contracts.go
|
||||
provides: "BackendUser.Metadata (*string, column metadata), Subtitle, MorphName, AttachRelations, BackendUserMorphType, BackendUserAvatarField"
|
||||
contains: "column:metadata"
|
||||
- path: modules/cabana/account.go
|
||||
provides: "accountUpdate, avatarUpload, avatarRemove handlers, profileFor, BackendUserAvatar, AvatarMaxBytes, metadata subtitle helpers"
|
||||
contains: "func BackendUserAvatar"
|
||||
- path: modules/cabana/admin_openapi.go
|
||||
provides: "AdminProfile.Subtitle and Avatar, AdminAvatar, AdminAccountRequest, swag docs for PUT /account, POST and DELETE /account/avatar"
|
||||
contains: "@Router /account [put]"
|
||||
- path: admin/src/views/AccountView.vue
|
||||
provides: "My account screen: profile fields, avatar upload and remove, password change"
|
||||
contains: "/account"
|
||||
- path: admin/src/components/shell/UserMenu.vue
|
||||
provides: "My account menu item and avatar image in the trigger and the menu header"
|
||||
contains: "data-action=\"account\""
|
||||
- path: modules/cabana/account_test.go
|
||||
provides: "Postgres-backed tests of the account routes, the profile payload and the avatar lifecycle"
|
||||
contains: "TestAccount"
|
||||
- path: admin/tests/views/AccountView.test.ts
|
||||
provides: "SPA tests of the My account screen"
|
||||
contains: "AccountView"
|
||||
key_links:
|
||||
- from: modules/cabana/http.go
|
||||
to: modules/cabana/account.go
|
||||
via: "mount registers PUT /account, POST /account/avatar and DELETE /account/avatar in the backend-guarded group, each wrapped in requireAjax"
|
||||
pattern: "requireAjax\\(s\\.(accountUpdate|avatarUpload|avatarRemove)\\)"
|
||||
- from: modules/cabana/auth.go
|
||||
to: modules/cabana/account.go
|
||||
via: "me answers s.profileFor, which adds Subtitle() and the BackendUserAvatar URLs"
|
||||
pattern: "profileFor"
|
||||
- from: admin/src/components/shell/UserMenu.vue
|
||||
to: admin/src/views/AccountView.vue
|
||||
via: "the My account item pushes the account route registered in router.ts"
|
||||
pattern: "name: 'account'"
|
||||
- from: admin/src/views/AccountView.vue
|
||||
to: admin/src/state/useAuth.ts
|
||||
via: "every successful save, upload or remove passes the returned AdminProfile to setCurrentUser, so the top bar updates"
|
||||
pattern: "setCurrentUser"
|
||||
- from: modules/cabana/admin_openapi.go
|
||||
to: admin/src/api/schema.d.ts
|
||||
via: "scripts/check-admin-openapi.sh regenerates admin.json and schema.d.ts; types.ts aliases AdminAvatar and AdminAccountRequest"
|
||||
pattern: "cabana.AdminAccountRequest"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Give backend administrators a subtitle and an avatar, and add a WinterCMS-style My account page, opened from the avatar menu, where an admin edits their own profile and password.
|
||||
|
||||
**Repository.** All code lands in summercms.go, the framework. It stays application-neutral: no consuming-application names in code, READMEs or docs. Planning documents (this plan and its SUMMARY) are committed separately by the orchestrator. Never stage `.planning/`, `.msd/` or `.planning/state.json` together with code.
|
||||
|
||||
Purpose: WinterCMS keeps a free-form `metadata` JSON column and an `avatar` attachOne on `backend_users`, and its Journal author component shows `metadata.subtitle`. The Go `backend_users` table has neither, so authors imported into the framework lose their subtitle and picture, and no admin can edit them (todo `journal-author-profile-fields`). This plan settles the framework storage the same way WinterCMS does and adds the editor.
|
||||
|
||||
Output:
|
||||
- Three code commits in summercms.go:
|
||||
1. The profile slice: migration, model, GET /auth/me subtitle, PUT /account, SPA page and menu entry.
|
||||
2. The avatar slice: attachment, upload and remove routes, avatar in /auth/me, SPA upload and top-bar image.
|
||||
3. Unit tests that cover both slices in full.
|
||||
- A SUMMARY that says which part of the todo is closed.
|
||||
|
||||
Source coverage:
|
||||
- Task description, subtitle and avatar on backend users: Tasks 1 and 2.
|
||||
- My account page reached from the avatar menu: Tasks 1 and 2.
|
||||
- The admin edits their own data, enforced server-side: Task 1.
|
||||
- Password change requires the current password: Task 1.
|
||||
- Avatar validation of type and size, and safe storage names: Task 2.
|
||||
- Avatar and subtitle on the current-user payload: Tasks 1 and 2.
|
||||
- Additive migration with rollback: Task 1.
|
||||
- README and docs updated in the same change: Tasks 1 and 2.
|
||||
- Unit tests in the last task: Task 3.
|
||||
- Todo `journal-author-profile-fields`: this plan closes its framework-storage half. Still open: the description field and the plugin's author payload (see output).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@CLAUDE.md
|
||||
@.planning/STATE.md
|
||||
@.planning/todos/pending/journal-author-profile-fields.md
|
||||
@modules/lagoon/backend_admin_migrations.go
|
||||
@modules/cabana/contracts.go
|
||||
@modules/cabana/auth.go
|
||||
@modules/cabana/http.go
|
||||
@modules/cabana/admin_openapi.go
|
||||
@modules/cabana/field_file.go
|
||||
@modules/lagoon/attach/store.go
|
||||
@modules/lagoon/attach/file.go
|
||||
@admin/src/components/shell/UserMenu.vue
|
||||
@admin/src/state/useAuth.ts
|
||||
@admin/src/app/router.ts
|
||||
@admin/src/views/SettingsFormView.vue
|
||||
|
||||
Facts established while planning (do not re-derive):
|
||||
|
||||
- **WinterCMS reference** (examples/golem15-wintercms-starter, modules/backend):
|
||||
- `Backend\Controllers\Users::myaccount` edits the signed-in admin's own record in the `myaccount` form context. That form shows login, email, first_name, last_name, password, password_confirmation and the `avatar` fileupload (mode image, 250x250). Role, groups, superuser and permissions are excluded from that context.
|
||||
- After a password or login change, Winter re-authenticates the user.
|
||||
- `Backend\Models\User` has `$attachOne = ['avatar' => System\Models\File]` and `$jsonable = ['permissions', 'metadata']`.
|
||||
- Migration `2023_02_16_000012_Db_Backend_Add_User_Metadata` adds `metadata` as a nullable mediumText.
|
||||
- Validation: email `required|between:6,255|email|unique`, password `min:4|confirmed`.
|
||||
- Lang: `backend::lang.myaccount.menu_label` is "My account" and `backend::lang.user.avatar` is "Avatar".
|
||||
- **Where the PHP Journal plugin keeps the subtitle.** The WinterCMS Journal `components/Author.php` reads `$this->author->metadata['subtitle']`, the core `backend_users.metadata` JSON. The Go column is therefore `metadata` (TEXT, Winter jsonable), with the subtitle under the `subtitle` key, not a dedicated column. A verbatim row copy then carries the subtitle without any transform.
|
||||
- **Stored metadata can be odd.** PHP `json_encode([])` writes `[]`, so a copied row can hold `[]`, NULL or blank. Map `BackendUser.Metadata` as a raw `*string` (as `Permissions` is a raw string). Never use a strict `lagoon.Jsonable[map...]` type here: a scan error would break the guard's `BackendUsers.FindByID` and lock every admin out. Parse leniently in helpers.
|
||||
- **No backend-user admin controller exists** in the framework. The only admin screens are plugin controllers, and backend admins are managed with the `admin:create` and `admin:reset-password` commands. My account is therefore the only editor of subtitle and avatar. There is no "users admin form" to extend.
|
||||
- **Admin route plumbing** (pattern commit `b492e79`, the markdown preview route):
|
||||
- Routes are mounted in `service.mount` (http.go) inside `r.GroupRaw(api, []string{"backend"}, ...)`. State-changing routes are wrapped in `requireAjax`.
|
||||
- Every route needs all of the following, in the same commit:
|
||||
- a swag doc function in admin_openapi.go;
|
||||
- a regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` (`bash scripts/check-admin-openapi.sh`);
|
||||
- an entry in `phase09Routes` (security_coverage_test.go, checked by TestPhase09PermissionMatrix);
|
||||
- a line in the `want` list of TestPhase10Coverage and an updated count message (phase10_coverage_test.go);
|
||||
- an updated count and comment in TestPhase10CSRF (phase10_csrf_test.go, now 26);
|
||||
- a case in TestPhase10OpenAPIConformance (openapi_conformance_test.go, decoded with DisallowUnknownFields against the admin.json schema).
|
||||
- The cases run in slice order with one Bearer token, and POST /auth/logout is last. A conformance case for PUT /account must not change the password: that sets tokens_valid_after and invalidates the token for later cases.
|
||||
- **Path choice.** Controller routes have at least three segments (`/{vendor}/{plugin}/{controller}`). `/account` and `/account/avatar` (one and two segments) collide with nothing, so no reserved vendor segment is needed. Do not use `/auth/me/avatar`: it would shadow a three-segment controller `auth.me.avatar`. In the SPA, `/account` is one segment and cannot collide with `/:vendor/:plugin/:controller`.
|
||||
- **Body helpers in cabana.**
|
||||
- `s.decodeCappedObject(w, r)` (relation_child.go) decodes a JSON object with UseNumber under `s.jsonCap()`. Too large is a `*http.MaxBytesError` (answer 413 `payload_too_large` as `writeRelationError` does), and anything malformed is `invalidBody()`.
|
||||
- Validation failures are `&ValidationError{Details: map[string]any{field: []string{msg}}}`, written with `writeCRUDError`.
|
||||
- `s.translator()` returns the phrasebook translator. `fieldMessage(ctx, tr, rule, field, params)` gives a `lagoon::validation` line with an English fallback.
|
||||
- **lagoon.ValidateRequest** supports `required`, `nullable`, `string`, `email`, `min`, `max`, `between` and closures built with `lagoon.CustomRule`.
|
||||
- It has no `confirmed` and no `unique` rule.
|
||||
- A non-implicit rule, custom rules included, is skipped for an absent or blank value.
|
||||
- So run the confirmation, current-password and uniqueness checks in Go after ValidateRequest, and add their messages to the same details map.
|
||||
- bcrypt refuses input over 72 bytes: `bouncer.HashPassword` returns an error.
|
||||
- **Upload plumbing to reuse** (field_file.go `fileUploadOn`):
|
||||
- Read `http.MaxBytesReader` through a `bodyReader`, then `r.MultipartReader()`, and require exactly one part named `file_data` with a non-blank file name.
|
||||
- Call `attach.Store(ctx, tx, bucket, attach.Upload{FileName, Body, Public}, attach.Limits{...})`. It generates the disk name, keeps only a sanitized base name as file_name, enforces MaxBytes while streaming, and in Image mode applies `attach.IsAllowedImage`.
|
||||
- Map errors with the `writeFileError` logic: `attach.ErrTooLarge`, `ErrFileType`, `ErrMIMEType` and `ErrNotImage` are a 422 on the field, and a `*http.MaxBytesError` is 413.
|
||||
- Store writes the blob before the row, so on a rolled-back transaction delete `attach.BlobKeys(*stored)` with `attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys)`.
|
||||
- The bucket comes from `s.bucket()`, and the cap arithmetic is `uploadCap` with `multipartOverhead`.
|
||||
- `attach.DefaultImageExtensions` is jpg, jpeg, png, gif and webp. `attach.AllowedImageMIMEs` lists the four raster MIME types.
|
||||
- `File.URL()` builds the public URL with no I/O. `File.Thumb(ctx, bucket, 250, 250, "crop")` returns a thumbnail URL and generates it lazily.
|
||||
- `fileItem` shows the pattern: a thumbnail failure is logged at warn level and the thumb_url is left out.
|
||||
- **Session plumbing** (auth.go):
|
||||
- `sessionToken(r)` reports whether the token came from the cookie.
|
||||
- `bouncer.MintAudience(s.secret, uitoa(id), s.issuer, s.ttl, bouncer.AudienceBackend)` mints a token, and `s.writeSessionCookie(w, token)` sets the cookie.
|
||||
- The guard rejects a token when `iat.Before(TokensValidAfter)`, and JWT iat has whole-second precision. Set the cutoff to `time.Now().UTC().Truncate(time.Second)` and mint after it, so the fresh token (iat in the same or a later second) passes. `admin:reset-password` instead uses now+1s, because it re-issues nothing.
|
||||
- `s.logAuth(r, outcome, id)` writes the `admin.auth` audit line.
|
||||
- **Admin SPA.**
|
||||
- Vue 3 and TypeScript, openapi-fetch client `api` (src/api/client.ts). It adds X-Requested-With and refreshes once on a 401, replaying a cloned request.
|
||||
- Every `backend::lang.*` key used under admin/src must resolve in both the en and pl catalogs (`modules/phrasebook/backend/lang/{en,pl}/lang.yaml`), or TestPhase10SPAKeysResolve fails `go test`.
|
||||
- tsconfig includes `tests/**/*.ts`, so a test fixture typed as AdminProfile without the new required `subtitle` breaks `npm --prefix admin run typecheck`.
|
||||
- The embedded build `modules/boardwalk/dist` is committed with the SPA change (`npm --prefix admin run build`). `bash scripts/check-admin-dist.sh` fails when it is stale.
|
||||
- Page pattern: SettingsFormView.vue (header, card form, fixed footer with Save, `FormErrorBanner`, `fieldErrors`, `showToast`). Plain input markup: LoginView.vue.
|
||||
- **Test harness.**
|
||||
- cabana_test: `adminGorm(t)` starts a shared testcontainers Postgres and runs `lagoon.Migrate`. `adminHandler(t, gdb, mutate)` assembles the router without a bucket, with upload_bytes 1 MiB. `insertAdmin(...)` and `getAuth`/`postAuth`/`postJSON`/`accessToken` are in auth_test.go. The conformance env has a memblob bucket.
|
||||
- lagoon: `dedicatedDB(t, name)` and `rollbackBackendAdmin` (which loops over `BackendAdminMigrations`, so the new entry is picked up).
|
||||
- **Docker is not reachable for user golem.** Integration fallback, proven by quick tasks i6e and mgk:
|
||||
1. Start a throwaway cluster in the session scratchpad: `initdb -D <scratch>/pg -U postgres --auth=trust`, then `pg_ctl -D <scratch>/pg -l <scratch>/pg.log -o "-p 55493 -k '' -c listen_addresses=127.0.0.1" start`.
|
||||
2. Make temporary, uncommitted edits to the Postgres starters: `adminGorm` in modules/cabana/auth_test.go, the container starter in modules/cabana/query_test.go, `startLagoonPostgres` in modules/lagoon/postgres_test.go, and modules/lagoon/attach/lifecycle_test.go if attach tests run. When env `SUMMER_TEST_PG_DSN` is set, each opens that DSN with `sql.Open("pgx", dsn)` instead of a container. Each stop function must tolerate a nil container.
|
||||
3. Use a fresh database per full run (`createdb -h 127.0.0.1 -p 55493 -U postgres runN`): the cabana harness keeps state in one database.
|
||||
4. Revert the harness files with `git checkout --` before every `git add`, and stop the cluster at the end.
|
||||
5. Record this in the SUMMARY.
|
||||
- Never pass `-short` to an acceptance run. Never commit a harness override.
|
||||
- **Planning-time HEAD** is `5ba1a4a`, used as the base of the diff gates.
|
||||
- **Repo hygiene.** Stage by explicit path. Commit messages carry no co-author or attribution trailer: the user's global and project CLAUDE.md override the session reminder.
|
||||
|
||||
Planning decisions (Claude's discretion; record them in the SUMMARY):
|
||||
- P-01 Subtitle in `metadata.subtitle`, not in its own column. This gives WinterCMS row parity and matches what the Journal author component reads. Unknown metadata keys are preserved.
|
||||
- P-02 Avatar as a public `system_files` attachOne under the WinterCMS morph name `Backend\Models\User`, field `avatar`, the same as WinterCMS. Public, because author pages will show it.
|
||||
- P-03 Routes `PUT /account`, `POST /account/avatar` and `DELETE /account/avatar`. The profile is read from the existing `GET /auth/me`, with no separate GET /account.
|
||||
- P-04 Login stays read-only on My account. The task lists first name, last name, email, subtitle, avatar and password. WinterCMS also lets an admin change their login there; changing it would also need the sign-in ambiguity checks.
|
||||
- P-05 An email change needs no current password (WinterCMS parity). A password change does need it (task requirement). The framework has no email-based password reset, so an email change cannot take over the account.
|
||||
- P-06 A password change ends every other session (tokens_valid_after) and keeps the current browser signed in with a fresh cookie, which matches Winter's re-login. A Bearer client must sign in again.
|
||||
- P-07 `cabana.AvatarMaxBytes` is a fixed 2 MiB with no new config key. `http.body_limits.upload_bytes` still caps the request.
|
||||
- P-08 The password rule is WinterCMS `min:4`, plus a 72-byte maximum, which bcrypt requires.
|
||||
- P-09 PUT /account has no throttle. It requires an authenticated session, and a login-style throttle would block normal saves. Failed current-password checks are logged instead.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: My account profile end-to-end: metadata column, subtitle, PUT /account, SPA page and menu entry</name>
|
||||
<files>modules/lagoon/backend_admin_migrations.go, modules/lagoon/README.md, modules/cabana/contracts.go, modules/cabana/account.go, modules/cabana/auth.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/account_test.go, modules/cabana/security_coverage_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/state/useAuth.ts, admin/src/app/router.ts, admin/src/app/pageTitle.ts, admin/src/components/shell/Breadcrumbs.vue, admin/src/components/shell/UserMenu.vue, admin/src/views/AccountView.vue, admin/tests/helpers.ts, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/, docs/backend/users-and-permissions.md, docs/backend/admin-spa.md</files>
|
||||
<action>Build the thinnest complete path first: an admin opens My account from the avatar menu, changes the subtitle, saves, and sees the change returned by GET /auth/me. Then fill in the rest of the profile form on the same path.
|
||||
|
||||
1. **Migration** (lagoon, per P-01):
|
||||
- Append `202610090001_backend_users_metadata` as the last entry of `lagoon.BackendAdminMigrations`.
|
||||
- Migrate: `ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT` (nullable, no default).
|
||||
- Rollback: `ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`.
|
||||
- Leave the two existing migrations untouched.
|
||||
- Update the doc comment on BackendAdminMigrations to name the column as WinterCMS's jsonable `metadata`.
|
||||
|
||||
2. **Model** (contracts.go):
|
||||
- Add `Metadata *string` with tag `gorm:"column:metadata"` to `BackendUser`, after `Permissions`.
|
||||
- Add the method `(BackendUser) Subtitle() string`.
|
||||
- In account.go, add two package-level helpers: `metadataString(raw *string, key string) string` and `setMetadataString(raw *string, key, value string) (*string, error)`.
|
||||
- Decode into `map[string]json.RawMessage` so other values survive unchanged.
|
||||
- Treat NULL, blank, `null` and `[]` as an empty object.
|
||||
- The reader returns "" for invalid JSON or a non-string value.
|
||||
- The writer deletes the key when value is "", and returns nil (SQL NULL) when no keys remain.
|
||||
- The writer returns an error, and never overwrites, when the stored text is neither empty nor `[]` nor a JSON object.
|
||||
|
||||
3. **Profile payload** (admin_openapi.go, auth.go, account.go):
|
||||
- Add `Subtitle string` with tag `json:"subtitle"` to `AdminProfile`.
|
||||
- Replace the free function `profileOf` with the method `(s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile`, and use it in `me`. Task 2 extends it with the avatar.
|
||||
|
||||
4. **PUT /account** (account.go, `(s *service) accountUpdate`), mounted in the backend-guarded group as `g.Put("/account", requireAjax(s.accountUpdate))`:
|
||||
- **Principal.** Read it from `bouncer.User`. It must be a backend principal, otherwise answer 401.
|
||||
- **Body.** Decode with `s.decodeCappedObject`.
|
||||
- **Allow-list (enforced server-side).** The accepted keys are first_name, last_name, email, subtitle, current_password, password and password_confirmation. Any other key answers 422 `validation_failed` with one detail per offending key. The message is `backend::lang.myaccount.field_not_editable`, translated through `s.translator()` in the request locale. Nothing is written.
|
||||
- **Transaction.** Inside `lagoon.Transaction`, load the user with `Preload("Role")` and `clause.Locking{Strength: "UPDATE"}` by `principal.ID`, never by any request value. A missing or deactivated user answers 401, as `me` does.
|
||||
- **Field rules,** through `lagoon.ValidateRequest` with an ordered `[]lagoon.RequestRule`:
|
||||
- first_name and last_name: `nullable|string|max:255`.
|
||||
- email: `required|string|email|between:6,255`.
|
||||
- subtitle: `nullable|string|max:255`.
|
||||
- password: `nullable|string|min:4|max:72`.
|
||||
- **Checks run in Go afterwards,** because custom rules are skipped for absent or blank values. Add their messages to the same details map, ordered by `lagoon.ErrorKeys`:
|
||||
- password over 72 bytes: the `max.string` line.
|
||||
- password_confirmation must equal password: `lagoon::validation.confirmed` through `fieldMessage`.
|
||||
- a non-blank password requires current_password (the `required` line), and `bouncer.CheckPassword` against the stored hash must pass. Otherwise use `backend::lang.myaccount.current_password_invalid` and call `s.logAuth(r, "failed", user.ID)`.
|
||||
- email uniqueness: `Unscoped` (soft-deleted rows included), `id <> user.ID AND (lower(email) = lower(?) OR lower(login) = lower(?))`, answered with the `unique` validation line.
|
||||
- A Postgres 23505 on `backend_users_email_lower_unique` maps to the same 422 on email.
|
||||
- **Write.** One `Updates` map with only these columns:
|
||||
- first_name, last_name and email, each trimmed;
|
||||
- metadata, through `setMetadataString(user.Metadata, "subtitle", trimmed subtitle)`; absent or null means "";
|
||||
- updated_at = now;
|
||||
- when a password was given: password (`bouncer.HashPassword(s.bcryptCost, ...)`) and tokens_valid_after = `time.Now().UTC().Truncate(time.Second)`.
|
||||
- Absent first_name, last_name or subtitle mean empty: PUT replaces the profile.
|
||||
- **Session (per P-06).** After the commit of a password change, when `sessionToken(r)` came from the cookie, mint a fresh backend token with `bouncer.MintAudience` and call `s.writeSessionCookie`. Call `s.logAuth(r, "password_changed", user.ID)`.
|
||||
- **Response.** 200 with `Envelope[AdminProfile]` built by `profileFor` from the reloaded row.
|
||||
- **Errors.** Malformed body 422, too large 413, anything else through `writeCRUDError`, logged.
|
||||
|
||||
5. **OpenAPI** (admin_openapi.go):
|
||||
- Add `AdminAccountRequest`: Email `json:"email"`; FirstName, LastName and Subtitle as `*string` with omitempty; CurrentPassword, Password and PasswordConfirmation as strings with omitempty. Its comment states that it documents the only accepted keys.
|
||||
- Add the doc function `AdminAccountUpdate` (`@Router /account [put]`, `@Security BackendBearer`, success `Envelope[AdminProfile]`, failures 401, 403, 413 and 422) in the style of `AdminMarkdownPreview`.
|
||||
- Extend the `AdminMe` doc description with subtitle.
|
||||
- Run `bash scripts/check-admin-openapi.sh` and commit admin.json and schema.d.ts.
|
||||
|
||||
6. **Inventories:**
|
||||
- `{key: "PUT /account"}` in `phase09Routes`.
|
||||
- `"PUT /account"` in the sorted `want` list of TestPhase10Coverage.
|
||||
- The CSRF count 26 becomes 27, in both the message and the comment list.
|
||||
- A conformance case "PUT /account" (200, `cabana.Envelope-cabana_AdminProfile`) that changes first_name and subtitle and keeps the conform admin's email, with no password. Place it before POST /auth/logout.
|
||||
|
||||
7. **Lang:** add a `myaccount:` group to both catalogs with these keys:
|
||||
- menu_label (My account / Moje konto)
|
||||
- menu_description
|
||||
- profile
|
||||
- login
|
||||
- first_name
|
||||
- last_name
|
||||
- email
|
||||
- subtitle
|
||||
- subtitle_comment
|
||||
- password_section
|
||||
- password_comment (leave blank to keep the current password)
|
||||
- current_password
|
||||
- password
|
||||
- password_confirmation
|
||||
- current_password_invalid
|
||||
- field_not_editable
|
||||
- saved
|
||||
|
||||
Every key the SPA uses must resolve in both locales.
|
||||
|
||||
8. **SPA:**
|
||||
- **types.ts:** alias `AdminAccountRequest`.
|
||||
- **useAuth.ts:** export `setCurrentUser(profile: AdminProfile)`.
|
||||
- **router.ts:** add `{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } }` before the controller routes.
|
||||
- **pageTitle.ts and Breadcrumbs.vue:** the account route shows `backend::lang.myaccount.menu_label`, as the settings route is handled.
|
||||
- **AccountView.vue:** follow the SettingsFormView layout and the LoginView input markup.
|
||||
- Login is read-only.
|
||||
- Inputs for first_name, last_name, email (type email) and subtitle, with its comment.
|
||||
- A password section with current_password (autocomplete current-password) and password and password_confirmation (autocomplete new-password).
|
||||
- Initial values come from `currentUser`.
|
||||
- Save sends `api.PUT('/account', { body })` with exactly the allowed keys, and the password keys only when a new password is typed.
|
||||
- On 200: `setCurrentUser`, clear the password inputs and show the toast `myaccount.saved`.
|
||||
- On 422: show the errors per field with `fieldErrors` and `FormErrorBanner`, and focus the first one.
|
||||
- On any other failure: show the `form.error_generic` toast.
|
||||
- **UserMenu.vue:** add a `DropdownMenuItem` with `data-action="account"`, a lucide user icon and the label `myaccount.menu_label`, above the separator and logout. It calls `router.push({ name: 'account' })`.
|
||||
- **admin/tests/helpers.ts:** update any profile fixture so typecheck and the existing tests stay green.
|
||||
- Run `npm --prefix admin run build` and commit `modules/boardwalk/dist`.
|
||||
|
||||
9. **Smoke test:** add `TestAccountProfileSmoke` in a new modules/cabana/account_test.go (package cabana_test, `adminGorm`, `adminHandler`, `insertAdmin`). Using a Bearer token, PUT /account sets names, email and subtitle, and GET /auth/me returns them. A body with `is_superuser` answers 422. Full coverage is Task 3.
|
||||
|
||||
10. **Docs, in this commit** (CLAUDE.md documentation rules):
|
||||
- **cabana README:**
|
||||
- a Features bullet for My account;
|
||||
- in the Admin API routes table, a PUT `/account` row, and GET `/auth/me` now returning subtitle;
|
||||
- in the API reference: `cabana.AdminProfile`, `cabana.AdminAccountRequest`, `cabana.BackendUser.Subtitle`.
|
||||
- **lagoon README:** BackendAdminMigrations adds Winter's nullable `metadata` column.
|
||||
- **docs/backend/users-and-permissions.md:** a `## My account` section covering:
|
||||
- which fields can be edited, and that login, role, superuser, permissions and activation cannot;
|
||||
- the email rules;
|
||||
- the password rules and session effects, including that a Bearer client signs in again;
|
||||
- storage in `backend_users.metadata` under `subtitle`, compatible with copied WinterCMS rows.
|
||||
- **docs/backend/admin-spa.md:** the `/account` screen opened from the user menu.
|
||||
- Use neutral names only. Name no consuming application.
|
||||
|
||||
11. **Commit** with subject `feat(cabana): add the My account page with subtitle and password change`. Stage by explicit path, with no planning files and no harness overrides.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && out=$(go test ./modules/cabana -count=1 -v -run '^(TestAccountProfileSmoke|TestPhase10OpenAPIConformance|TestPhase09PermissionMatrix|TestPhase09SecurityCoverage|TestPhase10CSRF|TestPhase10Coverage|TestAdminAuthLifecycle)$' 2>&1) && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestAccountProfileSmoke' && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestPhase10OpenAPIConformance' && go test ./modules/lagoon -count=1 -run '^(TestBackendAdmin|TestPhase09MigrationsFreshRollback)' && grep -qF '202610090001_backend_users_metadata' modules/lagoon/backend_admin_migrations.go && grep -qF 'requireAjax(s.accountUpdate)' modules/cabana/http.go && grep -qF "name: 'account'" admin/src/app/router.ts && grep -qF 'data-action="account"' admin/src/components/shell/UserMenu.vue && grep -qF 'PUT `/account`' modules/cabana/README.md && grep -qF '## My account' docs/backend/users-and-permissions.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -qF 'My account' && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- The migration adds and rolls back `backend_users.metadata`.
|
||||
- GET /auth/me returns subtitle.
|
||||
- PUT /account edits only the caller's own first_name, last_name, email, subtitle and (with current_password) password, and refuses every other key with 422.
|
||||
- The avatar menu opens /account, and saving there updates the top bar.
|
||||
- Inventories, admin.json, schema.d.ts, dist, READMEs and docs are in one commit.
|
||||
- Every check is green with Docker or the context fallback, which is reverted before staging.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Avatar end-to-end: attachOne relation, upload and remove routes, avatar in /auth/me, SPA upload and top-bar image</name>
|
||||
<files>modules/cabana/contracts.go, modules/cabana/account.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/account_test.go, modules/cabana/security_coverage_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/views/AccountView.vue, admin/src/components/shell/UserMenu.vue, admin/tests/helpers.ts, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/, docs/backend/users-and-permissions.md</files>
|
||||
<action>Add the avatar along the path Task 1 proved (per P-02 and P-07).
|
||||
|
||||
1. **Model** (contracts.go):
|
||||
- Exported constants `BackendUserMorphType = "Backend\\Models\\User"` (WinterCMS's class string, so copied rows match) and `BackendUserAvatarField = "avatar"`.
|
||||
- `BackendUser` implements `attach.Owner` (`MorphName()` returns BackendUserMorphType).
|
||||
- `BackendUser` implements `attach.HasRelations` (`AttachRelations()` returns one `attach.Relation{Name: BackendUserAvatarField, Public: true}`, with Many false).
|
||||
|
||||
2. **Lookup** (account.go):
|
||||
- Exported `const AvatarMaxBytes int64 = 2 << 20`.
|
||||
- Exported `func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error)`. It returns the newest `system_files` row (attachment_type BackendUserMorphType, field avatar, attachment_id the decimal id, ordered by id DESC), or nil, nil when there is none. Plugins can call it to show an author's picture.
|
||||
- Extend `profileFor` to set `Avatar *AdminAvatar` (admin_openapi.go: `AdminAvatar{URL string json:"url"; ThumbURL string json:"thumb_url,omitempty"}`, with tag `json:"avatar,omitempty"` on AdminProfile).
|
||||
- `URL` is `File.URL()`.
|
||||
- `ThumbURL` is `File.Thumb(ctx, bucket, 250, 250, "crop")`, only when `s.bucket()` is not nil and the content type is in `attach.AllowedImageMIMEs`.
|
||||
- A thumbnail error is logged at warn level and leaves thumb_url out. A lookup error is logged and leaves the avatar out, so /auth/me never fails on it.
|
||||
|
||||
3. **POST /account/avatar** (`(s *service) avatarUpload`, mounted as `g.Post("/account/avatar", requireAjax(s.avatarUpload))`):
|
||||
- The principal is resolved as in Task 1. A nil bucket answers 500 and is logged.
|
||||
- The cap is `AvatarMaxBytes + multipartOverhead`, lowered to `s.uploadBytes` when that is set and smaller.
|
||||
- Read the body as `fileUploadOn` does. Require exactly one part named `file_data` with a non-blank file name, otherwise answer 422 `invalidBody()`. A trailing part is refused.
|
||||
- Inside `lagoon.Transaction`:
|
||||
- lock the user row FOR UPDATE by `principal.ID` (this serialises concurrent avatar changes);
|
||||
- call `attach.Store` with `attach.Upload{FileName: part.FileName(), Body: part, Public: true}` and `attach.Limits{MaxBytes: AvatarMaxBytes, Extensions: attach.DefaultImageExtensions, MIMETypes: attach.AllowedImageMIMEs, Image: true}`;
|
||||
- set attachment_id, attachment_type (BackendUserMorphType) and field (avatar) on the stored row;
|
||||
- load the previous avatar rows FOR UPDATE and delete them, collecting `attach.BlobKeys` of each.
|
||||
- After the commit, call `attach.DeleteKeys(context.WithoutCancel(ctx), bucket, oldKeys)`, logging a failure without failing the request.
|
||||
- On a rolled-back transaction, delete the new blob's keys.
|
||||
- Map errors as `writeFileError` does, with the field name `avatar`:
|
||||
- ErrTooLarge: `max.file` with the KB value of AvatarMaxBytes.
|
||||
- ErrFileType and ErrMIMEType: `mimes` listing the default image extensions.
|
||||
- ErrNotImage: `image`.
|
||||
- MaxBytesError: 413.
|
||||
- Never use the client file name in any key or path. Store generates the disk name.
|
||||
- Answer 200 with `Envelope[AdminProfile]` from `profileFor`.
|
||||
|
||||
4. **DELETE /account/avatar** (`(s *service) avatarRemove`, `g.Delete("/account/avatar", requireAjax(s.avatarRemove))`):
|
||||
- In a transaction, lock the user, then delete every avatar row of the user and collect the blob keys.
|
||||
- After the commit, delete the blobs.
|
||||
- Answer 200 with the profile, also when there was no avatar.
|
||||
|
||||
5. **OpenAPI:**
|
||||
- Add the doc functions `AdminAccountAvatarUpload` (`@Accept multipart/form-data`, `@Param file_data formData file true "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)"`, `@Router /account/avatar [post]`, failures 401, 403, 413 and 422) and `AdminAccountAvatarRemove` (`@Router /account/avatar [delete]`).
|
||||
- Extend the AdminMe description with the avatar.
|
||||
- Regenerate with `bash scripts/check-admin-openapi.sh`.
|
||||
- Alias `AdminAvatar` in types.ts.
|
||||
|
||||
6. **Inventories:**
|
||||
- phase09Routes gets `POST /account/avatar` and `DELETE /account/avatar`.
|
||||
- TestPhase10Coverage `want` gets both lines.
|
||||
- The CSRF count 27 becomes 29.
|
||||
- Two conformance cases before logout: an upload of `conformPNG(t)` (200, `cabana.Envelope-cabana_AdminProfile`; assert avatar.url is set), then a delete (200).
|
||||
|
||||
7. **Lang:** add these keys to both catalogs:
|
||||
- avatar
|
||||
- avatar_upload
|
||||
- avatar_remove
|
||||
- avatar_remove_confirm
|
||||
- avatar_hint (JPG, PNG, GIF or WebP, up to 2 MB)
|
||||
- avatar_saved
|
||||
- avatar_removed
|
||||
|
||||
8. **SPA:**
|
||||
- **AccountView.vue gets an avatar block:**
|
||||
- It shows the current thumb_url, or url, or the initials.
|
||||
- A hidden file input with `accept="image/jpeg,image/png,image/gif,image/webp"` sits behind an Upload button.
|
||||
- The upload goes through `api.POST('/account/avatar', ...)` with a `bodySerializer` that returns a FormData holding the single part `file_data`. The typed client's 401 refresh-and-replay then applies. Do not set Content-Type by hand.
|
||||
- A Remove button confirms through the existing confirm helper in components/ui/confirm.ts, then calls `api.DELETE('/account/avatar')`.
|
||||
- Both pass the returned profile to `setCurrentUser` and show a toast.
|
||||
- A 422 shows the `avatar` detail under the block, and a 413 shows the hint.
|
||||
- **UserMenu.vue:** when `currentUser.avatar` is set, render an `img` (attribute `data-user-avatar`, `alt=""`, rounded-full, object-cover, the same size as the initials circle) in the trigger and in the menu header. Otherwise keep the initials span with `data-user-initials`.
|
||||
- Update the profile fixtures in admin/tests/helpers.ts if typecheck needs it.
|
||||
- Rebuild the dist.
|
||||
|
||||
9. **Smoke test:** add `TestAccountAvatarSmoke` to account_test.go, with a local helper that builds the handler like `adminHandler` but also publishes a memblob bucket through `attach.Publish` and sets upload_bytes to 8 MiB. Uploading a PNG makes GET /auth/me return avatar.url and thumb_url, and DELETE removes the avatar.
|
||||
|
||||
10. **Docs, in this commit:**
|
||||
- **cabana README:**
|
||||
- the POST and DELETE `/account/avatar` rows;
|
||||
- the /auth/me avatar;
|
||||
- in the API reference: `cabana.AdminAvatar`, `cabana.AvatarMaxBytes`, `cabana.BackendUserAvatar`, `cabana.BackendUserMorphType`, `cabana.BackendUserAvatarField`, `cabana.BackendUser.MorphName` and `cabana.BackendUser.AttachRelations`.
|
||||
- **users-and-permissions.md My account section:**
|
||||
- avatar storage (a public `system_files` attachOne, compatible with WinterCMS rows);
|
||||
- the type and size limits;
|
||||
- replace and remove semantics;
|
||||
- that plugins read the picture through `cabana.BackendUserAvatar` and the subtitle through `cabana.BackendUser.Subtitle`.
|
||||
|
||||
11. **Commit** with subject `feat(cabana): add backend user avatars to My account and the top bar`.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && out=$(go test ./modules/cabana -count=1 -v -run '^(TestAccountAvatarSmoke|TestAccountProfileSmoke|TestPhase10OpenAPIConformance|TestPhase09PermissionMatrix|TestPhase09SecurityCoverage|TestPhase10CSRF|TestPhase10Coverage)$' 2>&1) && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestAccountAvatarSmoke' && printf '%s\n' "$out" | grep -q -- '^--- PASS: TestPhase10OpenAPIConformance' && grep -qF 'requireAjax(s.avatarUpload)' modules/cabana/http.go && grep -qF 'requireAjax(s.avatarRemove)' modules/cabana/http.go && grep -qF 'func BackendUserAvatar(' modules/cabana/account.go && grep -qF 'data-user-avatar' admin/src/components/shell/UserMenu.vue && grep -qF 'cabana.BackendUserAvatar' modules/cabana/README.md && subj=$(git log -1 --format=%s) && printf '%s\n' "$subj" | grep -qF 'avatar' && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- An admin uploads and removes their avatar on My account.
|
||||
- The image is validated as a raster image of at most 2 MiB and stored under a server-generated name as a WinterCMS-compatible public attachOne.
|
||||
- It shows in the top bar and in GET /auth/me (url and thumb_url).
|
||||
- Replacing it deletes the old row and its blobs.
|
||||
- Inventories, OpenAPI output, dist, README and docs are in the same commit, and every check is green.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Unit tests: full coverage of the My account profile, password and avatar code (last task, per project rule)</name>
|
||||
<files>modules/cabana/account_test.go, modules/cabana/account_internal_test.go, modules/lagoon/backend_admin_migrations_test.go, admin/tests/views/AccountView.test.ts, admin/tests/shell/UserMenu.test.ts, admin/tests/state/useAuth.test.ts, admin/tests/fixtures/lang.json</files>
|
||||
<action>Bring this plan's code to full unit coverage. Change no production code. If a test exposes a bug, fix it in its own `fix(cabana): ...` commit before this one.
|
||||
|
||||
1. **modules/cabana/account_internal_test.go** (package cabana), table-driven, no database. It holds two tests, `TestMetadataSubtitleHelpers` and `TestBackendUserAttachContract`:
|
||||
- `metadataString` and `setMetadataString` over these inputs:
|
||||
- nil, "", "null" and "[]";
|
||||
- `{"subtitle":"Editor"}`;
|
||||
- `{"subtitle":5}` (reads "");
|
||||
- invalid JSON (reads "", and write returns an error);
|
||||
- `"text"` and `[1]` (write returns an error);
|
||||
- `{"subtitle":"a","x":{"y":[1,2]}}`: the write keeps x byte-equal;
|
||||
- clearing the last key returns nil.
|
||||
- `BackendUser.Subtitle`.
|
||||
- `MorphName` and `AttachRelations` values.
|
||||
|
||||
2. **modules/cabana/account_test.go** (package cabana_test), extended with subtests under these tests: `TestAccountProfileUpdate`, `TestAccountForbiddenKeys`, `TestAccountEmailRules`, `TestAccountPasswordChange`, `TestAccountAvatarUpload`, `TestAccountAvatarRefusals`, `TestAccountAvatarReplaceAndRemove`, `TestAuthMeProfilePayload`. Cover:
|
||||
- **Payload:** GET /auth/me returns subtitle "" and no avatar key for a fresh admin, and returns the subtitle of a row whose metadata was written with raw SQL as `{"subtitle":"Editor","x":1}`. A row with metadata `[]` or invalid JSON still signs in and serves /auth/me.
|
||||
- **Profile update:** names, email and subtitle are saved and trimmed. The x key survives a subtitle change. An empty subtitle removes the key. updated_at advances.
|
||||
- **Forbidden keys:** each of login, role_id, is_superuser, permissions, is_activated, id, password_hash and metadata answers 422 with that key in the details, and a raw SELECT shows the row unchanged. A second admin's row is never touched: same names, and the target is always the token subject.
|
||||
- **Email:** missing, invalid and short addresses answer 422. Another admin's email in a different case answers 422, as does another admin's login used as an email. A soft-deleted admin's email answers 422. The caller's own email in upper case is accepted.
|
||||
- **Password:**
|
||||
- A missing current_password answers 422 on current_password.
|
||||
- A wrong one answers 422 and writes an `admin.auth` failed log line (capture slog as TestAdminAuthLogging does). The hash is unchanged.
|
||||
- A mismatched confirmation answers 422. Under 4 characters answers 422. Over 72 bytes, for example 40 two-byte runes, answers 422 and never 500.
|
||||
- Success makes `bouncer.CheckPassword` pass with the new password and sets tokens_valid_after.
|
||||
- Over Bearer, the old token then gets 401 on /auth/me.
|
||||
- Over cookie transport (`X-Requested-With`, cookie from a cookie login), the response sets a new `cabana.AdminCookieName` cookie that works for /auth/me while the old cookie value gets 401.
|
||||
- **CSRF:** a cookie PUT without X-Requested-With is refused.
|
||||
- **Avatar upload** (helper with memblob bucket and upload_bytes 8 MiB):
|
||||
- A PNG creates exactly one system_files row: attachment_type `Backend\Models\User`, field avatar, attachment_id the id, is_public true.
|
||||
- Its disk_name contains no slash and does not contain the client name. An upload named `../../evil.png` stores file_name `evil.png` and a key under the partition directory.
|
||||
- /auth/me shows url and thumb_url.
|
||||
- **Avatar refusals:**
|
||||
- SVG bytes named .svg, and HTML bytes named .png, answer 422 on avatar.
|
||||
- A file over AvatarMaxBytes answers 413 or 422. Assert the status the code defines.
|
||||
- A wrong part name, no part, and two parts answer 422.
|
||||
- No bucket answers 500.
|
||||
- No row is left behind and no blob remains in the bucket for any refusal.
|
||||
- **Replace and remove:**
|
||||
- A second upload deletes the first row and its original and thumbnail blobs (`bucket.Exists` is false) and leaves one row.
|
||||
- DELETE removes the row and the blobs and answers 200 with no avatar key.
|
||||
- A second DELETE also answers 200.
|
||||
- `cabana.BackendUserAvatar` returns nil, nil for a user without an avatar.
|
||||
|
||||
3. **modules/lagoon/backend_admin_migrations_test.go:** add `TestBackendAdminMetadataColumn`.
|
||||
- After Migrate, the column exists, is nullable TEXT, and a Winter-shaped INSERT with metadata `{"subtitle":"Editor"}` round-trips.
|
||||
- RollbackLast on the summercms.cabana history drops only metadata and keeps the row.
|
||||
- Migrate adds the column back.
|
||||
- 202610090001_backend_users_metadata is the last history id.
|
||||
|
||||
4. **SPA tests:**
|
||||
- **admin/tests/views/AccountView.test.ts:**
|
||||
- it renders the profile from currentUser with login read-only;
|
||||
- PUT sends only the allowed keys, and the password keys only when a new password is typed;
|
||||
- a 200 updates currentUser and clears the password inputs;
|
||||
- a 422 shows field errors and focuses the first field;
|
||||
- an upload posts FormData with file_data, and the profile with the avatar reaches currentUser;
|
||||
- remove confirms and calls DELETE.
|
||||
- **admin/tests/shell/UserMenu.test.ts:** the account item has role menuitem and navigates to the account route. The avatar img replaces the initials when avatar is set, and the initials stay without it.
|
||||
- **admin/tests/state/useAuth.test.ts:** `setCurrentUser`.
|
||||
- **admin/tests/fixtures/lang.json:** add the new `backend::lang.myaccount.*` keys.
|
||||
|
||||
5. **Run everything:**
|
||||
- Without -short: `go test ./modules/cabana ./modules/lagoon/... -count=1` (Docker, or the context fallback reverted before staging); `go test -short ./... -count=1`; `npm --prefix admin test`.
|
||||
- Record per-function coverage of modules/cabana/account.go with `go test ./modules/cabana -count=1 -coverprofile` and `go tool cover -func` in the SUMMARY. No function in account.go may be at 0.0%.
|
||||
- With Docker reachable, also run `go test ./... -count=1`.
|
||||
|
||||
6. **Commit** with subject `test(cabana): cover My account profile, password and avatar`.</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules cmd internal)" && go vet ./... && go test -short ./... -count=1 && d=$(mktemp -d) && out=$(go test ./modules/cabana -count=1 -v -coverprofile="$d/c.out" -run '^(TestAccount|TestAuthMeProfilePayload|TestMetadataSubtitleHelpers|TestBackendUserAttachContract|TestPhase10OpenAPIConformance|TestPhase10CSRF|TestPhase09PermissionMatrix)' 2>&1) && for t in TestMetadataSubtitleHelpers TestBackendUserAttachContract TestAccountProfileUpdate TestAccountForbiddenKeys TestAccountEmailRules TestAccountPasswordChange TestAccountAvatarUpload TestAccountAvatarRefusals TestAccountAvatarReplaceAndRemove TestAuthMeProfilePayload; do printf '%s\n' "$out" | grep -q -- "^--- PASS: $t " || { echo "missing PASS $t"; exit 1; }; done && ! printf '%s\n' "$out" | grep -q -- '^--- FAIL' && cov=$(go tool cover -func="$d/c.out") && printf '%s\n' "$cov" | grep -qF 'cabana/account.go' && test -z "$(printf '%s\n' "$cov" | grep -F 'cabana/account.go' | awk '$NF=="0.0%"')" && mig=$(go test ./modules/lagoon -count=1 -v -run '^TestBackendAdminMetadataColumn$' 2>&1) && printf '%s\n' "$mig" | grep -q -- '^--- PASS: TestBackendAdminMetadataColumn' && npm --prefix admin run typecheck && npm --prefix admin test && test -f admin/tests/views/AccountView.test.ts && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go -C ../sm-journal-plugin vet ./... && go -C ../fonoteka.go vet ./... && git diff --quiet 5ba1a4a HEAD -- go.mod go.sum admin/package.json admin/package-lock.json && changed=$(git diff --name-only --diff-filter=d 5ba1a4a HEAD -- modules docs README.md) && test -n "$changed" && test -z "$(printf '%s\n' "$changed" | xargs -r grep -EIil 'fonoteka|plytarium|płytarium|grzyby')" && test -z "$(git status --porcelain -- modules/cabana/auth_test.go modules/cabana/query_test.go modules/lagoon/postgres_test.go modules/lagoon/attach/lifecycle_test.go)"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- Every new code path in account.go, contracts.go and the metadata migration is exercised: profile update, forbidden keys, email rules, password change and session rotation, avatar upload, refusals, replace and remove, and the payload.
|
||||
- SPA tests cover AccountView, the user menu and setCurrentUser.
|
||||
- No account.go function is at 0% coverage, and the per-function table is in the SUMMARY.
|
||||
- go vet, go test (full, Docker or the reverted fallback), the SPA tests, the docs checker, the OpenAPI and dist gates, and vet of the dependent repos are green.
|
||||
- Harness files are unmodified.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser (cookie session) → PUT /account, POST and DELETE /account/avatar | An authenticated but untrusted request body and multipart upload cross into the admin API |
|
||||
| request body → backend_users row | Mass-assignment surface: the body must not reach role, superuser, permissions, activation, login or another admin's row |
|
||||
| client upload → system_files and blob bucket → public storage URL | Uploaded bytes and the client file name become a stored, publicly served file |
|
||||
| backend_users.metadata copied from WinterCMS → guard and /auth/me | Legacy JSON text that may be `[]`, blank or malformed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-mys-01 | Elevation of Privilege | accountUpdate body (role_id, is_superuser, permissions, is_activated, login) | critical | mitigate | Key allow-list; any other key answers 422 and nothing is written. The Updates map is built only from the allow-listed columns. TestAccountForbiddenKeys checks the row is unchanged by raw SELECT. |
|
||||
| T-mys-02 | Tampering | accountUpdate and avatar routes targeting another admin | high | mitigate | No id is read from the path or body. Every query uses `principal.ID` from the verified token (FOR UPDATE). The test shows a second admin's row stays untouched. |
|
||||
| T-mys-03 | Spoofing | password change through a stolen session | high | mitigate | current_password must pass `bouncer.CheckPassword`. A failure is logged as admin.auth failed. Success sets tokens_valid_after, which ends every other session. TestAccountPasswordChange covers it. |
|
||||
| T-mys-04 | Tampering (CSRF) | PUT /account, POST and DELETE /account/avatar over cookie transport | high | mitigate | Each is wrapped in requireAjax. The TestPhase10CSRF walk count rises to 29, and the coverage list includes the routes. |
|
||||
| T-mys-05 | Tampering (stored XSS) | avatar upload of SVG or HTML disguised as an image | high | mitigate | attach.Store image mode: extension allow-list jpg, jpeg, png, gif and webp; sniffed MIME limited to `attach.AllowedImageMIMEs`; `attach.IsAllowedImage` decode guard. TestAccountAvatarRefusals covers SVG and HTML-as-PNG. |
|
||||
| T-mys-06 | Tampering (path traversal) | client file name | high | mitigate | The disk name is generated by Store, and the file_name is only a sanitized base name used for display, never in a key. The test uploads `../../evil.png`. |
|
||||
| T-mys-07 | Denial of Service | oversized avatar body | medium | mitigate | MaxBytesReader at AvatarMaxBytes plus multipart overhead, further capped by http.body_limits.upload_bytes, and Store enforces the limit while streaming. The answer is 413 or 422, and no row or blob is left behind (tested). |
|
||||
| T-mys-08 | Denial of Service | malformed legacy metadata breaking the guard's user load | high | mitigate | Metadata is a raw `*string`, never a strict JSON scan type, and helpers parse leniently. The test signs in an admin whose metadata is `[]` or invalid JSON. |
|
||||
| T-mys-09 | Tampering | email set to another admin's email or login (sign-in ambiguity locks both out) | medium | mitigate | Case-insensitive uniqueness against every other admin's email and login, soft-deleted included. A unique-index race maps to 422. TestAccountEmailRules covers it. |
|
||||
| T-mys-10 | Tampering | avatar replace races and orphaned blobs | low | mitigate | The user row is locked FOR UPDATE. Old rows are deleted in the transaction and their blobs after the commit. A new blob is deleted on rollback. The replace test checks `bucket.Exists`. |
|
||||
| T-mys-11 | Information Disclosure | public avatar URL | low | accept | WinterCMS parity: avatars are public, and author pages need them. The URL uses an unguessable server-generated disk name. |
|
||||
| T-mys-12 | Spoofing | brute force of current_password over PUT /account | low | accept | It needs an authenticated, activated session, which already controls the account. Each attempt costs bcrypt and is logged. A login-style throttle would block normal saves (P-09). |
|
||||
| T-mys-13 | Spoofing | email change without the current password | low | accept | WinterCMS parity (P-05). The framework has no email-based password reset, so the email is not a recovery channel. |
|
||||
| T-mys-SC | Tampering | npm and Go dependency installs | low | accept | No new packages. The verify gates prove go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged since 5ba1a4a. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- go vet ./... and go test -short ./... are green at every commit. The cabana and lagoon Postgres suites pass without -short, with Docker or the reverted context fallback. With Docker reachable, go test ./... -count=1 is green.
|
||||
- `bash scripts/check-admin-openapi.sh --check` and `bash scripts/check-admin-dist.sh` pass after each feature commit.
|
||||
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` pass. No consuming-application name appears in changed modules or docs files.
|
||||
- The admin SPA typecheck and vitest pass. TestPhase10SPAKeysResolve proves every new SPA key resolves in en and pl.
|
||||
- Manual smoke (optional, recorded in the SUMMARY if done): on a dev build, open the avatar menu, choose My account, change the subtitle, upload a PNG, and check that the top bar shows the image; then change the password and confirm the session stays.
|
||||
- go -C ../sm-journal-plugin vet ./... and go -C ../fonoteka.go vet ./... still pass against the changed framework.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A backend admin can open My account from the avatar menu and change their first name, last name, email, subtitle and avatar, and change their password with the current one, all without touching role, permissions, superuser, activation or login.
|
||||
- The subtitle and avatar are stored where WinterCMS stores them (`backend_users.metadata.subtitle` and a `Backend\Models\User` avatar attachOne), so a later data copy needs no transform. Plugins read them through `cabana.BackendUser.Subtitle` and `cabana.BackendUserAvatar`.
|
||||
- GET /auth/me serves subtitle and avatar, and the top bar shows the avatar.
|
||||
- Three commits (two feature commits, one test commit), with docs in the feature commits and all gates green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261009-mys-add-subtitle-and-avatar-to-backend-users/261009-mys-SUMMARY.md` when done. It must contain:
|
||||
- The three commit SHAs.
|
||||
- How the Postgres suites ran: Docker, or the fallback with its database names.
|
||||
- The per-function coverage table of modules/cabana/account.go.
|
||||
- Decisions P-01 to P-09 as built.
|
||||
- A section "Todo journal-author-profile-fields". It states that the framework storage decision is made and shipped: `backend_users.metadata` holds the subtitle under `subtitle`, the avatar is an attachOne under `cabana.BackendUserMorphType` and field `avatar`, both are edited on My account, and plugins read them with `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`. It also lists what stays open:
|
||||
1. The author description has no editor yet. It would fit `metadata.description` the same way.
|
||||
2. sm-journal-plugin must add the subtitle and avatar keys to `authorProfile` and extend `TestJournalAuthorProfile`.
|
||||
3. The author import should fill `metadata` and copy the avatar `system_files` rows.
|
||||
|
||||
The orchestrator updates the todo file. The executor does not edit `.planning/todos`.
|
||||
</output>
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
phase: quick-261009-mys
|
||||
plan: 01
|
||||
subsystem: cabana (admin API and SPA), lagoon (backend admin migrations)
|
||||
tags: [admin, my-account, backend-users, avatar, subtitle, wintercms-parity]
|
||||
status: complete
|
||||
requires:
|
||||
- lagoon.BackendAdminMigrations (backend_users)
|
||||
- lagoon/attach (Store, File, BlobKeys, DeleteKeys)
|
||||
- bouncer (MintAudience, CheckPassword, HashPassword, blacklist)
|
||||
provides:
|
||||
- backend_users.metadata column (202610090001_backend_users_metadata)
|
||||
- cabana.BackendUser.Subtitle / Metadata
|
||||
- cabana.BackendUserAvatar, cabana.BackendUserMorphType, cabana.BackendUserAvatarField, cabana.AvatarMaxBytes
|
||||
- PUT /account, POST and DELETE /account/avatar
|
||||
- GET /auth/me subtitle and avatar
|
||||
- SPA My account screen (/account) and avatar in the user menu
|
||||
affects:
|
||||
- sm-journal-plugin authorProfile (still open, see the todo section)
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Winter jsonable column kept as raw *string and parsed leniently
|
||||
- key allow-list on a self-service PUT, with the row chosen by the token subject
|
||||
- attachOne through attach.Store with blobs deleted after the commit
|
||||
key-files:
|
||||
created:
|
||||
- modules/cabana/account.go
|
||||
- modules/cabana/account_test.go
|
||||
- modules/cabana/account_internal_test.go
|
||||
- admin/src/views/AccountView.vue
|
||||
- admin/tests/views/AccountView.test.ts
|
||||
modified:
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
- modules/lagoon/backend_admin_migrations_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/auth.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/phase10_csrf_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/app/pageTitle.ts
|
||||
- admin/src/components/shell/Breadcrumbs.vue
|
||||
- admin/src/components/shell/UserMenu.vue
|
||||
- admin/tests/helpers.ts
|
||||
- admin/tests/fixtures/lang.json
|
||||
- admin/tests/shell/UserMenu.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- modules/boardwalk/dist (rebuilt)
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
decisions:
|
||||
- "P-01: the subtitle is stored in backend_users.metadata under the subtitle key, not in its own column; unknown metadata keys are kept"
|
||||
- "P-02: the avatar is a public system_files attachOne under the WinterCMS morph name Backend\\Models\\User, field avatar"
|
||||
- "P-03: the routes are PUT /account, POST /account/avatar and DELETE /account/avatar; the profile is read from GET /auth/me"
|
||||
- "P-04: login is read-only on My account"
|
||||
- "P-05: an email change needs no current password; a password change does"
|
||||
- "P-06: a password change sets tokens_valid_after and gives a cookie session a fresh cookie; it also blacklists the token that made the request"
|
||||
- "P-07: cabana.AvatarMaxBytes is a fixed 2 MiB with no new config key"
|
||||
- "P-08: passwords need at least 4 characters and at most 72 bytes"
|
||||
- "P-09: PUT /account has no throttle; failed current-password checks are logged as admin.auth failed"
|
||||
metrics:
|
||||
duration: "about 45 min"
|
||||
completed: 2026-10-09
|
||||
tasks: 3
|
||||
files: 37
|
||||
estimate:
|
||||
tokens: 170000
|
||||
tasks: 3
|
||||
actuals:
|
||||
tokens: 48600
|
||||
tasks: 3
|
||||
commits: 6
|
||||
plan_head_before: 5ba1a4ae1dc87f705ac9e6ae00564d3bb09d094f
|
||||
plan_head_after: 89611eaaf92c56104a25c0c96fd5bf60dcfa0194
|
||||
---
|
||||
|
||||
# Phase quick-261009-mys Plan 01: Backend user subtitle, avatar and My account Summary
|
||||
|
||||
Backend administrators now have a My account page, opened from the avatar menu. On it they edit their own names, email and subtitle, change their password with the current one, and upload or remove an avatar. The subtitle is stored in WinterCMS's `backend_users.metadata.subtitle` and the avatar is a WinterCMS-compatible `Backend\Models\User` attachOne, so copied WinterCMS rows need no transform.
|
||||
|
||||
## Commits (summercms.go, branch master)
|
||||
|
||||
| Task | Commit | Subject |
|
||||
|------|--------|---------|
|
||||
| 1 | `143eca5` | feat(cabana): add the My account page with subtitle and password change |
|
||||
| 2 | `64d21c8` | feat(cabana): add backend user avatars to My account and the top bar |
|
||||
| 3 (bug found by its tests) | `4f1cda3` | fix(cabana): answer an empty or malformed avatar upload body with 422 |
|
||||
| 3 | `89611ea` | test(cabana): cover My account profile, password and avatar |
|
||||
|
||||
The ledger counts 6 commits between `5ba1a4a` and `89611ea`, but only 4 are from this plan. Two planning commits from a concurrent session landed in between and are not part of this plan: `02936b1` docs(17) and `a03302e` docs(state). No commit carries a co-author trailer. go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged since `5ba1a4a`.
|
||||
|
||||
## What was built
|
||||
|
||||
- **Migration.** `202610090001_backend_users_metadata` is the last entry of `lagoon.BackendAdminMigrations`. It adds `metadata TEXT` (nullable), and its rollback drops only that column.
|
||||
- **Model.**
|
||||
- `cabana.BackendUser.Metadata` is a raw `*string`.
|
||||
- `Subtitle()` reads the subtitle leniently: NULL, blank, `null`, `[]`, invalid JSON and non-string values all read as "".
|
||||
- `MorphName()` and `AttachRelations()` declare the avatar attachOne.
|
||||
- The constants are `BackendUserMorphType` and `BackendUserAvatarField`.
|
||||
- **`GET /auth/me`.** Returns `subtitle` always, and `avatar {url, thumb_url}` (a 250x250 crop) when the admin has a public avatar. Both `me` and the account routes build the payload through `service.profileFor`.
|
||||
- **`PUT /account`.**
|
||||
- The key allow-list is enforced on the server. Any other key is a 422 on that key, with the translated `backend::lang.myaccount.field_not_editable` message, and nothing is written.
|
||||
- Field rules: `first_name` and `last_name` are `nullable|string|max:255`; `email` is `required|string|email|between:6,255`; `subtitle` is `nullable|string|max:255`; `password` is `min:4|max:72`.
|
||||
- Checks in Go: the 72-byte limit, the confirmation, the current password (a failure is logged as `admin.auth failed`), and email uniqueness against other admins' emails and logins, ignoring case and including soft-deleted admins. A 23505 on the email index also answers 422.
|
||||
- The row is locked FOR UPDATE by the principal id.
|
||||
- Saving keeps every other metadata key, and an empty subtitle removes its key.
|
||||
- **`POST /account/avatar`.**
|
||||
- Takes one `file_data` part through `attach.Store`, in image mode, with the default image extensions and `AllowedImageMIMEs`, up to `AvatarMaxBytes`.
|
||||
- The body cap is `AvatarMaxBytes` plus 64 KiB, lowered to `upload_bytes` when that is smaller.
|
||||
- The previous avatar rows are deleted in the same transaction and their blobs after the commit.
|
||||
- If the transaction rolls back, the new blob is deleted.
|
||||
- **`DELETE /account/avatar`.** Idempotent. It deletes the rows now and the blobs after the commit.
|
||||
- **`cabana.BackendUserAvatar(ctx, db, userID)`.** Lets plugins read the picture.
|
||||
- **SPA.**
|
||||
- The user menu has a My account item (`data-action="account"`) that opens `/account` (AccountView).
|
||||
- The avatar image (`data-user-avatar`) replaces the initials in the menu trigger and the menu header.
|
||||
- New exports in `useAuth.ts`: `setCurrentUser` and `profileInitials`.
|
||||
- Breadcrumbs and the page title name the page.
|
||||
- **Inventories and generated files.**
|
||||
- `phase09Routes` and the TestPhase10Coverage list include the three routes.
|
||||
- The CSRF walk count went from 26 to 29.
|
||||
- There are three conformance cases, all placed before logout. The PUT case does not change the password.
|
||||
- admin.json, schema.d.ts and the dist build were regenerated in each feature commit.
|
||||
- **Docs** (written in the same commits as the code):
|
||||
- cabana and lagoon READMEs;
|
||||
- `docs/backend/users-and-permissions.md` (`## My account`);
|
||||
- `docs/backend/admin-spa.md`.
|
||||
- They name no consuming application.
|
||||
|
||||
## Test results
|
||||
|
||||
**Docker was not reachable.** User golem cannot open `/var/run/docker.sock`, so I used the plan's fallback:
|
||||
|
||||
- I started a throwaway PostgreSQL 18.6 cluster with `initdb` in the session scratchpad, listening on 127.0.0.1:55493 with no unix socket.
|
||||
- I made temporary, uncommitted `SUMMER_TEST_PG_DSN` overrides in four files:
|
||||
- `adminGorm` (modules/cabana/auth_test.go);
|
||||
- `newListService`'s container starter (modules/cabana/query_test.go);
|
||||
- `startLagoonPostgres` (modules/lagoon/postgres_test.go);
|
||||
- `attachGorm` (modules/lagoon/attach/lifecycle_test.go). This one creates a fresh `attach_<nanos>` database per call, because each attach test expects an empty database.
|
||||
- Each full run used a fresh database: `t1full` (Task 1), `t2full` (Task 2), and `t3full` (coverage) plus `t3final` (acceptance) for Task 3. Smaller runs used `base1`, `t1run1`, `t2run1`, `t3run1` and `t3run2`.
|
||||
- I reverted the four harness files with `git checkout --` before every `git add`. `git status` on them is clean, and I stopped the cluster at the end.
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| `go test ./modules/cabana ./modules/lagoon/... -count=1 -v` (no `-short`, fallback DB `t3final`) | cabana, lagoon and lagoon/attach all ok: 442 `--- PASS`, 0 SKIP, 0 FAIL |
|
||||
| New tests | PASS: TestMetadataSubtitleHelpers, TestBackendUserAttachContract, TestAccountRoutesRefuseNonBackendPrincipals, TestAccountProfileSmoke, TestAccountAvatarSmoke, TestAuthMeProfilePayload, TestAccountProfileUpdate, TestAccountForbiddenKeys, TestAccountEmailRules, TestAccountPasswordChange, TestAccountAvatarUpload, TestAccountAvatarRefusals, TestAccountAvatarReplaceAndRemove, TestBackendAdminMetadataColumn |
|
||||
| Inventory tests | PASS: TestPhase10OpenAPIConformance, TestPhase10CSRF (29), TestPhase10Coverage, TestPhase09PermissionMatrix, TestPhase09SecurityCoverage, TestAdminAuthLifecycle, TestPhase09MigrationsFreshRollback, TestBackendAdmin* |
|
||||
| `go vet ./...` | green at every commit |
|
||||
| gofmt on every changed `.go` file | clean. The plan's whole-tree `gofmt -l modules cmd internal` already fails at base on six unrelated files (deferred-items.md 1). |
|
||||
| `go test -short ./... -count=1` | Every package passes except the ones that start testcontainers even under `-short`: beachcomber, cabana, conga, lagoon and lighthouse. All their failures are Docker-provider errors, and the same failures happen at base (deferred-items.md 2). One run also hit a tide centrifugo timeout, which passed when re-run alone (deferred-items.md 3). The cabana and lagoon packages pass in full against the fallback database (above). beachcomber, conga and lighthouse were not run against Postgres. This task does not touch them. |
|
||||
| `bash scripts/check-admin-openapi.sh --check` | OK after each feature commit and at the end |
|
||||
| `npm --prefix admin run typecheck` | green |
|
||||
| `npm --prefix admin test` | 75 files, 1104 tests passed |
|
||||
| `bash scripts/check-admin-dist.sh` | dist matches a fresh build |
|
||||
| `go test ./cmd/summer -run TestDocsTree`, `go run ./cmd/summer docs:build --check` | ok, no problems found |
|
||||
| `go -C ../sm-journal-plugin vet ./...`, `go -C ../fonoteka.go vet ./...` | both green |
|
||||
| Consuming-application names in changed modules/docs files | none |
|
||||
| `go test ./... -count=1` with Docker | **not run.** Docker is unreachable. Re-run it on a machine with Docker before release. |
|
||||
|
||||
**RED evidence for the fix commit.** Before `4f1cda3`, `TestAccountAvatarRefusals/no_part` and `/malformed_bodies` both got 500 `{"code":"error"}`. After the fix, both answer 422 on `body`.
|
||||
|
||||
### Coverage of modules/cabana/account.go
|
||||
|
||||
Run: `go test ./modules/cabana -count=1 -coverprofile` (fallback DB `t3full`), then `go tool cover -func`. Package coverage is 87.1%. No function is at 0.0%.
|
||||
|
||||
| Function | Coverage |
|
||||
|----------|----------|
|
||||
| `decodeMetadata` | 100.0% |
|
||||
| `metadataString` | 100.0% |
|
||||
| `setMetadataString` | 87.5% |
|
||||
| `profileFor` | 100.0% |
|
||||
| `avatarOf` | 76.9% |
|
||||
| `BackendUserAvatar` | 81.8% |
|
||||
| `accountPrincipal` | 100.0% |
|
||||
| `lockAccount` | 66.7% |
|
||||
| `backendMessage` | 100.0% |
|
||||
| `accountMessage` | 100.0% |
|
||||
| `accountString` | 100.0% |
|
||||
| `accountUpdate` | 91.3% |
|
||||
| `revokePresentedToken` | 63.6% |
|
||||
| `refusedAccountKeys` | 100.0% |
|
||||
| `accountDetails` | 100.0% |
|
||||
| `emailTaken` | 80.0% |
|
||||
| `emailConflict` | 100.0% |
|
||||
| `writeAccountError` | 62.5% |
|
||||
| `avatarCap` | 100.0% |
|
||||
| `detachAvatars` | 91.7% |
|
||||
| `deleteAvatarBlobs` | 87.5% |
|
||||
| `avatarUpload` | 93.0% |
|
||||
| `avatarRemove` | 83.3% |
|
||||
| `multipartFailure` | 75.0% |
|
||||
| `writeAvatarError` | 88.2% |
|
||||
|
||||
The branches left uncovered are database, storage and token failure paths. Examples are a lock query error, a blacklist write error, a thumbnail storage error, and a deactivated admin who still gets past the guard. The guard refuses deactivated admins before the handler runs.
|
||||
|
||||
## Decisions P-01 to P-09 as built
|
||||
|
||||
- **P-01:** The subtitle is stored under `metadata.subtitle`, and every other key is kept. Stored text that is not empty, `[]` or a JSON object is never overwritten. Saving a different subtitle onto such text answers 422 on `subtitle` (`myaccount.subtitle_unavailable`). A save that leaves the subtitle unchanged still succeeds and leaves the text as it is.
|
||||
- **P-02:** The avatar is a public attachOne with type `Backend\Models\User` and field `avatar`. A copied WinterCMS row is picked up as it is (tested). A protected row (`is_public` false) is left out of the profile, because it has no public URL.
|
||||
- **P-03:** The routes are `PUT /account`, `POST /account/avatar` and `DELETE /account/avatar`. There is no GET /account.
|
||||
- **P-04:** Login is read-only. A `login` key answers 422.
|
||||
- **P-05:** An email change needs no current password. A password change does.
|
||||
- **P-06:** A password change sets `tokens_valid_after` to the start of the current second. A cookie session gets a fresh cookie and stays signed in, and a Bearer client must sign in again. In addition, the presented token is blacklisted (see deviation 2).
|
||||
- **P-07:** `AvatarMaxBytes` is 2 MiB with no config key, and `upload_bytes` still caps the request. A file over 2 MiB answers 422 (`max.file`, 2048 KB). A body over the request cap answers 413.
|
||||
- **P-08:** Passwords need at least 4 characters and at most 72 bytes. 40 two-byte runes answer 422, never 500.
|
||||
- **P-09:** There is no throttle. A wrong current password is logged as `admin.auth failed` with the admin id and no secrets (tested).
|
||||
|
||||
## Todo journal-author-profile-fields
|
||||
|
||||
**Closed: the framework storage decision is made and shipped.**
|
||||
- `backend_users.metadata` holds the subtitle under the `subtitle` key, as WinterCMS does.
|
||||
- The avatar is a public system_files attachOne under `cabana.BackendUserMorphType` (`Backend\Models\User`) and field `avatar` (`cabana.BackendUserAvatarField`).
|
||||
- Both are edited on My account.
|
||||
- Plugins read them with `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`.
|
||||
|
||||
**Still open:**
|
||||
1. The author description has no editor yet. It would fit `metadata.description` in the same way, through the same metadata helpers.
|
||||
2. sm-journal-plugin must add the subtitle and avatar keys to `authorProfile` and extend `TestJournalAuthorProfile`.
|
||||
3. The author import should fill `metadata` and copy the avatar `system_files` rows.
|
||||
|
||||
I did not edit `.planning/todos`; the orchestrator updates the todo file.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] An empty or malformed avatar upload body answered 500**
|
||||
- **Found during:** Task 3 (TestAccountAvatarRefusals).
|
||||
- **Issue:** A multipart body with no part (`NextPart` returns `io.EOF`) or with a broken boundary fell through `writeAvatarError` to the generic 500. The plan requires 422 for "no part".
|
||||
- **Fix:** `multipartFailure` maps any multipart read error that is not a `MaxBytesError` to the invalid-body 422, both for the first part and for the trailing-part check.
|
||||
- **Files modified:** modules/cabana/account.go.
|
||||
- **Commit:** `4f1cda3`. Committed on its own before the test commit, as the plan asks.
|
||||
|
||||
**2. [Rule 2 - Security] A password change also blacklists the token that made the request**
|
||||
- **Found during:** Task 1.
|
||||
- **Issue:** JWT `iat` has whole-second precision, so the `tokens_valid_after` cutoff cannot end a token issued in the same second as the change. That includes the token that changed the password.
|
||||
- **Fix:** After the commit, `revokePresentedToken` adds the presented token's jti to the backend blacklist, as logout does. The old cookie or Bearer token is therefore refused at once. Other sessions issued in an earlier second are cut off by `tokens_valid_after`.
|
||||
- **Files modified:** modules/cabana/account.go.
|
||||
- **Commit:** `143eca5`.
|
||||
|
||||
**3. [Rule 3 - Blocking] Two existing lagoon migration tests assumed the old last entry**
|
||||
- **Found during:** Task 1.
|
||||
- **Issue and fix:**
|
||||
- `TestBackendAdminMigration`'s column list gained `metadata`.
|
||||
- `TestBackendAdminEmailIndexRefusesCaseDuplicates` rolled back the email index with `RollbackLast`, which now drops metadata instead. It now uses `RollbackTo("202609240001_backend_admin_identity")`.
|
||||
- **Files modified:** modules/lagoon/backend_admin_migrations_test.go.
|
||||
- **Commit:** `143eca5`.
|
||||
|
||||
**4. [Rule 3 - Blocking] The fallback harness needed a fresh database per attach test**
|
||||
- **Found during:** the Task 1 full run.
|
||||
- **Issue:** `TestStore/row_failure_deletes_the_blob` expects `system_files` not to exist yet. testcontainers gives every `attachGorm` call a new database, but a single shared fallback DSN does not.
|
||||
- **Fix:** The temporary `attachGorm` override creates `attach_<nanos>` per call. This was a harness-only change and was never committed.
|
||||
|
||||
**5. Small additions beyond the plan text**
|
||||
- `profileInitials` is exported from `useAuth.ts` and shared by UserMenu and AccountView.
|
||||
- A `myaccount.subtitle_unavailable` lang key exists for metadata that is not a JSON object.
|
||||
- `TestAccountRoutesRefuseNonBackendPrincipals` is a third internal test. It checks that all three handlers answer 401 for no principal, a frontend principal or a zero id, without reading the body.
|
||||
- The confirmation mismatch is reported on `password`, which matches Laravel's `confirmed` rule.
|
||||
- The test `accountHandler` raises `admin.login.max_attempts`, because these tests sign many admins in from one address.
|
||||
- `admin-spa.md` also mentions the avatar.
|
||||
|
||||
### Deferred (out of scope, pre-existing)
|
||||
|
||||
See `deferred-items.md`:
|
||||
- the whole-tree gofmt failures at base;
|
||||
- `-short` still starting testcontainers;
|
||||
- the tide centrifugo flake under load.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. Every field on the screen is wired to the API, and every new route is wired to storage.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None beyond the plan's threat model. T-mys-01 to T-mys-10 are mitigated, and each has the test the register names. T-mys-11 to T-mys-13 and T-mys-SC are accepted as planned.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: modules/cabana/account.go, modules/cabana/account_test.go, modules/cabana/account_internal_test.go, admin/src/views/AccountView.vue, admin/tests/views/AccountView.test.ts.
|
||||
- FOUND: commits `143eca5`, `64d21c8`, `4f1cda3` and `89611ea` on master.
|
||||
- The harness files are unmodified and the Postgres cluster is stopped.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Deferred items (quick 261009-mys)
|
||||
|
||||
These problems were already there before this task started, in files the task did not change. They are logged here and were not fixed.
|
||||
|
||||
1. **`gofmt -l modules cmd internal` is not clean at base `5ba1a4a`.** Six files are unformatted: `modules/compass/config_test.go`, `modules/compass/persist_test.go`, `modules/party/registry_test.go`, `modules/tide/flow_test.go`, `modules/tide/headers_test.go`, `modules/wristband/server.go` and `internal/build/registry.go`. gofmt also reports a parse error on the intentional testdata file `internal/docsite/testdata/violations/snippet-unparsable-go/lib/lib.go`. As a result, the plan's `test -z "$(gofmt -l modules cmd internal)"` gate cannot pass at base. Every file this task changed is gofmt-clean.
|
||||
2. **`go test -short ./...` still starts testcontainers.** The `TestMain` `testShort()` helpers in lagoon, conga, lighthouse and beachcomber look for the literal argument `-test.short`, but `go test -short` passes `-test.short=true`, so the helpers miss it. Many cabana tests also never check `testing.Short()`, including those built on `newListService`, `hookFixture` and `TestPhase09SecurityCoverage`'s hook subtests. Without Docker, these packages fail on the Docker provider error even under `-short`. The same failures happen at base `5ba1a4a`; I checked this in a scratch worktree.
|
||||
3. **`modules/tide` centrifugo replay is flaky under load.** `centrifugo_test.go:223` (`context deadline exceeded`) failed once during a parallel `go test -short ./...` and passed when re-run alone.
|
||||
17
.planning/todos/pending/cabana-richeditor-field-type.md
Normal file
17
.planning/todos/pending/cabana-richeditor-field-type.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
created: 2026-10-09T14:30:00Z
|
||||
title: Add a richeditor (HTML WYSIWYG) admin field type to cabana
|
||||
area: framework
|
||||
severity: minor
|
||||
files:
|
||||
- modules/cabana/form_schema.go
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
D-21 asks for a rich content field on Journal categories. cabana has no WYSIWYG field type, so category content is edited as raw HTML in a `textarea` (size huge). The `markdown` and `mlmarkdown` fields cannot hold HTML, because their preview omits raw HTML as `<!-- raw HTML omitted -->`.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a `richeditor` field type (and an `mlricheditor` variant if a translatable rich field is needed) to the cabana form schema (`modules/cabana/form_schema.go`) and to the admin SPA. Keep the server-side gate on save: the Journal plugin's `classes.CheckHTML` runs in the Categories controller's `FormBeforeCreate` and `FormBeforeUpdate`, and the new field type must not bypass it. Then switch the Journal category `content` field in `models/category/fields.yaml` from `textarea` to `richeditor`.
|
||||
26
.planning/todos/pending/journal-author-profile-fields.md
Normal file
26
.planning/todos/pending/journal-author-profile-fields.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
created: 2026-10-09T11:22:00Z
|
||||
title: Expose author subtitle, description and avatar on the Journal author API
|
||||
area: plugins
|
||||
severity: minor
|
||||
files:
|
||||
- ../sm-journal-plugin/controllers/api/authors.go
|
||||
- modules/lagoon/backend_admin_migrations.go
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The WinterCMS author component (`journalAuthor`) shows `metadata.subtitle`, and its SEO add-on reads the author avatar and a description. The framework's `backend_users` table has no `metadata` column and no avatar attachment, so the D-20 endpoint `GET /_journal/api/v1/authors/{author}` exposes only `id`, `slug`, `first_name` and `last_name`. An author page built on it has no subtitle, bio or picture, and the import into `backend_users` creates authors without them.
|
||||
|
||||
## Solution
|
||||
|
||||
Make a framework decision on backend user profile storage first. Either add a `metadata` JSON column plus an avatar attachment on backend users (in `modules/lagoon/backend_admin_migrations.go` and the admin user form), or give the journal plugin its own author profile table edited in the admin. Then add the new keys to the author payload additively (`authorProfile` in `controllers/api/authors.go`), document them in the plugin README, and extend `TestJournalAuthorProfile`'s exact key set. Never emit a key without a storage source.
|
||||
|
||||
## Progress (2026-10-09, quick task 261009-mys)
|
||||
|
||||
The framework decision is made and shipped (commits 143eca5, 64d21c8, 4f1cda3, 89611ea): `backend_users.metadata` holds the subtitle under `subtitle` (WinterCMS-compatible), and the avatar is a WinterCMS-style `Backend\Models\User` `avatar` attachOne, both edited on the My account page. Plugins read them via `cabana.BackendUser.Subtitle()` and `cabana.BackendUserAvatar()`.
|
||||
|
||||
Still open:
|
||||
- an editor for the author description (would fit `metadata.description`);
|
||||
- `subtitle` and `avatar` keys in sm-journal-plugin's `authorProfile`, README and `TestJournalAuthorProfile`;
|
||||
- the author import filling `metadata` and copying avatar file rows.
|
||||
@@ -1,6 +1,35 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"cabana.AdminAccountRequest": {
|
||||
"properties": {
|
||||
"current_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"password_confirmation": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminActionRequest": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
@@ -35,6 +64,20 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminAvatar": {
|
||||
"properties": {
|
||||
"thumb_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -129,6 +172,14 @@
|
||||
},
|
||||
"cabana.AdminProfile": {
|
||||
"properties": {
|
||||
"avatar": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.AdminAvatar"
|
||||
}
|
||||
],
|
||||
"description": "Avatar is present when the admin has a public avatar."
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -149,6 +200,10 @@
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/cabana.AdminRoleSummary"
|
||||
},
|
||||
"subtitle": {
|
||||
"description": "Subtitle is backend_users.metadata.subtitle; \"\" when not set.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -157,7 +212,8 @@
|
||||
"id",
|
||||
"is_superuser",
|
||||
"last_name",
|
||||
"login"
|
||||
"login",
|
||||
"subtitle"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -2021,6 +2077,213 @@
|
||||
},
|
||||
"openapi": "3.0.3",
|
||||
"paths": {
|
||||
"/account": {
|
||||
"put": {
|
||||
"description": "Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminAccountRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Profile and optional password change",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request Entity Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Update my account",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/account/avatar": {
|
||||
"delete": {
|
||||
"description": "Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Remove my avatar",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\\Models\\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"file_data": {
|
||||
"description": "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_data"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request Entity Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"BackendBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload my avatar",
|
||||
"tags": [
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"parameters": [
|
||||
@@ -2129,6 +2392,7 @@
|
||||
},
|
||||
"/auth/me": {
|
||||
"get": {
|
||||
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
||||
228
admin/src/api/schema.d.ts
vendored
228
admin/src/api/schema.d.ts
vendored
@@ -4,6 +4,212 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/account": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Update my account
|
||||
* @description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Profile and optional password change */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminAccountRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request Entity Too Large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/account/avatar": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload my avatar
|
||||
* @description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/**
|
||||
* Format: binary
|
||||
* @description Image (jpg, jpeg, png, gif or webp, at most 2 MiB)
|
||||
*/
|
||||
file_data: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Request Entity Too Large */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Entity */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Remove my avatar
|
||||
* @description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
|
||||
*/
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -127,7 +333,10 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Current admin */
|
||||
/**
|
||||
* Current admin
|
||||
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4286,6 +4495,15 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
"cabana.AdminAccountRequest": {
|
||||
current_password?: string;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
password?: string;
|
||||
password_confirmation?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
"cabana.AdminActionRequest": {
|
||||
/**
|
||||
* @description Payload is the widget's own JSON value (the summer-action event's
|
||||
@@ -4309,6 +4527,10 @@ export interface components {
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
"cabana.AdminAvatar": {
|
||||
thumb_url?: string;
|
||||
url: string;
|
||||
};
|
||||
"cabana.AdminFileCaptionRequest": {
|
||||
description?: string;
|
||||
title?: string;
|
||||
@@ -4336,6 +4558,8 @@ export interface components {
|
||||
html: string;
|
||||
};
|
||||
"cabana.AdminProfile": {
|
||||
/** @description Avatar is present when the admin has a public avatar. */
|
||||
avatar?: components["schemas"]["cabana.AdminAvatar"];
|
||||
email: string;
|
||||
first_name: string;
|
||||
id: number;
|
||||
@@ -4343,6 +4567,8 @@ export interface components {
|
||||
last_name: string;
|
||||
login: string;
|
||||
role?: components["schemas"]["cabana.AdminRoleSummary"];
|
||||
/** @description Subtitle is backend_users.metadata.subtitle; "" when not set. */
|
||||
subtitle: string;
|
||||
};
|
||||
"cabana.AdminRecord": {
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -11,6 +11,10 @@ export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
|
||||
export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||
/** An admin's avatar: the public image URL and its 250x250 thumbnail URL. */
|
||||
export type AdminAvatar = Schemas['cabana.AdminAvatar']
|
||||
/** Body of PUT /account: the only keys My account may change. */
|
||||
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
|
||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||
export type SettingsEntry = Schemas['cabana.SettingsEntry']
|
||||
export type SettingsResult = Schemas['cabana.SettingsResult']
|
||||
|
||||
@@ -15,6 +15,9 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
|
||||
if (route.name === 'settings') {
|
||||
return t('backend::lang.settings.title')
|
||||
}
|
||||
if (route.name === 'account') {
|
||||
return t('backend::lang.myaccount.menu_label')
|
||||
}
|
||||
if (route.name === 'settings-form') {
|
||||
return settingsEntry(String(route.params.code ?? ''))?.label ?? String(route.params.code ?? '')
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import FormView from '../views/FormView.vue'
|
||||
import PreviewView from '../views/PreviewView.vue'
|
||||
import SettingsFormView from '../views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../views/SettingsIndexView.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
import NotFoundView from '../views/NotFoundView.vue'
|
||||
|
||||
declare module 'vue-router' {
|
||||
@@ -67,6 +68,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
||||
},
|
||||
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
|
||||
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
|
||||
// My account: the signed-in admin's own profile and password.
|
||||
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||
|
||||
@@ -22,6 +22,9 @@ interface Crumb {
|
||||
const route = useRoute()
|
||||
|
||||
const crumbs = computed<Crumb[]>(() => {
|
||||
if (route.name === 'account') {
|
||||
return [{ label: t('backend::lang.myaccount.menu_label') }]
|
||||
}
|
||||
if (route.name === 'settings' || route.name === 'settings-form') {
|
||||
const out: Crumb[] = [{ label: t('backend::lang.nav.settings'), to: '/settings' }]
|
||||
const entry = route.name === 'settings-form' ? settingsEntry(String(route.params.code ?? '')) : null
|
||||
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
import { ChevronDown, LogOut } from '@lucide/vue'
|
||||
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { currentUser, logout } from '../../state/useAuth'
|
||||
import { currentUser, logout, profileInitials } from '../../state/useAuth'
|
||||
|
||||
// User menu (design: Top header): a 44px button with the initials avatar,
|
||||
// the name above the role from /auth/me (both hidden at tablet width) and a
|
||||
// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and
|
||||
// e-mail and a Wyloguj item. Logging out always ends on the login screen,
|
||||
// even when the logout call fails (T-10-23).
|
||||
// User menu (design: Top header): a 44px button with the avatar image (the
|
||||
// initials when the admin has none), the name above the role from /auth/me
|
||||
// (both hidden at tablet width) and a chevron; the 280px menu (Reka
|
||||
// DropdownMenu, role menu) shows the name and e-mail, a My account item that
|
||||
// opens /account and a Wyloguj item. Logging out always ends on the login
|
||||
// screen, even when the logout call fails (T-10-23).
|
||||
const router = useRouter()
|
||||
|
||||
const displayName = computed(() => {
|
||||
@@ -30,23 +31,17 @@ const displayName = computed(() => {
|
||||
})
|
||||
|
||||
/** First and last name initials, else the login's first letters. */
|
||||
const initials = computed(() => {
|
||||
const user = currentUser.value
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
return user.login.slice(0, 2).toUpperCase()
|
||||
})
|
||||
const initials = computed(() => profileInitials(currentUser.value))
|
||||
|
||||
/** The avatar thumbnail, else the image; '' shows the initials. */
|
||||
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
|
||||
|
||||
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
||||
|
||||
async function onAccount(): Promise<void> {
|
||||
await router.push({ name: 'account' }).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
await logout(router)
|
||||
}
|
||||
@@ -62,7 +57,15 @@ async function onLogout(): Promise<void> {
|
||||
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
|
||||
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
|
||||
>
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
data-user-avatar
|
||||
class="size-[34px] shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
data-user-initials
|
||||
@@ -82,7 +85,15 @@ async function onLogout(): Promise<void> {
|
||||
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-2 py-2">
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
data-user-avatar
|
||||
class="size-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
||||
aria-hidden="true"
|
||||
>{{ initials }}</span
|
||||
@@ -92,6 +103,14 @@ async function onLogout(): Promise<void> {
|
||||
<span class="truncate text-[13px] text-muted">{{ currentUser?.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
data-action="account"
|
||||
class="flex h-10 cursor-pointer items-center gap-2.5 rounded-control px-3 font-medium outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
|
||||
@select="onAccount"
|
||||
>
|
||||
<UserRound :size="18" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.myaccount.menu_label') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuItem
|
||||
data-action="logout"
|
||||
|
||||
@@ -54,6 +54,26 @@ export async function me(): Promise<AdminProfile | null> {
|
||||
return user.value
|
||||
}
|
||||
|
||||
/** First and last name initials, else the login's first two letters. */
|
||||
export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_name' | 'login'> | null): string {
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
return user.login.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||
export function setCurrentUser(profile: AdminProfile): void {
|
||||
user.value = profile
|
||||
}
|
||||
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
@@ -88,6 +108,7 @@ export function useAuth() {
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
setCurrentUser,
|
||||
clearUser,
|
||||
}
|
||||
}
|
||||
|
||||
429
admin/src/views/AccountView.vue
Normal file
429
admin/src/views/AccountView.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminAccountRequest } from '../api/types'
|
||||
import { t } from '../app/i18n'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import { controlClass } from '../components/form/control'
|
||||
import { fieldErrors, focusField } from '../components/form/formState'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { currentUser, profileInitials, setCurrentUser } from '../state/useAuth'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// My account (WinterCMS Users::myaccount): the signed-in admin edits their
|
||||
// own first name, last name, email and subtitle, and changes the password
|
||||
// with the current one. Login is shown read-only. PUT /account takes exactly
|
||||
// the allowed keys; the password keys are sent only when a new password is
|
||||
// typed. The avatar is uploaded (POST /account/avatar, one file_data part)
|
||||
// and removed (DELETE /account/avatar, after a confirmation) on its own.
|
||||
// Every success passes the returned profile to setCurrentUser, so the top
|
||||
// bar updates without a reload.
|
||||
const ID_PREFIX = 'account'
|
||||
|
||||
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
|
||||
type PasswordKey = 'current_password' | 'password' | 'password_confirmation'
|
||||
|
||||
interface Row<K extends string> {
|
||||
name: K
|
||||
label: string
|
||||
type: 'text' | 'email' | 'password'
|
||||
autocomplete: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
const profileRows: Row<ProfileKey>[] = [
|
||||
{ name: 'first_name', label: 'backend::lang.myaccount.first_name', type: 'text', autocomplete: 'given-name' },
|
||||
{ name: 'last_name', label: 'backend::lang.myaccount.last_name', type: 'text', autocomplete: 'family-name' },
|
||||
{ name: 'email', label: 'backend::lang.myaccount.email', type: 'email', autocomplete: 'email' },
|
||||
{
|
||||
name: 'subtitle',
|
||||
label: 'backend::lang.myaccount.subtitle',
|
||||
type: 'text',
|
||||
autocomplete: 'organization-title',
|
||||
comment: 'backend::lang.myaccount.subtitle_comment',
|
||||
},
|
||||
]
|
||||
|
||||
const passwordRows: Row<PasswordKey>[] = [
|
||||
{
|
||||
name: 'current_password',
|
||||
label: 'backend::lang.myaccount.current_password',
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
},
|
||||
{ name: 'password', label: 'backend::lang.myaccount.password', type: 'password', autocomplete: 'new-password' },
|
||||
{
|
||||
name: 'password_confirmation',
|
||||
label: 'backend::lang.myaccount.password_confirmation',
|
||||
type: 'password',
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
]
|
||||
|
||||
const FIELD_ORDER: string[] = [...profileRows, ...passwordRows].map((row) => row.name)
|
||||
|
||||
function initialProfile(): Record<ProfileKey, string> {
|
||||
const user = currentUser.value
|
||||
return {
|
||||
first_name: user?.first_name ?? '',
|
||||
last_name: user?.last_name ?? '',
|
||||
email: user?.email ?? '',
|
||||
subtitle: user?.subtitle ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const profile = ref<Record<ProfileKey, string>>(initialProfile())
|
||||
const passwords = ref<Record<PasswordKey, string>>({ current_password: '', password: '', password_confirmation: '' })
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const busy = ref(false)
|
||||
|
||||
const login = computed(() => currentUser.value?.login ?? '')
|
||||
|
||||
// Avatar: the thumbnail, else the image, else the initials.
|
||||
const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const avatarBusy = ref(false)
|
||||
const avatarErrors = ref<string[]>([])
|
||||
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
|
||||
const initials = computed(() => profileInitials(currentUser.value))
|
||||
const confirm = useConfirm()
|
||||
|
||||
function chooseAvatar(): void {
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAvatarPicked(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || avatarBusy.value) {
|
||||
return
|
||||
}
|
||||
avatarBusy.value = true
|
||||
avatarErrors.value = []
|
||||
try {
|
||||
// The typed client keeps the 401 refresh-and-replay; FormData lets the
|
||||
// browser set the multipart Content-Type and boundary.
|
||||
const result = await api.POST('/account/avatar', {
|
||||
body: { file_data: '' },
|
||||
bodySerializer: () => {
|
||||
const form = new FormData()
|
||||
form.append('file_data', file, file.name)
|
||||
return form
|
||||
},
|
||||
})
|
||||
if (result.data) {
|
||||
setCurrentUser(result.data.data)
|
||||
showToast(t('backend::lang.myaccount.avatar_saved'))
|
||||
return
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
const details = fieldErrors(result.error?.error.details)
|
||||
avatarErrors.value = details.avatar ?? Object.values(details).flat()
|
||||
return
|
||||
}
|
||||
if (result.response.status === 413) {
|
||||
avatarErrors.value = [t('backend::lang.myaccount.avatar_hint')]
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
avatarBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAvatarRemove(): Promise<void> {
|
||||
if (avatarBusy.value) {
|
||||
return
|
||||
}
|
||||
const confirmed = await confirm.ask({
|
||||
message: t('backend::lang.myaccount.avatar_remove_confirm'),
|
||||
confirmLabel: t('backend::lang.myaccount.avatar_remove'),
|
||||
danger: true,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
avatarBusy.value = true
|
||||
avatarErrors.value = []
|
||||
try {
|
||||
const result = await api.DELETE('/account/avatar')
|
||||
if (result.data) {
|
||||
setCurrentUser(result.data.data)
|
||||
showToast(t('backend::lang.myaccount.avatar_removed'))
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
avatarBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function inputId(name: string): string {
|
||||
return `${ID_PREFIX}-${name}`
|
||||
}
|
||||
|
||||
function describedBy(row: Row<string>): string | undefined {
|
||||
const ids = [row.comment ? `${inputId(row.name)}-comment` : '', errors.value[row.name] ? `${inputId(row.name)}-error` : '']
|
||||
const out = ids.filter(Boolean).join(' ')
|
||||
return out === '' ? undefined : out
|
||||
}
|
||||
|
||||
function clearError(name: string): void {
|
||||
if (errors.value[name]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[name]
|
||||
errors.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function setProfile(name: ProfileKey, value: string): void {
|
||||
profile.value = { ...profile.value, [name]: value }
|
||||
clearError(name)
|
||||
}
|
||||
|
||||
function setPassword(name: PasswordKey, value: string): void {
|
||||
passwords.value = { ...passwords.value, [name]: value }
|
||||
clearError(name)
|
||||
}
|
||||
|
||||
/** The PUT /account body: the profile keys always, password keys only with a new password. */
|
||||
function payload(): AdminAccountRequest {
|
||||
const body: AdminAccountRequest = {
|
||||
first_name: profile.value.first_name,
|
||||
last_name: profile.value.last_name,
|
||||
email: profile.value.email,
|
||||
subtitle: profile.value.subtitle,
|
||||
}
|
||||
if (passwords.value.password !== '') {
|
||||
body.current_password = passwords.value.current_password
|
||||
body.password = passwords.value.password
|
||||
body.password_confirmation = passwords.value.password_confirmation
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.PUT('/account', { body: payload() })
|
||||
if (result.data) {
|
||||
errors.value = {}
|
||||
setCurrentUser(result.data.data)
|
||||
profile.value = initialProfile()
|
||||
passwords.value = { current_password: '', password: '', password_confirmation: '' }
|
||||
showToast(t('backend::lang.myaccount.saved'))
|
||||
return
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
const first = FIELD_ORDER.find((name) => errors.value[name])
|
||||
await nextTick()
|
||||
if (first) {
|
||||
focusField(ID_PREFIX, first)
|
||||
}
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full flex-col gap-5 pb-24" data-account>
|
||||
<header class="flex min-w-0 flex-col">
|
||||
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ t('backend::lang.myaccount.menu_label') }}</h1>
|
||||
<p class="text-muted">{{ t('backend::lang.myaccount.menu_description') }}</p>
|
||||
</header>
|
||||
|
||||
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card" data-account-avatar>
|
||||
<h2 class="text-[18px] font-bold">{{ t('backend::lang.myaccount.avatar') }}</h2>
|
||||
<div class="flex flex-wrap items-center gap-5">
|
||||
<img
|
||||
v-if="avatarSrc"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
data-account-avatar-image
|
||||
class="size-20 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
data-account-avatar-initials
|
||||
class="flex size-20 shrink-0 items-center justify-center rounded-full bg-accent text-[26px] font-bold text-on-accent"
|
||||
>{{ initials }}</span
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="ImageUp"
|
||||
data-action="avatar-upload"
|
||||
:disabled="avatarBusy"
|
||||
@click="chooseAvatar"
|
||||
>
|
||||
{{ t('backend::lang.myaccount.avatar_upload') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="currentUser?.avatar"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:icon="Trash2"
|
||||
data-action="avatar-remove"
|
||||
:disabled="avatarBusy"
|
||||
@click="onAvatarRemove"
|
||||
>
|
||||
{{ t('backend::lang.myaccount.avatar_remove') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.avatar_hint') }}</p>
|
||||
</div>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
name="file_data"
|
||||
:accept="AVATAR_ACCEPT"
|
||||
class="hidden"
|
||||
data-account-avatar-input
|
||||
@change="onAvatarPicked"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="avatarErrors.length > 0" role="alert" data-avatar-error class="flex flex-col gap-0.5 text-[13px] text-danger">
|
||||
<p v-for="(text, index) in avatarErrors" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
|
||||
novalidate
|
||||
@submit.prevent="onSave"
|
||||
>
|
||||
<fieldset class="flex flex-col gap-[18px]">
|
||||
<legend class="mb-[18px] text-[18px] font-bold">{{ t('backend::lang.myaccount.profile') }}</legend>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<label :for="inputId('login')" class="font-semibold">{{ t('backend::lang.myaccount.login') }}</label>
|
||||
<input
|
||||
:id="inputId('login')"
|
||||
name="login"
|
||||
type="text"
|
||||
:value="login"
|
||||
readonly
|
||||
data-account-login
|
||||
:class="controlClass(false)"
|
||||
class="h-input bg-hover text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-[18px] md:grid-cols-2">
|
||||
<div
|
||||
v-for="row in profileRows"
|
||||
:key="row.name"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:data-field="row.name"
|
||||
>
|
||||
<label :for="inputId(row.name)" class="font-semibold">{{ t(row.label) }}</label>
|
||||
<input
|
||||
:id="inputId(row.name)"
|
||||
:name="row.name"
|
||||
:type="row.type"
|
||||
:autocomplete="row.autocomplete"
|
||||
:value="profile[row.name]"
|
||||
:aria-invalid="errors[row.name] ? 'true' : undefined"
|
||||
:aria-describedby="describedBy(row)"
|
||||
:class="controlClass(!!errors[row.name])"
|
||||
class="h-input"
|
||||
@input="setProfile(row.name, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<p v-if="row.comment" :id="`${inputId(row.name)}-comment`" class="text-[13px] text-muted">
|
||||
{{ t(row.comment) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="errors[row.name]"
|
||||
:id="`${inputId(row.name)}-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors[row.name]" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="flex flex-col gap-[18px]">
|
||||
<legend class="mb-1.5 text-[18px] font-bold">{{ t('backend::lang.myaccount.password_section') }}</legend>
|
||||
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.password_comment') }}</p>
|
||||
<div class="grid grid-cols-1 gap-[18px] md:grid-cols-3">
|
||||
<div
|
||||
v-for="row in passwordRows"
|
||||
:key="row.name"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:data-field="row.name"
|
||||
>
|
||||
<label :for="inputId(row.name)" class="font-semibold">{{ t(row.label) }}</label>
|
||||
<input
|
||||
:id="inputId(row.name)"
|
||||
:name="row.name"
|
||||
type="password"
|
||||
:autocomplete="row.autocomplete"
|
||||
:value="passwords[row.name]"
|
||||
:aria-invalid="errors[row.name] ? 'true' : undefined"
|
||||
:aria-describedby="describedBy(row)"
|
||||
:class="controlClass(!!errors[row.name])"
|
||||
class="h-input"
|
||||
@input="setPassword(row.name, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div
|
||||
v-if="errors[row.name]"
|
||||
:id="`${inputId(row.name)}-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors[row.name]" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
:busy="confirm.busy.value"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
|
||||
<footer
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
>
|
||||
<Button variant="primary" data-action="save" :disabled="busy" @click="onSave">
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
75
admin/tests/fixtures/lang.json
vendored
75
admin/tests/fixtures/lang.json
vendored
@@ -282,6 +282,81 @@
|
||||
"many": "Odłączono :count rekordów",
|
||||
"other": "Odłączono :count rekordu"
|
||||
},
|
||||
"backend::lang.myaccount.avatar": {
|
||||
"other": "Awatar"
|
||||
},
|
||||
"backend::lang.myaccount.avatar_hint": {
|
||||
"other": "JPG, PNG, GIF lub WebP, maksymalnie 2 MB."
|
||||
},
|
||||
"backend::lang.myaccount.avatar_remove": {
|
||||
"other": "Usuń zdjęcie"
|
||||
},
|
||||
"backend::lang.myaccount.avatar_remove_confirm": {
|
||||
"other": "Usunąć awatar?"
|
||||
},
|
||||
"backend::lang.myaccount.avatar_removed": {
|
||||
"other": "Usunięto awatar"
|
||||
},
|
||||
"backend::lang.myaccount.avatar_saved": {
|
||||
"other": "Zapisano awatar"
|
||||
},
|
||||
"backend::lang.myaccount.avatar_upload": {
|
||||
"other": "Wgraj zdjęcie"
|
||||
},
|
||||
"backend::lang.myaccount.current_password": {
|
||||
"other": "Obecne hasło"
|
||||
},
|
||||
"backend::lang.myaccount.current_password_invalid": {
|
||||
"other": "Obecne hasło jest nieprawidłowe."
|
||||
},
|
||||
"backend::lang.myaccount.email": {
|
||||
"other": "E-mail"
|
||||
},
|
||||
"backend::lang.myaccount.field_not_editable": {
|
||||
"other": "Tego pola nie można zmienić w ustawieniach konta."
|
||||
},
|
||||
"backend::lang.myaccount.first_name": {
|
||||
"other": "Imię"
|
||||
},
|
||||
"backend::lang.myaccount.last_name": {
|
||||
"other": "Nazwisko"
|
||||
},
|
||||
"backend::lang.myaccount.login": {
|
||||
"other": "Login"
|
||||
},
|
||||
"backend::lang.myaccount.menu_description": {
|
||||
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło."
|
||||
},
|
||||
"backend::lang.myaccount.menu_label": {
|
||||
"other": "Moje konto"
|
||||
},
|
||||
"backend::lang.myaccount.password": {
|
||||
"other": "Nowe hasło"
|
||||
},
|
||||
"backend::lang.myaccount.password_comment": {
|
||||
"other": "Pozostaw puste, aby zachować obecne hasło."
|
||||
},
|
||||
"backend::lang.myaccount.password_confirmation": {
|
||||
"other": "Powtórz nowe hasło"
|
||||
},
|
||||
"backend::lang.myaccount.password_section": {
|
||||
"other": "Zmiana hasła"
|
||||
},
|
||||
"backend::lang.myaccount.profile": {
|
||||
"other": "Profil"
|
||||
},
|
||||
"backend::lang.myaccount.saved": {
|
||||
"other": "Zapisano konto"
|
||||
},
|
||||
"backend::lang.myaccount.subtitle": {
|
||||
"other": "Podtytuł"
|
||||
},
|
||||
"backend::lang.myaccount.subtitle_comment": {
|
||||
"other": "Krótki opis pod Twoim imieniem, na przykład rola lub stanowisko."
|
||||
},
|
||||
"backend::lang.myaccount.subtitle_unavailable": {
|
||||
"other": "Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON."
|
||||
},
|
||||
"backend::lang.nav.breadcrumbs": {
|
||||
"other": "Ścieżka nawigacji"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ export const profile = {
|
||||
email: 'dev@example.test',
|
||||
first_name: 'Dana',
|
||||
last_name: 'Dev',
|
||||
subtitle: '',
|
||||
is_superuser: false,
|
||||
role: { id: 2, code: 'developer', name: 'Developer' },
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { AdminProfile } from '../../src/api/types'
|
||||
import UserMenu from '../../src/components/shell/UserMenu.vue'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountMenu(user: Partial<typeof profile.data> | null) {
|
||||
async function mountMenu(user: Partial<AdminProfile> | null) {
|
||||
let calls: Request[] = []
|
||||
if (user) {
|
||||
calls = await signIn({
|
||||
@@ -68,6 +69,48 @@ describe('user menu', () => {
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('opens My account from the menu', async () => {
|
||||
const { wrapper, router } = await mountMenu({})
|
||||
const content = await open(wrapper)
|
||||
const items = [...content.querySelectorAll<HTMLElement>('[role="menuitem"]')].map((item) => item.dataset.action)
|
||||
expect(items).toEqual(['account', 'logout'])
|
||||
const item = content.querySelector<HTMLElement>('[data-action="account"]')!
|
||||
expect(item.getAttribute('role')).toBe('menuitem')
|
||||
expect(item.textContent?.trim()).toBe('Moje konto')
|
||||
|
||||
item.click()
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('account')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/account')
|
||||
expect(currentUser.value).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the avatar thumbnail in place of the initials in the trigger and the menu', async () => {
|
||||
const avatar = { url: '/storage/uploads/a/b/c/abc.png', thumb_url: '/storage/uploads/a/b/c/thumb_1_250_250_0_0_crop.png' }
|
||||
const { wrapper } = await mountMenu({ avatar })
|
||||
const image = wrapper.find('[data-user-avatar]')
|
||||
expect(image.attributes('src')).toBe(avatar.thumb_url)
|
||||
expect(image.attributes('alt')).toBe('')
|
||||
expect(image.classes()).toEqual(expect.arrayContaining(['rounded-full', 'object-cover', 'size-[34px]']))
|
||||
expect(wrapper.find('[data-user-initials]').exists()).toBe(false)
|
||||
const content = await open(wrapper)
|
||||
const header = content.querySelector<HTMLImageElement>('img[data-user-avatar]')!
|
||||
expect(header.getAttribute('src')).toBe(avatar.thumb_url)
|
||||
expect(header.classList.contains('size-10')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the full image without a thumbnail, and to the initials without an avatar', async () => {
|
||||
const { wrapper } = await mountMenu({ avatar: { url: '/storage/uploads/a/b/c/abc.png' } })
|
||||
expect(wrapper.find('[data-user-avatar]').attributes('src')).toBe('/storage/uploads/a/b/c/abc.png')
|
||||
wrapper.unmount()
|
||||
resetState()
|
||||
const plain = await mountMenu({})
|
||||
expect(plain.wrapper.find('[data-user-avatar]').exists()).toBe(false)
|
||||
expect(plain.wrapper.find('[data-user-initials]').text()).toBe('DD')
|
||||
})
|
||||
|
||||
it('opens a menu with the e-mail and logs out to the login route', async () => {
|
||||
const { wrapper, router, calls } = await mountMenu({})
|
||||
const content = await open(wrapper)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearUser, currentUser, login, logout, me, useAuth } from '../../src/state/useAuth'
|
||||
import {
|
||||
clearUser,
|
||||
currentUser,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
profileInitials,
|
||||
setCurrentUser,
|
||||
useAuth,
|
||||
} from '../../src/state/useAuth'
|
||||
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings, settings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
@@ -139,3 +148,24 @@ describe('logout (T-10-23)', () => {
|
||||
expect(auth.clearUser).toBe(clearUser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentUser and profileInitials', () => {
|
||||
it('replaces the signed-in profile, as My account does after a save', async () => {
|
||||
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
|
||||
await me()
|
||||
const next = { ...profile.data, first_name: 'Grace', subtitle: 'Admiral', avatar: { url: '/a.png' } }
|
||||
setCurrentUser(next)
|
||||
expect(currentUser.value).toEqual(next)
|
||||
expect(useAuth().user.value?.subtitle).toBe('Admiral')
|
||||
expect(useAuth().setCurrentUser).toBe(setCurrentUser)
|
||||
clearUser()
|
||||
expect(currentUser.value).toBeNull()
|
||||
})
|
||||
|
||||
it('takes the name initials, else the login letters', () => {
|
||||
expect(profileInitials(null)).toBe('')
|
||||
expect(profileInitials({ first_name: 'grace', last_name: 'hopper', login: 'gh' })).toBe('GH')
|
||||
expect(profileInitials({ first_name: ' ', last_name: 'Hopper', login: 'gh' })).toBe('H')
|
||||
expect(profileInitials({ first_name: '', last_name: '', login: 'root' })).toBe('RO')
|
||||
})
|
||||
})
|
||||
|
||||
237
admin/tests/views/AccountView.test.ts
Normal file
237
admin/tests/views/AccountView.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import AccountView from '../../src/views/AccountView.vue'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { API, mountApp, profile, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const ACCOUNT = `${API}/account`
|
||||
const AVATAR = `${API}/account/avatar`
|
||||
|
||||
const signedIn = {
|
||||
...profile.data,
|
||||
first_name: 'Dana',
|
||||
last_name: 'Dev',
|
||||
subtitle: 'Editor',
|
||||
}
|
||||
|
||||
const avatar = { url: '/storage/uploads/abc/def/ghi/abcdefghi.png', thumb_url: '/storage/uploads/abc/def/ghi/thumb_9_250_250_0_0_crop.png' }
|
||||
|
||||
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
|
||||
|
||||
const invalid = (details: Record<string, string[]>) => ({
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details } },
|
||||
})
|
||||
|
||||
async function openAccount(user: Record<string, unknown> = signedIn, routes: Record<string, Route> = {}) {
|
||||
return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true })
|
||||
}
|
||||
|
||||
function input(wrapper: VueWrapper, name: string) {
|
||||
return wrapper.find<HTMLInputElement>(`#account-${name}`)
|
||||
}
|
||||
|
||||
async function typeInto(wrapper: VueWrapper, name: string, value: string): Promise<void> {
|
||||
await input(wrapper, name).setValue(value)
|
||||
}
|
||||
|
||||
async function save(wrapper: VueWrapper): Promise<void> {
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
||||
return (await request.clone().json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function pickFile(wrapper: VueWrapper, file: File): Promise<void> {
|
||||
const element = wrapper.find<HTMLInputElement>('[data-account-avatar-input]')
|
||||
Object.defineProperty(element.element, 'files', { value: [file], configurable: true })
|
||||
await element.trigger('change')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('My account', () => {
|
||||
it('renders the signed-in profile with the login read-only', async () => {
|
||||
const { wrapper } = await openAccount()
|
||||
expect(wrapper.findComponent(AccountView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Moje konto')
|
||||
const login = input(wrapper, 'login')
|
||||
expect(login.element.value).toBe('dev')
|
||||
expect(login.attributes('readonly')).toBeDefined()
|
||||
expect(input(wrapper, 'first_name').element.value).toBe('Dana')
|
||||
expect(input(wrapper, 'last_name').element.value).toBe('Dev')
|
||||
expect(input(wrapper, 'email').element.value).toBe('dev@example.test')
|
||||
expect(input(wrapper, 'email').attributes('type')).toBe('email')
|
||||
expect(input(wrapper, 'subtitle').element.value).toBe('Editor')
|
||||
expect(input(wrapper, 'subtitle').attributes('aria-describedby')).toBe('account-subtitle-comment')
|
||||
expect(input(wrapper, 'current_password').attributes('autocomplete')).toBe('current-password')
|
||||
expect(input(wrapper, 'password').attributes('autocomplete')).toBe('new-password')
|
||||
expect(input(wrapper, 'password_confirmation').attributes('autocomplete')).toBe('new-password')
|
||||
expect(input(wrapper, 'password').element.value).toBe('')
|
||||
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
|
||||
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sends only the profile keys when no new password is typed', async () => {
|
||||
const { wrapper, calls } = await openAccount(signedIn, {
|
||||
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', subtitle: 'Admiral' }),
|
||||
})
|
||||
await typeInto(wrapper, 'first_name', 'Grace')
|
||||
await typeInto(wrapper, 'subtitle', 'Admiral')
|
||||
await typeInto(wrapper, 'current_password', 'typed-but-unused')
|
||||
await save(wrapper)
|
||||
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
|
||||
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await bodyOf(request!)).toEqual({
|
||||
first_name: 'Grace',
|
||||
last_name: 'Dev',
|
||||
email: 'dev@example.test',
|
||||
subtitle: 'Admiral',
|
||||
})
|
||||
expect(currentUser.value?.first_name).toBe('Grace')
|
||||
expect(currentUser.value?.subtitle).toBe('Admiral')
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano konto', tone: 'success' })
|
||||
})
|
||||
|
||||
it('sends the password keys with a new password, updates the top bar and clears the inputs', async () => {
|
||||
const { wrapper, calls } = await openAccount(signedIn, {
|
||||
[`PUT ${ACCOUNT}`]: envelope({ ...signedIn, first_name: 'Grace', last_name: 'Hopper' }),
|
||||
})
|
||||
await typeInto(wrapper, 'first_name', 'Grace')
|
||||
await typeInto(wrapper, 'last_name', 'Hopper')
|
||||
await typeInto(wrapper, 'current_password', 'old-secret')
|
||||
await typeInto(wrapper, 'password', 'new-secret')
|
||||
await typeInto(wrapper, 'password_confirmation', 'new-secret')
|
||||
await save(wrapper)
|
||||
const [request] = requestsTo(calls, 'PUT', ACCOUNT)
|
||||
expect(await bodyOf(request!)).toEqual({
|
||||
first_name: 'Grace',
|
||||
last_name: 'Hopper',
|
||||
email: 'dev@example.test',
|
||||
subtitle: 'Editor',
|
||||
current_password: 'old-secret',
|
||||
password: 'new-secret',
|
||||
password_confirmation: 'new-secret',
|
||||
})
|
||||
expect(input(wrapper, 'current_password').element.value).toBe('')
|
||||
expect(input(wrapper, 'password').element.value).toBe('')
|
||||
expect(input(wrapper, 'password_confirmation').element.value).toBe('')
|
||||
expect(wrapper.find('[data-user-identity]').text()).toContain('Grace Hopper')
|
||||
expect(wrapper.find('[data-user-initials]').text()).toBe('GH')
|
||||
})
|
||||
|
||||
it('shows 422 errors on their fields, focuses the first and clears an error on input', async () => {
|
||||
const { wrapper } = await openAccount(signedIn, {
|
||||
[`PUT ${ACCOUNT}`]: invalid({
|
||||
current_password: ['Obecne hasło jest nieprawidłowe.'],
|
||||
email: ['The email has already been taken.'],
|
||||
role_id: ['Tego pola nie można zmienić w ustawieniach konta.'],
|
||||
}),
|
||||
})
|
||||
await typeInto(wrapper, 'password', 'new-secret')
|
||||
await save(wrapper)
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-error-banner]').text()).toContain('Tego pola nie można zmienić')
|
||||
const email = input(wrapper, 'email')
|
||||
expect(email.attributes('aria-invalid')).toBe('true')
|
||||
expect(email.attributes('aria-describedby')).toBe('account-email-error')
|
||||
expect(wrapper.find('#account-email-error').text()).toBe('The email has already been taken.')
|
||||
expect(wrapper.find('#account-current_password-error').text()).toBe('Obecne hasło jest nieprawidłowe.')
|
||||
expect(document.activeElement).toBe(email.element)
|
||||
expect(currentUser.value?.first_name).toBe('Dana')
|
||||
await typeInto(wrapper, 'email', 'other@example.test')
|
||||
expect(wrapper.find('#account-email-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#account-current_password-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('toasts the generic error on any other failure', async () => {
|
||||
const { wrapper } = await openAccount(signedIn, {
|
||||
[`PUT ${ACCOUNT}`]: { status: 500, body: { error: { code: 'error', message: 'Server error', details: {} } } },
|
||||
})
|
||||
await save(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uploads the avatar as one file_data part and shows it in the top bar', async () => {
|
||||
const posted: string[] = []
|
||||
const { wrapper, calls } = await openAccount(signedIn, {
|
||||
[`POST ${AVATAR}`]: async (request) => {
|
||||
const form = await request.formData()
|
||||
const file = form.get('file_data') as File
|
||||
posted.push([...form.keys()].join(','), file.name, await file.text())
|
||||
return envelope({ ...signedIn, avatar })
|
||||
},
|
||||
})
|
||||
await pickFile(wrapper, new File(['png-bytes'], 'me.png', { type: 'image/png' }))
|
||||
const [request] = requestsTo(calls, 'POST', AVATAR)
|
||||
expect(request!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(request!.headers.get('Content-Type')).toMatch(/^multipart\/form-data; boundary=/)
|
||||
expect(posted).toEqual(['file_data', 'me.png', 'png-bytes'])
|
||||
expect(currentUser.value?.avatar).toEqual(avatar)
|
||||
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
|
||||
expect(wrapper.find('[data-user-avatar]').attributes('src')).toBe(avatar.thumb_url)
|
||||
expect(wrapper.find('[data-user-initials]').exists()).toBe(false)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Zapisano awatar', tone: 'success' })
|
||||
expect(wrapper.find('[data-account-avatar-input]').attributes('accept')).toBe('image/jpeg,image/png,image/gif,image/webp')
|
||||
})
|
||||
|
||||
it('shows the avatar 422 detail and the hint on 413', async () => {
|
||||
let status = 422
|
||||
const { wrapper } = await openAccount(signedIn, {
|
||||
[`POST ${AVATAR}`]: () =>
|
||||
status === 422
|
||||
? invalid({ avatar: ['The avatar must be an image.'] })
|
||||
: { status: 413, body: { error: { code: 'payload_too_large', message: 'Payload too large', details: {} } } },
|
||||
})
|
||||
await pickFile(wrapper, new File(['<svg/>'], 'logo.svg', { type: 'image/svg+xml' }))
|
||||
expect(wrapper.find('[data-avatar-error]').text()).toBe('The avatar must be an image.')
|
||||
expect(currentUser.value?.avatar).toBeUndefined()
|
||||
status = 413
|
||||
await pickFile(wrapper, new File(['x'], 'big.png', { type: 'image/png' }))
|
||||
expect(wrapper.find('[data-avatar-error]').text()).toBe('JPG, PNG, GIF lub WebP, maksymalnie 2 MB.')
|
||||
})
|
||||
|
||||
it('removes the avatar after a confirmation', async () => {
|
||||
const { wrapper, calls } = await openAccount({ ...signedIn, avatar }, {
|
||||
[`DELETE ${AVATAR}`]: envelope(signedIn),
|
||||
})
|
||||
expect(wrapper.find('[data-account-avatar-image]').attributes('src')).toBe(avatar.thumb_url)
|
||||
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()!.textContent).toContain('Usunąć awatar?')
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(0)
|
||||
|
||||
await wrapper.find('[data-action="avatar-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'DELETE', AVATAR)).toHaveLength(1)
|
||||
expect(currentUser.value?.avatar).toBeUndefined()
|
||||
expect(wrapper.find('[data-account-avatar-initials]').text()).toBe('DD')
|
||||
expect(wrapper.find('[data-action="avatar-remove"]').exists()).toBe(false)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Usunięto awatar', tone: 'success' })
|
||||
})
|
||||
|
||||
it('names the page in the breadcrumbs and the document title', async () => {
|
||||
const { wrapper } = await openAccount()
|
||||
expect(wrapper.find('[data-crumb]').text()).toBe('Moje konto')
|
||||
expect(document.title).toBe('Moje konto | SummerCMS')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,8 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad
|
||||
|
||||
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
|
||||
|
||||
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
|
||||
|
||||
## Types from OpenAPI
|
||||
|
||||
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date:
|
||||
|
||||
@@ -74,6 +74,18 @@ Actions registered through `pact.HasAdminActions`, bulk actions (`pact.HasAdminB
|
||||
|
||||
A permission denial and a refusal are different answers with the same status. A denial comes from the framework: the administrator lacks a permission code, and the 403 carries the framework's fixed text. A refusal comes from controller code that returns a `cabana.ForbiddenError`: the administrator may run the route, but this change is not allowed for this record, and the 403 carries the plugin's translated message and, optionally, messages per field. See [Refusing a write](admin-controllers.md#refusing-a-write).
|
||||
|
||||
## My account
|
||||
|
||||
Every signed-in administrator can edit their own profile on the My account screen, which the SPA opens at `<prefix>/account` from the user menu. It saves through `PUT <prefix>/api/v1/account`, the Go form of WinterCMS's `Users::myaccount`. The route always edits the row of the token's administrator; no id is read from the path or the body.
|
||||
|
||||
- **Editable:** first name, last name, email and subtitle, plus the password. The body may hold only `first_name`, `last_name`, `email`, `subtitle`, `current_password`, `password` and `password_confirmation` (`cabana.AdminAccountRequest`). Any other key, such as `login`, `role_id`, `is_superuser`, `permissions` or `is_activated`, answers 422 on that key and nothing is written. The login is shown but cannot be changed here, and role, superuser flag, permissions and activation stay with the operator commands below.
|
||||
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
|
||||
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
|
||||
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
|
||||
- **Avatar:** `POST <prefix>/api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE <prefix>/api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials.
|
||||
|
||||
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
|
||||
|
||||
## Managing administrators
|
||||
|
||||
The application binary has two commands for operators:
|
||||
|
||||
13
g15office.json
Normal file
13
g15office.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"project": "summercms",
|
||||
"default_status": "To do",
|
||||
"default_type": "Task",
|
||||
"statuses": [
|
||||
"To do",
|
||||
"Rejected",
|
||||
"In progress",
|
||||
"Testing",
|
||||
"Go Live",
|
||||
"Live"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-D3quaPLf.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-D3quaPLf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BFvYYWYJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
|
||||
<script type="module" crossorigin src="./assets/index-D3quaPLf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -33,6 +33,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
|
||||
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
||||
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
|
||||
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
|
||||
@@ -45,7 +46,9 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
|-----------------|---------|
|
||||
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
|
||||
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). |
|
||||
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
|
||||
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||
@@ -212,6 +215,15 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
|
||||
| `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. |
|
||||
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
|
||||
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
|
||||
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
|
||||
| `cabana.AdminAccountUpdate` / `cabana.AdminAccountAvatarUpload` / `cabana.AdminAccountAvatarRemove` | Swag annotations of the My account routes. |
|
||||
| `cabana.AdminAvatar` | The `avatar` of a `cabana.AdminProfile`: the public `url` and the 250x250 crop `thumb_url` (left out when the thumbnail cannot be made). |
|
||||
| `cabana.AvatarMaxBytes` | Largest avatar image the upload route accepts, 2 MiB; `http.body_limits.upload_bytes` still caps the request. |
|
||||
| `cabana.BackendUserAvatar` | Returns a backend user's avatar `attach.File` (the newest avatar row), or nil when there is none; `File.URL` and `File.Thumb` give its public URLs. |
|
||||
| `cabana.BackendUserMorphType` / `cabana.BackendUserAvatarField` | The avatar's `system_files.attachment_type` (WinterCMS's `Backend\Models\User`) and `field` (`avatar`). |
|
||||
| `cabana.BackendUser.MorphName` / `cabana.BackendUser.AttachRelations` | `attach.Owner` and `attach.HasRelations` of the backend user: one public attachOne `avatar`. |
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
|
||||
722
modules/cabana/account.go
Normal file
722
modules/cabana/account.go
Normal file
@@ -0,0 +1,722 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// metadataSubtitle is the backend_users.metadata key of the admin subtitle,
|
||||
// the key WinterCMS's Journal author component reads.
|
||||
const metadataSubtitle = "subtitle"
|
||||
|
||||
// AvatarMaxBytes is the largest avatar image POST /account/avatar accepts:
|
||||
// 2 MiB. http.body_limits.upload_bytes still caps the whole request.
|
||||
const AvatarMaxBytes int64 = 2 << 20
|
||||
|
||||
// avatarThumbEdge is the edge of the avatar's crop thumbnail, WinterCMS's
|
||||
// myaccount avatar size.
|
||||
const avatarThumbEdge = 250
|
||||
|
||||
// avatarLimits are the attach.Store rules of an avatar: a raster image (jpg,
|
||||
// jpeg, png, gif or webp, a sniffed image MIME type and the decode guard) of
|
||||
// at most AvatarMaxBytes.
|
||||
var avatarLimits = attach.Limits{
|
||||
MaxBytes: AvatarMaxBytes,
|
||||
Extensions: attach.DefaultImageExtensions,
|
||||
MIMETypes: attach.AllowedImageMIMEs,
|
||||
Image: true,
|
||||
}
|
||||
|
||||
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
|
||||
const passwordMaxBytes = 72
|
||||
|
||||
// accountKeys are the only keys PUT /account accepts. Any other key (login,
|
||||
// role_id, is_superuser, permissions, is_activated, id and so on) is refused
|
||||
// with a 422 and nothing is written.
|
||||
var accountKeys = []string{
|
||||
"first_name", "last_name", "email", "subtitle",
|
||||
"current_password", "password", "password_confirmation",
|
||||
}
|
||||
|
||||
// accountRules are the field rules of PUT /account. The confirmation, the
|
||||
// current password and the email uniqueness are checked in Go afterwards.
|
||||
var accountRules = []lagoon.RequestRule{
|
||||
{Field: "first_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "last_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "email", Rules: lagoon.ParseRules("required|string|email|between:6,255")},
|
||||
{Field: "subtitle", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "current_password", Rules: lagoon.ParseRules("nullable|string")},
|
||||
{Field: "password", Rules: lagoon.ParseRules("nullable|string|min:4|max:72")},
|
||||
{Field: "password_confirmation", Rules: lagoon.ParseRules("nullable|string")},
|
||||
}
|
||||
|
||||
// errAccountGone is a signed-in admin whose row is missing or deactivated.
|
||||
var errAccountGone = errors.New("cabana: account is missing or deactivated")
|
||||
|
||||
// errMetadataNotObject is stored metadata that is neither empty, [] nor a
|
||||
// JSON object; it is never overwritten.
|
||||
var errMetadataNotObject = errors.New("cabana: backend user metadata is not a JSON object")
|
||||
|
||||
// decodeMetadata parses Winter's jsonable metadata. NULL, blank, null and []
|
||||
// (PHP's json_encode of an empty array) are an empty object. ok is false for
|
||||
// text that is not a JSON object.
|
||||
func decodeMetadata(raw *string) (map[string]json.RawMessage, bool) {
|
||||
if raw == nil {
|
||||
return map[string]json.RawMessage{}, true
|
||||
}
|
||||
text := strings.TrimSpace(*raw)
|
||||
if text == "" || text == "null" || text == "[]" {
|
||||
return map[string]json.RawMessage{}, true
|
||||
}
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil || out == nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// metadataString returns the string value of key in Winter's jsonable
|
||||
// metadata, or "" when the metadata is empty or malformed or the value is
|
||||
// not a string.
|
||||
func metadataString(raw *string, key string) string {
|
||||
values, ok := decodeMetadata(raw)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
encoded, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(encoded, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setMetadataString returns metadata with key set to value, every other key
|
||||
// kept as stored. An empty value removes the key, and metadata without keys
|
||||
// is nil (SQL NULL). Stored text that is not empty, [] or a JSON object is
|
||||
// an error and is never overwritten.
|
||||
func setMetadataString(raw *string, key, value string) (*string, error) {
|
||||
values, ok := decodeMetadata(raw)
|
||||
if !ok {
|
||||
return nil, errMetadataNotObject
|
||||
}
|
||||
if value == "" {
|
||||
delete(values, key)
|
||||
} else {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = encoded
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := string(out)
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
|
||||
// the My account routes. The avatar is looked up through db; a lookup or
|
||||
// thumbnail failure is logged and leaves the avatar (or its thumb_url) out,
|
||||
// so the profile never fails on it.
|
||||
func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Subtitle: user.Subtitle(),
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
if db != nil {
|
||||
profile.Avatar = s.avatarOf(ctx, db, user.ID)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// avatarOf is the public URL and the 250x250 crop thumbnail URL of an
|
||||
// admin's avatar, or nil when there is none. A protected row (is_public
|
||||
// false, possible only in copied data) has no public URL and is left out.
|
||||
func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *AdminAvatar {
|
||||
f, err := BackendUserAvatar(ctx, db, userID)
|
||||
if err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err)
|
||||
return nil
|
||||
}
|
||||
if f == nil || !f.Public() {
|
||||
return nil
|
||||
}
|
||||
out := &AdminAvatar{URL: f.URL()}
|
||||
if bucket := s.bucket(); bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
||||
thumb, err := f.Thumb(ctx, bucket, avatarThumbEdge, avatarThumbEdge, "crop")
|
||||
if err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err)
|
||||
} else {
|
||||
out.ThumbURL = thumb
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BackendUserAvatar returns a backend user's avatar: the newest system_files
|
||||
// row with attachment_type cabana.BackendUserMorphType, field
|
||||
// cabana.BackendUserAvatarField and the user's id, or nil, nil when there is
|
||||
// none. Plugins call it to show an author's picture (File.URL, File.Thumb).
|
||||
func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("cabana: avatar lookup without a database")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var files []attach.File
|
||||
err := db.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Where("attachment_type = ? AND field = ? AND attachment_id = ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID)).
|
||||
Order("id DESC").Limit(1).Find(&files).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &files[0], nil
|
||||
}
|
||||
|
||||
// accountPrincipal is the signed-in backend principal, or nil after a 401
|
||||
// was written.
|
||||
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return nil
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
// lockAccount loads the admin's own row FOR UPDATE with the role, by the
|
||||
// principal id only. A missing or deactivated row is errAccountGone.
|
||||
func lockAccount(ctx context.Context, tx *gorm.DB, id uint) (BackendUser, error) {
|
||||
var user BackendUser
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("Role").Where("id = ?", id).Take(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BackendUser{}, errAccountGone
|
||||
}
|
||||
if err != nil {
|
||||
return BackendUser{}, err
|
||||
}
|
||||
if !user.IsActivated {
|
||||
return BackendUser{}, errAccountGone
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// backendMessage is a backend::lang line in the request locale, or fallback
|
||||
// when no translator has the key.
|
||||
func backendMessage(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
|
||||
full := "backend::lang." + key
|
||||
if tr != nil && tr.Has(full) {
|
||||
if s := tr.Get(ctx, full, nil); s != "" && s != full {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// accountMessage is a lagoon::validation line for a My account field, with
|
||||
// Laravel's English text for the rules fieldMessage has no fallback for.
|
||||
func accountMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
key := "lagoon::validation." + rule
|
||||
if tr == nil || !tr.Has(key) {
|
||||
attr := strings.ReplaceAll(field, "_", " ")
|
||||
switch rule {
|
||||
case "confirmed":
|
||||
return "The " + attr + " confirmation does not match."
|
||||
case "unique":
|
||||
return "The " + attr + " has already been taken."
|
||||
case "max.string":
|
||||
return "The " + attr + " may not be greater than " + params["max"] + " characters."
|
||||
}
|
||||
}
|
||||
return fieldMessage(ctx, tr, rule, field, params)
|
||||
}
|
||||
|
||||
// accountString is the trimmed string value of key, "" when it is absent,
|
||||
// null or not a string.
|
||||
func accountString(body map[string]any, key string) string {
|
||||
s, _ := body[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// accountUpdate serves PUT /account: the signed-in admin edits their own
|
||||
// first name, last name, email and subtitle and, with the current password,
|
||||
// changes the password. The row is chosen by the token subject, never by the
|
||||
// request. A password change ends every other session (tokens_valid_after)
|
||||
// and gives a cookie session a fresh cookie.
|
||||
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil {
|
||||
return
|
||||
}
|
||||
body, err := s.decodeCappedObject(w, r)
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
tr := s.translator()
|
||||
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
|
||||
writeCRUDError(w, &ValidationError{Details: details})
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
// Laravel's TrimStrings: names, email and subtitle are trimmed before
|
||||
// they are validated; passwords are taken as typed.
|
||||
for _, key := range []string{"first_name", "last_name", "email", "subtitle"} {
|
||||
if v, ok := body[key].(string); ok {
|
||||
body[key] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
password, _ := body["password"].(string)
|
||||
changePassword := password != ""
|
||||
var saved BackendUser
|
||||
var failedCurrent bool
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
user, err := lockAccount(ctx, tx, principal.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errs, err := lagoon.ValidateRequest(ctx, tx, body, accountRules, tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errs == nil {
|
||||
errs = map[string][]string{}
|
||||
}
|
||||
add := func(field, message string) { errs[field] = append(errs[field], message) }
|
||||
if changePassword {
|
||||
if _, failed := errs["password"]; !failed && len(password) > passwordMaxBytes {
|
||||
add("password", accountMessage(ctx, tr, "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)}))
|
||||
}
|
||||
if confirmation, _ := body["password_confirmation"].(string); confirmation != password {
|
||||
add("password", accountMessage(ctx, tr, "confirmed", "password", nil))
|
||||
}
|
||||
current, _ := body["current_password"].(string)
|
||||
switch {
|
||||
case len(errs["current_password"]) > 0:
|
||||
case current == "":
|
||||
add("current_password", fieldMessage(ctx, tr, "required", "current_password", nil))
|
||||
case !bouncer.CheckPassword(user.Password, current):
|
||||
add("current_password", backendMessage(ctx, tr, "myaccount.current_password_invalid", "The current password is incorrect."))
|
||||
failedCurrent = true
|
||||
}
|
||||
}
|
||||
email := accountString(body, "email")
|
||||
if len(errs["email"]) == 0 {
|
||||
taken, err := emailTaken(ctx, tx, user.ID, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
add("email", accountMessage(ctx, tr, "unique", "email", nil))
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"first_name": accountString(body, "first_name"),
|
||||
"last_name": accountString(body, "last_name"),
|
||||
"email": email,
|
||||
"updated_at": now,
|
||||
}
|
||||
if subtitle := accountString(body, "subtitle"); len(errs["subtitle"]) == 0 && subtitle != user.Subtitle() {
|
||||
metadata, err := setMetadataString(user.Metadata, metadataSubtitle, subtitle)
|
||||
if err != nil {
|
||||
add("subtitle", backendMessage(ctx, tr, "myaccount.subtitle_unavailable", "The subtitle cannot be saved because the stored profile metadata is not a JSON object."))
|
||||
} else {
|
||||
updates["metadata"] = metadata
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return &ValidationError{Details: accountDetails(errs)}
|
||||
}
|
||||
if changePassword {
|
||||
hash, err := bouncer.HashPassword(s.bcryptCost, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["password"] = hash
|
||||
// JWT iat has whole-second precision: the cutoff is the start of
|
||||
// this second, so the fresh cookie minted after it still passes
|
||||
// while every token issued in an earlier second is refused.
|
||||
updates["tokens_valid_after"] = now.Truncate(time.Second)
|
||||
}
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||
if emailConflict(err) {
|
||||
return &ValidationError{Details: fieldDetail("email", accountMessage(ctx, tr, "unique", "email", nil))}
|
||||
}
|
||||
return err
|
||||
}
|
||||
saved, err = lockAccount(ctx, tx, user.ID)
|
||||
return err
|
||||
})
|
||||
if failedCurrent {
|
||||
s.logAuth(r, "failed", principal.ID)
|
||||
}
|
||||
if err != nil {
|
||||
s.writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
if changePassword {
|
||||
s.logAuth(r, "password_changed", principal.ID)
|
||||
s.revokePresentedToken(r)
|
||||
if _, fromCookie := sessionToken(r); fromCookie {
|
||||
token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err)
|
||||
} else {
|
||||
s.writeSessionCookie(w, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||
}
|
||||
|
||||
// revokePresentedToken blacklists the token the request was made with, so
|
||||
// the session that changed the password cannot be replayed even within the
|
||||
// second the tokens_valid_after cutoff cannot separate.
|
||||
func (s *service) revokePresentedToken(r *http.Request) {
|
||||
if s.bl == nil {
|
||||
return
|
||||
}
|
||||
raw, _ := sessionToken(r)
|
||||
_, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
|
||||
if until := exp.Add(time.Minute); until.After(expiresAt) {
|
||||
expiresAt = until
|
||||
}
|
||||
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
|
||||
slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// refusedAccountKeys answers one detail per body key PUT /account does not
|
||||
// accept.
|
||||
func refusedAccountKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
|
||||
var details map[string]any
|
||||
for key := range body {
|
||||
if slices.Contains(accountKeys, key) {
|
||||
continue
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// accountDetails orders each field's messages as lagoon.ErrorKeys ranks the
|
||||
// fields and returns them as validation details.
|
||||
func accountDetails(errs map[string][]string) map[string]any {
|
||||
details := make(map[string]any, len(errs))
|
||||
for _, key := range lagoon.ErrorKeys(errs, accountRules) {
|
||||
details[key] = errs[key]
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// emailTaken reports whether email equals, ignoring case, the email or login
|
||||
// of any other admin, soft-deleted admins included.
|
||||
func emailTaken(ctx context.Context, tx *gorm.DB, id uint, email string) (bool, error) {
|
||||
if email == "" {
|
||||
return false, nil
|
||||
}
|
||||
var n int64
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Unscoped().Model(&BackendUser{}).
|
||||
Where("id <> ? AND (lower(email) = lower(?) OR lower(login) = lower(?))", id, email, email).
|
||||
Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// emailConflict reports a unique violation on a backend_users email index,
|
||||
// the race the emailTaken check cannot close.
|
||||
func emailConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
|
||||
return false
|
||||
}
|
||||
return pgErr.ConstraintName == "backend_users_email_lower_unique" || pgErr.ConstraintName == "backend_users_email_key"
|
||||
}
|
||||
|
||||
// writeAccountError maps a My account failure: a missing or deactivated
|
||||
// admin is 401, a body past the cap 413, validation 422 and anything else a
|
||||
// logged 500.
|
||||
func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, errAccountGone) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
var ve *ValidationError
|
||||
var tooBig *http.MaxBytesError
|
||||
if !errors.As(err, &ve) && !errors.As(err, &tooBig) {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err)
|
||||
}
|
||||
writeCRUDError(w, err)
|
||||
}
|
||||
|
||||
// avatarCap is the request body cap of an avatar upload: AvatarMaxBytes plus
|
||||
// the multipart framing, lowered to http.body_limits.upload_bytes.
|
||||
func (s *service) avatarCap() int64 {
|
||||
limit := AvatarMaxBytes + multipartOverhead
|
||||
if s != nil && s.uploadBytes > 0 && s.uploadBytes < limit {
|
||||
limit = s.uploadBytes
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// detachAvatars deletes every avatar row of an admin inside tx and returns
|
||||
// the blob keys of the originals and their thumbnails. The rows are locked
|
||||
// first; the caller deletes the blobs after the commit.
|
||||
func detachAvatars(ctx context.Context, tx *gorm.DB, userID uint, keep uint) ([]string, error) {
|
||||
var rows []attach.File
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("attachment_type = ? AND field = ? AND attachment_id = ? AND id <> ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID), keep).
|
||||
Order("id").Find(&rows).Error
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
var keys []string
|
||||
for _, f := range rows {
|
||||
ids = append(ids, f.ID)
|
||||
keys = append(keys, attach.BlobKeys(f)...)
|
||||
}
|
||||
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id IN ?", ids).Delete(&attach.File{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// deleteAvatarBlobs removes detached avatar blobs once tx has committed; a
|
||||
// failure is logged and never fails the request.
|
||||
func deleteAvatarBlobs(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, userID uint, keys []string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
if bucket == nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID)
|
||||
return
|
||||
}
|
||||
if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil {
|
||||
slog.Default().WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// avatarUpload serves POST /account/avatar: one multipart file_data part,
|
||||
// stored through attach.Store as a public image of at most AvatarMaxBytes
|
||||
// under a server-generated disk name, attached to the signed-in admin as the
|
||||
// avatar attachOne. The previous avatar row is deleted in the same
|
||||
// transaction and its blobs after the commit.
|
||||
func (s *service) avatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil {
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
if bucket == nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket")
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.avatarCap())}
|
||||
r.Body = io.NopCloser(body)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
s.writeAvatarError(w, r, body, multipartFailure(body, err))
|
||||
return
|
||||
}
|
||||
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
var stored *attach.File
|
||||
var saved BackendUser
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
// The row lock serialises concurrent avatar changes of one admin.
|
||||
user, err := lockAccount(ctx, tx, principal.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Store keeps only a sanitized base name as file_name; the disk name
|
||||
// and the blob key are generated, never taken from the client.
|
||||
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, avatarLimits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored = f
|
||||
// Exactly one part: anything after file_data is refused.
|
||||
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return invalidBody()
|
||||
}
|
||||
return multipartFailure(body, err)
|
||||
}
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if err := q.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
|
||||
"attachment_id": uitoa(user.ID),
|
||||
"attachment_type": BackendUserMorphType,
|
||||
"field": BackendUserAvatarField,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := detachAvatars(ctx, tx, user.ID, f.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
|
||||
saved = user
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// attach.Store wrote the blob before the row; a rolled-back
|
||||
// transaction leaves it to this caller.
|
||||
if stored != nil {
|
||||
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||
}
|
||||
s.writeAvatarError(w, r, body, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||
}
|
||||
|
||||
// avatarRemove serves DELETE /account/avatar: it deletes the signed-in
|
||||
// admin's avatar rows now and their blobs after the commit. Removing an
|
||||
// avatar that is not there also answers 200.
|
||||
func (s *service) avatarRemove(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil {
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
bucket := s.bucket()
|
||||
var saved BackendUser
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
user, err := lockAccount(ctx, tx, principal.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := detachAvatars(ctx, tx, user.ID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
|
||||
saved = user
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||
}
|
||||
|
||||
// multipartFailure is the error of a multipart read: a body past the cap
|
||||
// stays a *http.MaxBytesError (413), and an empty or malformed body (no
|
||||
// part at all, a broken boundary) is the invalid-body 422, never a 500.
|
||||
func multipartFailure(body *bodyReader, err error) error {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
|
||||
return err
|
||||
}
|
||||
return invalidBody()
|
||||
}
|
||||
|
||||
// writeAvatarError maps an avatar upload failure as writeFileError does,
|
||||
// with the field name avatar: a body past the cap is 413, an attach.Store
|
||||
// refusal a 422 on avatar and a malformed multipart body a 422 on body.
|
||||
func (s *service) writeAvatarError(w http.ResponseWriter, r *http.Request, body *bodyReader, err error) {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
|
||||
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
|
||||
return
|
||||
}
|
||||
ctx, tr := r.Context(), s.translator()
|
||||
var detail string
|
||||
switch {
|
||||
case errors.Is(err, attach.ErrTooLarge):
|
||||
detail = fieldMessage(ctx, tr, "max.file", BackendUserAvatarField, map[string]string{"max": strconv.FormatInt(AvatarMaxBytes/1024, 10)})
|
||||
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
|
||||
detail = fieldMessage(ctx, tr, "mimes", BackendUserAvatarField, map[string]string{"values": strings.Join(attach.DefaultImageExtensions, ", ")})
|
||||
case errors.Is(err, attach.ErrNotImage):
|
||||
detail = fieldMessage(ctx, tr, "image", BackendUserAvatarField, nil)
|
||||
}
|
||||
if detail != "" {
|
||||
writeCRUDError(w, &ValidationError{Details: fieldDetail(BackendUserAvatarField, detail)})
|
||||
return
|
||||
}
|
||||
if body != nil && body.err != nil {
|
||||
writeCRUDError(w, invalidBody())
|
||||
return
|
||||
}
|
||||
s.writeAccountError(w, r, err)
|
||||
}
|
||||
246
modules/cabana/account_internal_test.go
Normal file
246
modules/cabana/account_internal_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// TestMetadataSubtitleHelpers covers the lenient reader and the
|
||||
// key-preserving writer of Winter's jsonable backend_users.metadata.
|
||||
func TestMetadataSubtitleHelpers(t *testing.T) {
|
||||
t.Run("read", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw *string
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"blank", strPtr(""), ""},
|
||||
{"spaces", strPtr(" "), ""},
|
||||
{"null", strPtr("null"), ""},
|
||||
{"php empty array", strPtr("[]"), ""},
|
||||
{"subtitle", strPtr(`{"subtitle":"Editor"}`), "Editor"},
|
||||
{"padded subtitle", strPtr(` {"subtitle":"Editor","x":1} `), "Editor"},
|
||||
{"number", strPtr(`{"subtitle":5}`), ""},
|
||||
{"null value", strPtr(`{"subtitle":null}`), ""},
|
||||
{"missing key", strPtr(`{"x":"y"}`), ""},
|
||||
{"invalid json", strPtr(`{"subtitle":`), ""},
|
||||
{"string", strPtr(`"text"`), ""},
|
||||
{"array", strPtr(`[1]`), ""},
|
||||
} {
|
||||
if got := metadataString(tc.raw, metadataSubtitle); got != tc.want {
|
||||
t.Errorf("%s: metadataString = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
if got := (BackendUser{Metadata: tc.raw}).Subtitle(); got != tc.want {
|
||||
t.Errorf("%s: Subtitle = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write onto empty metadata", func(t *testing.T) {
|
||||
for _, raw := range []*string{nil, strPtr(""), strPtr("null"), strPtr("[]")} {
|
||||
got, err := setMetadataString(raw, metadataSubtitle, "Editor")
|
||||
if err != nil || got == nil || *got != `{"subtitle":"Editor"}` {
|
||||
t.Fatalf("set on %v = %v, %v", raw, got, err)
|
||||
}
|
||||
cleared, err := setMetadataString(raw, metadataSubtitle, "")
|
||||
if err != nil || cleared != nil {
|
||||
t.Fatalf("clear on %v = %v, %v", raw, cleared, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write keeps other keys byte-equal", func(t *testing.T) {
|
||||
raw := strPtr(`{"subtitle":"a","x":{"y":[1,2]}}`)
|
||||
got, err := setMetadataString(raw, metadataSubtitle, "b")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("set = %v, %v", got, err)
|
||||
}
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(*got), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(decoded["x"]) != `{"y":[1,2]}` || string(decoded["subtitle"]) != `"b"` {
|
||||
t.Fatalf("metadata = %s", *got)
|
||||
}
|
||||
cleared, err := setMetadataString(got, metadataSubtitle, "")
|
||||
if err != nil || cleared == nil || *cleared != `{"x":{"y":[1,2]}}` {
|
||||
t.Fatalf("clear = %v, %v", cleared, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clearing the last key is NULL", func(t *testing.T) {
|
||||
got, err := setMetadataString(strPtr(`{"subtitle":"Editor"}`), metadataSubtitle, "")
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("clear = %v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text that is not an object is never overwritten", func(t *testing.T) {
|
||||
for _, raw := range []string{`{"subtitle":`, `"text"`, `[1]`, `5`} {
|
||||
got, err := setMetadataString(strPtr(raw), metadataSubtitle, "Editor")
|
||||
if !errors.Is(err, errMetadataNotObject) || got != nil {
|
||||
t.Fatalf("set on %q = %v, %v", raw, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("values are JSON-escaped", func(t *testing.T) {
|
||||
got, err := setMetadataString(nil, metadataSubtitle, `Quote " and \ back`)
|
||||
if err != nil || got == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back := metadataString(got, metadataSubtitle); back != `Quote " and \ back` {
|
||||
t.Fatalf("round trip = %q", back)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBackendUserAttachContract pins the WinterCMS morph name and the avatar
|
||||
// attachOne, the account helpers that need no database, and the
|
||||
// unique-violation mapping.
|
||||
func TestBackendUserAttachContract(t *testing.T) {
|
||||
var owner attach.Owner = BackendUser{}
|
||||
if owner.MorphName() != `Backend\Models\User` || BackendUserMorphType != `Backend\Models\User` {
|
||||
t.Fatalf("MorphName = %q", owner.MorphName())
|
||||
}
|
||||
var declared attach.HasRelations = BackendUser{}
|
||||
rels := declared.AttachRelations()
|
||||
if len(rels) != 1 || rels[0] != (attach.Relation{Name: "avatar", Many: false, Public: true}) || BackendUserAvatarField != "avatar" {
|
||||
t.Fatalf("AttachRelations = %#v", rels)
|
||||
}
|
||||
if AvatarMaxBytes != 2<<20 {
|
||||
t.Fatalf("AvatarMaxBytes = %d", AvatarMaxBytes)
|
||||
}
|
||||
|
||||
t.Run("avatar cap", func(t *testing.T) {
|
||||
if got := (&service{}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
|
||||
t.Fatalf("uncapped = %d", got)
|
||||
}
|
||||
if got := (&service{uploadBytes: 1 << 20}).avatarCap(); got != 1<<20 {
|
||||
t.Fatalf("upload_bytes 1 MiB = %d", got)
|
||||
}
|
||||
if got := (&service{uploadBytes: 64 << 20}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
|
||||
t.Fatalf("upload_bytes 64 MiB = %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unique violation mapping", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_lower_unique"}, true},
|
||||
{fmt.Errorf("wrapped: %w", &pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_key"}), true},
|
||||
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_login_key"}, false},
|
||||
{&pgconn.PgError{Code: "23503", ConstraintName: "backend_users_email_lower_unique"}, false},
|
||||
{errors.New("23505 backend_users_email_lower_unique"), false},
|
||||
} {
|
||||
if got := emailConflict(tc.err); got != tc.want {
|
||||
t.Errorf("emailConflict(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("messages fall back to English without a translator", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if got := backendMessage(ctx, nil, "myaccount.current_password_invalid", "fallback"); got != "fallback" {
|
||||
t.Fatalf("backendMessage = %q", got)
|
||||
}
|
||||
for rule, want := range map[string]string{
|
||||
"confirmed": "The password confirmation does not match.",
|
||||
"unique": "The password has already been taken.",
|
||||
"max.string": "The password may not be greater than 72 characters.",
|
||||
"required": "The password field is required.",
|
||||
} {
|
||||
if got := accountMessage(ctx, nil, rule, "password", map[string]string{"max": "72"}); got != want {
|
||||
t.Errorf("accountMessage(%s) = %q, want %q", rule, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("details follow the rule order", func(t *testing.T) {
|
||||
details := accountDetails(map[string][]string{"password": {"b"}, "email": {"a"}})
|
||||
if len(details) != 2 || details["email"].([]string)[0] != "a" || details["password"].([]string)[0] != "b" {
|
||||
t.Fatalf("details = %#v", details)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refused keys", func(t *testing.T) {
|
||||
details := refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "login": "y", "role_id": 1})
|
||||
if len(details) != 2 || details["login"] == nil || details["role_id"] == nil {
|
||||
t.Fatalf("details = %#v", details)
|
||||
}
|
||||
if refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "subtitle": nil}) != nil {
|
||||
t.Fatal("allowed keys were refused")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountRoutesRefuseNonBackendPrincipals calls the three My account
|
||||
// handlers without the guard: no principal, a frontend principal and a
|
||||
// zero id all answer 401 before any body is read or any query runs.
|
||||
func TestAccountRoutesRefuseNonBackendPrincipals(t *testing.T) {
|
||||
svc := &service{}
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"account update": svc.accountUpdate,
|
||||
"avatar upload": svc.avatarUpload,
|
||||
"avatar remove": svc.avatarRemove,
|
||||
}
|
||||
principals := map[string]*bouncer.Principal{
|
||||
"none": nil,
|
||||
"frontend": {ID: 4, Backend: false},
|
||||
"zero id": {ID: 0, Backend: true},
|
||||
}
|
||||
for name, handler := range handlers {
|
||||
for kind, principal := range principals {
|
||||
t.Run(name+" "+kind, func(t *testing.T) {
|
||||
spy := &readSpy{r: strings.NewReader(`{"email":"x@example.test"}`)}
|
||||
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), spy)
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
|
||||
if spy.reads != 0 {
|
||||
t.Fatalf("body read %d times", spy.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("backend principal without a database", func(t *testing.T) {
|
||||
super := &bouncer.Principal{ID: 1, Backend: true}
|
||||
for name, handler := range handlers {
|
||||
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), strings.NewReader(`{"email":"admin@example.test"}`))
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), super))
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("avatar lookup without a database", func(t *testing.T) {
|
||||
if f, err := BackendUserAvatar(context.Background(), nil, 1); f != nil || err == nil {
|
||||
t.Fatalf("lookup = %v, %v", f, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
1138
modules/cabana/account_test.go
Normal file
1138
modules/cabana/account_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,13 +99,39 @@ type AdminRoleSummary struct {
|
||||
|
||||
// AdminProfile is the GET /auth/me payload.
|
||||
type AdminProfile struct {
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
// Subtitle is backend_users.metadata.subtitle; "" when not set.
|
||||
Subtitle string `json:"subtitle"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||
// Avatar is present when the admin has a public avatar.
|
||||
Avatar *AdminAvatar `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// AdminAvatar is an admin's avatar: the public URL of the image and of its
|
||||
// 250x250 crop thumbnail (left out when the thumbnail cannot be made).
|
||||
type AdminAvatar struct {
|
||||
URL string `json:"url"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
}
|
||||
|
||||
// AdminAccountRequest is the body of PUT /account. It documents the only keys
|
||||
// the route accepts: any other key is a 422 on that key and nothing is
|
||||
// written. PUT replaces the profile, so an absent first_name, last_name or
|
||||
// subtitle is saved empty. The password keys change the password only when
|
||||
// password is not empty, and then current_password must match.
|
||||
type AdminAccountRequest struct {
|
||||
Email string `json:"email"`
|
||||
FirstName *string `json:"first_name,omitempty"`
|
||||
LastName *string `json:"last_name,omitempty"`
|
||||
Subtitle *string `json:"subtitle,omitempty"`
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PasswordConfirmation string `json:"password_confirmation,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /auth/login.
|
||||
@@ -164,6 +190,7 @@ func AdminLogout() {}
|
||||
// AdminMe documents GET /auth/me.
|
||||
//
|
||||
// @Summary Current admin
|
||||
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
@@ -174,6 +201,53 @@ func AdminLogout() {}
|
||||
// @Router /auth/me [get]
|
||||
func AdminMe() {}
|
||||
|
||||
// AdminAccountUpdate documents PUT /account.
|
||||
//
|
||||
// @Summary Update my account
|
||||
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param body body AdminAccountRequest true "Profile and optional password change"
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /account [put]
|
||||
func AdminAccountUpdate() {}
|
||||
|
||||
// AdminAccountAvatarUpload documents POST /account/avatar.
|
||||
//
|
||||
// @Summary Upload my avatar
|
||||
// @Description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
|
||||
// @Tags admin
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param file_data formData file true "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)"
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /account/avatar [post]
|
||||
func AdminAccountAvatarUpload() {}
|
||||
|
||||
// AdminAccountAvatarRemove documents DELETE /account/avatar.
|
||||
//
|
||||
// @Summary Remove my avatar
|
||||
// @Description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /account/avatar [delete]
|
||||
func AdminAccountAvatarRemove() {}
|
||||
|
||||
// AdminNavigation documents GET /navigation.
|
||||
//
|
||||
// @Summary Admin navigation
|
||||
|
||||
@@ -356,22 +356,7 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
|
||||
}
|
||||
|
||||
func profileOf(user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
return profile
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, user), map[string]any{})
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -30,6 +31,9 @@ func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
||||
// BackendUser is the Winter backend_users row. It is not a frontend user.
|
||||
// Nullable Winter columns are mapped so a copied row round-trips without a
|
||||
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
|
||||
// Metadata is Winter's jsonable metadata column kept as raw text, so a copied
|
||||
// value that is not a JSON object (NULL, blank or []) still loads; read it
|
||||
// through Subtitle.
|
||||
type BackendUser struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
@@ -41,6 +45,7 @@ type BackendUser struct {
|
||||
PersistCode string `gorm:"column:persist_code"`
|
||||
ResetPasswordCode string `gorm:"column:reset_password_code"`
|
||||
Permissions string `gorm:"column:permissions"`
|
||||
Metadata *string `gorm:"column:metadata"`
|
||||
IsActivated bool `gorm:"column:is_activated"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser"`
|
||||
RoleID *uint `gorm:"column:role_id"`
|
||||
@@ -55,6 +60,30 @@ type BackendUser struct {
|
||||
|
||||
func (BackendUser) TableName() string { return "backend_users" }
|
||||
|
||||
const (
|
||||
// BackendUserMorphType is the system_files attachment_type of a backend
|
||||
// user's files: WinterCMS's class string, so avatar rows copied from
|
||||
// WinterCMS match as they are.
|
||||
BackendUserMorphType = "Backend\\Models\\User"
|
||||
// BackendUserAvatarField is the attachOne relation (system_files.field)
|
||||
// of a backend user's avatar.
|
||||
BackendUserAvatarField = "avatar"
|
||||
)
|
||||
|
||||
// MorphName implements attach.Owner with WinterCMS's class string.
|
||||
func (BackendUser) MorphName() string { return BackendUserMorphType }
|
||||
|
||||
// AttachRelations implements attach.HasRelations: one public attachOne
|
||||
// avatar, as WinterCMS's Backend\Models\User declares it.
|
||||
func (BackendUser) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: BackendUserAvatarField, Public: true}}
|
||||
}
|
||||
|
||||
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
|
||||
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
|
||||
// all read as an empty subtitle.
|
||||
func (u BackendUser) Subtitle() string { return metadataString(u.Metadata, metadataSubtitle) }
|
||||
|
||||
// Option is a dropdown choice shared with later schema plans.
|
||||
type Option = pact.Option
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ func (s *service) mount(r pact.Router) {
|
||||
})
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
// My account: the signed-in admin edits their own profile.
|
||||
g.Put("/account", requireAjax(s.accountUpdate))
|
||||
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||
g.Get("/navigation", s.navigation)
|
||||
// Form previews render through RenderMarkdown; nothing is stored.
|
||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
||||
|
||||
@@ -356,6 +356,44 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||
{"PUT /account", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
// No password: a password change would end the token the later
|
||||
// cases still use.
|
||||
rec := e.send(t, http.MethodPut, "/account", map[string]any{"first_name": "Conform", "last_name": "Admin", "email": e.login + "@example.test", "subtitle": "Conformance"}, true)
|
||||
var body cabana.Envelope[cabana.AdminProfile]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.FirstName != "Conform" || body.Data.Subtitle != "Conformance" {
|
||||
t.Fatalf("account body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"POST /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file_data", "avatar.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(conformPNG(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := e.sendWith(t, http.MethodPost, "/account/avatar", buf.Bytes(), mw.FormDataContentType(), nil)
|
||||
var body cabana.Envelope[cabana.AdminProfile]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar == nil || body.Data.Avatar.URL == "" {
|
||||
t.Fatalf("avatar upload body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"DELETE /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
rec := e.sendWith(t, http.MethodDelete, "/account/avatar", nil, "", nil)
|
||||
var body cabana.Envelope[cabana.AdminProfile]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar != nil {
|
||||
t.Fatalf("avatar remove body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||
|
||||
@@ -88,9 +88,11 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
}
|
||||
sort.Strings(unsafe)
|
||||
want := []string{
|
||||
"DELETE /account/avatar",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
"POST /account/avatar",
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /auth/refresh",
|
||||
@@ -109,6 +111,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||
"PUT /account",
|
||||
"PUT /settings/{code}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
@@ -117,7 +120,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
}
|
||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 29 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
}
|
||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||
// nested pattern serving field options, filter options and relation lists.
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||
// refresh, logout, account update, avatar upload and remove, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||
// widget action, toolbar action, update, delete, record action, link,
|
||||
// unlink, file
|
||||
// upload, file reorder, file caption, file remove, relation child
|
||||
// create, update and delete, pivot update, child file upload, reorder,
|
||||
// caption and remove
|
||||
if unsafe != 26 {
|
||||
t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order)
|
||||
if unsafe != 29 {
|
||||
t.Fatalf("walked %d state-changing routes, want 29: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -39,6 +39,9 @@ var phase09Routes = []adminRoute{
|
||||
// refreshable token can be revoked) and is therefore not behind the guard.
|
||||
{key: "POST /auth/logout", public: true},
|
||||
{key: "GET /auth/me"},
|
||||
{key: "PUT /account"},
|
||||
{key: "POST /account/avatar"},
|
||||
{key: "DELETE /account/avatar"},
|
||||
{key: "GET /navigation"},
|
||||
{key: "GET /settings"},
|
||||
{key: "GET /settings/{code}/schema"},
|
||||
|
||||
@@ -124,7 +124,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.RollbackLast` | Rolls back the last migration of one plugin. |
|
||||
| `lagoon.Status` | Lists applied migration IDs per plugin as `lagoon.StatusRow` values. |
|
||||
| `lagoon.HistoryTableName` | Returns the gormigrate history table for a plugin ID. |
|
||||
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles and adds a case-insensitive unique index on `backend_users.email`. The index migration refuses to run while emails differ only in case and names the clashing logins. |
|
||||
| `lagoon.BackendAdminMigrations` | Creates the backend user, role and admin token blacklist tables, seeds the system roles, adds a case-insensitive unique index on `backend_users.email` and adds WinterCMS's nullable jsonable `backend_users.metadata` column (TEXT; the admin subtitle lives under its `subtitle` key). The index migration refuses to run while emails differ only in case and names the clashing logins. Rolling back the metadata migration drops only that column. |
|
||||
| `lagoon.QueueMigrations` | Migrates River's schema to `lagoon.RiverSchemaVersion` and creates the `summer_jobs` job record table. |
|
||||
| `lagoon.QueueHistoryID` | History id of the job-queue set, `summercms.conga`. |
|
||||
| `lagoon.JobsTable` | Name of the job record table, `summer_jobs`. |
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
|
||||
|
||||
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
|
||||
// the developer and publisher system roles and makes backend user emails
|
||||
// unique case-insensitively. History is isolated under the summercms.cabana
|
||||
// the developer and publisher system roles, makes backend user emails
|
||||
// unique case-insensitively and adds WinterCMS's nullable jsonable metadata
|
||||
// column to backend_users (the admin subtitle lives there). History is isolated under the summercms.cabana
|
||||
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
|
||||
// the history row is removed.
|
||||
var BackendAdminMigrations = []*gormigrate.Migration{
|
||||
@@ -117,4 +118,16 @@ FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&
|
||||
return tx.Exec(`DROP INDEX IF EXISTS backend_users_email_lower_unique`).Error
|
||||
},
|
||||
},
|
||||
{
|
||||
// WinterCMS 2023_02_16_000012_Db_Backend_Add_User_Metadata: a
|
||||
// nullable jsonable column. It is TEXT, not JSONB, so a copied value
|
||||
// such as [] or a blank string loads unchanged.
|
||||
ID: "202610090001_backend_users_metadata",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestBackendAdminMigration(t *testing.T) {
|
||||
"password": false, "activation_code": true, "persist_code": true, "reset_password_code": true,
|
||||
"permissions": true, "is_activated": false, "is_superuser": false, "role_id": true,
|
||||
"activated_at": true, "last_login": true, "created_at": false, "updated_at": false,
|
||||
"deleted_at": true, "tokens_valid_after": true,
|
||||
"deleted_at": true, "tokens_valid_after": true, "metadata": true,
|
||||
}
|
||||
assertColumns(t, "backend_users", users, wantUsers)
|
||||
roles := columnNullability(t, gdb, "backend_user_roles")
|
||||
@@ -481,7 +481,9 @@ func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
// Roll back to the identity tables, which drops the email index and the
|
||||
// later metadata column.
|
||||
if err := m.RollbackTo("202609240001_backend_admin_identity"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A Winter copy can hold emails that differ only in case.
|
||||
@@ -507,3 +509,97 @@ func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) {
|
||||
t.Fatal("unique lower(email) index missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackendAdminMetadataColumn covers 202610090001_backend_users_metadata:
|
||||
// WinterCMS's nullable jsonable metadata column, added last, round-tripping a
|
||||
// Winter-shaped row, and a rollback that drops only that column.
|
||||
func TestBackendAdminMetadataColumn(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_admin_metadata")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
columnType := func() (string, string, bool) {
|
||||
var cols []struct {
|
||||
DataType string
|
||||
Nullable string
|
||||
}
|
||||
if err := gdb.Raw(`SELECT data_type, is_nullable AS nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'backend_users' AND column_name = 'metadata'`).Scan(&cols).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return cols[0].DataType, cols[0].Nullable, true
|
||||
}
|
||||
if typ, nullable, ok := columnType(); !ok || typ != "text" || nullable != "YES" {
|
||||
t.Fatalf("metadata column type=%q nullable=%q present=%v", typ, nullable, ok)
|
||||
}
|
||||
if err := gdb.Exec(`INSERT INTO backend_users (first_name, last_name, login, email, password, permissions, metadata, is_activated, is_superuser, created_at, updated_at)
|
||||
VALUES ('Ada', 'Lovelace', 'ada', 'ada@example.test', 'winter-hash', '{}', '{"subtitle":"Editor","x":1}', TRUE, FALSE, NOW(), NOW())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored *string
|
||||
if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == nil || *stored != `{"subtitle":"Editor","x":1}` {
|
||||
t.Fatalf("metadata = %v", stored)
|
||||
}
|
||||
|
||||
history, err := HistoryTableName("summercms.cabana")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
if err := gdb.Table(history).Order("id").Pluck("id", &ids).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) == 0 || ids[len(ids)-1] != "202610090001_backend_users_metadata" {
|
||||
t.Fatalf("cabana history = %v", ids)
|
||||
}
|
||||
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610090001_backend_users_metadata" {
|
||||
t.Fatalf("last BackendAdminMigrations entry = %s", last)
|
||||
}
|
||||
|
||||
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := columnType(); ok {
|
||||
t.Fatal("metadata survived its rollback")
|
||||
}
|
||||
var rows []struct {
|
||||
Login string
|
||||
Email string
|
||||
}
|
||||
if err := gdb.Raw(`SELECT login, email FROM backend_users`).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Login != "ada" || rows[0].Email != "ada@example.test" {
|
||||
t.Fatalf("rows after rollback = %+v", rows)
|
||||
}
|
||||
if !indexOn(indexDefs(t, gdb, "backend_users"), "lower(email)") {
|
||||
t.Fatal("rolling back metadata dropped the email index")
|
||||
}
|
||||
|
||||
if err := Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if typ, _, ok := columnType(); !ok || typ != "text" {
|
||||
t.Fatalf("metadata was not added back: %q %v", typ, ok)
|
||||
}
|
||||
var restored *string
|
||||
if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&restored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored != nil {
|
||||
t.Fatalf("re-added metadata = %q, want NULL", *restored)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,3 +229,29 @@ permissioneditor:
|
||||
locked: You cannot change this permission.
|
||||
empty: No permissions are defined yet.
|
||||
other: Other
|
||||
myaccount:
|
||||
menu_label: My account
|
||||
menu_description: Edit your name, email, subtitle and password.
|
||||
profile: Profile
|
||||
login: Login
|
||||
first_name: First name
|
||||
last_name: Last name
|
||||
email: Email
|
||||
subtitle: Subtitle
|
||||
subtitle_comment: A short line shown under your name, for example your role or position.
|
||||
password_section: Change password
|
||||
password_comment: Leave blank to keep the current password.
|
||||
current_password: Current password
|
||||
password: New password
|
||||
password_confirmation: Confirm new password
|
||||
current_password_invalid: The current password is incorrect.
|
||||
field_not_editable: This field cannot be changed on My account.
|
||||
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
|
||||
saved: Account saved
|
||||
avatar: Avatar
|
||||
avatar_upload: Upload picture
|
||||
avatar_remove: Remove picture
|
||||
avatar_remove_confirm: Remove your avatar?
|
||||
avatar_hint: JPG, PNG, GIF or WebP, up to 2 MB.
|
||||
avatar_saved: Avatar saved
|
||||
avatar_removed: Avatar removed
|
||||
|
||||
@@ -251,3 +251,29 @@ permissioneditor:
|
||||
locked: Nie możesz zmienić tego uprawnienia.
|
||||
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
|
||||
other: Inne
|
||||
myaccount:
|
||||
menu_label: Moje konto
|
||||
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.
|
||||
profile: Profil
|
||||
login: Login
|
||||
first_name: Imię
|
||||
last_name: Nazwisko
|
||||
email: E-mail
|
||||
subtitle: Podtytuł
|
||||
subtitle_comment: Krótki opis pod Twoim imieniem, na przykład rola lub stanowisko.
|
||||
password_section: Zmiana hasła
|
||||
password_comment: Pozostaw puste, aby zachować obecne hasło.
|
||||
current_password: Obecne hasło
|
||||
password: Nowe hasło
|
||||
password_confirmation: Powtórz nowe hasło
|
||||
current_password_invalid: Obecne hasło jest nieprawidłowe.
|
||||
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
|
||||
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
|
||||
saved: Zapisano konto
|
||||
avatar: Awatar
|
||||
avatar_upload: Wgraj zdjęcie
|
||||
avatar_remove: Usuń zdjęcie
|
||||
avatar_remove_confirm: Usunąć awatar?
|
||||
avatar_hint: JPG, PNG, GIF lub WebP, maksymalnie 2 MB.
|
||||
avatar_saved: Zapisano awatar
|
||||
avatar_removed: Usunięto awatar
|
||||
|
||||
Reference in New Issue
Block a user