docs(quick-261009-mgk): add the category content field to sm-journal-plugin (D-21)
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 16
|
||||
current_phase_name: grzybyfunkcjonalne-pl-on-reusable-blog-views
|
||||
current_phase_name: grzybyfunkcjonalne.pl on reusable blog views
|
||||
status: executing
|
||||
stopped_at: Completed 14.3-04-PLAN.md
|
||||
last_updated: "2026-10-09T10:25:03.224Z"
|
||||
last_activity: 2026-10-08
|
||||
last_activity_desc: Phase 14.3 execution started
|
||||
state_head: 6d36ffaef0427a03ca5934d9f6332ea4128a443e
|
||||
last_updated: "2026-10-09T12:54:28.210Z"
|
||||
last_activity: 2026-10-09
|
||||
last_activity_desc: Phase 16 execution started
|
||||
state_head: 56f3d9c59f08f588e8769da92ded11b07bf7376f
|
||||
progress:
|
||||
total_phases: 31
|
||||
completed_phases: 13
|
||||
@@ -25,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: 16 (grzybyfunkcjonalne-pl-on-reusable-blog-views) — READY TO EXECUTE
|
||||
Plan: 4 of 4
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-09 - Completed quick task 261009-i6e: add the anonymous author endpoint to sm-journal-plugin (D-20)
|
||||
Phase: 16 (grzybyfunkcjonalne.pl on reusable blog views) — EXECUTING
|
||||
Plan: 1 of 5
|
||||
Status: Executing Phase 16
|
||||
Last activity: 2026-10-09 - Completed quick task 261009-mgk: add the category content field to sm-journal-plugin (D-21)
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -675,6 +675,7 @@ Recent decisions affecting current work:
|
||||
| 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/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
Reference in New Issue
Block a user