feat(admin): edit category content in the admin form behind the HTML gate

- add a content textarea (size huge) after description, with en and pl phrases
- add content to Category.Fillable
- export classes.CheckHTML and run it from FormBeforeCreate and FormBeforeUpdate:
  unsafe category content answers 422 on content, blank content is stored as NULL
This commit is contained in:
2026-10-09 16:26:12 +02:00
parent 740dcd086d
commit f22d08902e
10 changed files with 249 additions and 4 deletions

View File

@@ -19,7 +19,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
## Features
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save.
- **Categories and tags.** `models.Category` is translatable (name, description, slug). Fillable is name, slug, code, description, parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
- **Categories and tags.** `models.Category` is translatable (name, description, slug). It also 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; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`).
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
@@ -86,6 +86,8 @@ GET /_journal/api/v1/authors/jane-doe/posts?page=2
`content` is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is `null` when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first.
The admin Categories form refuses content that fails `classes.CheckHTML` with a `422` on `content`, and stores blank content as `null`. Rows written by SQL or by import commands bypass that gate, so consumers keep their own gate.
```json
{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"<h2>Acme news</h2><p>Pillar copy</p>","parent_id":null,"nest_depth":0,"post_count":3}]}
```
@@ -169,12 +171,13 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `parent_id`. |
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `content`, `parent_id`. |
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. |
| `models.All` | Models registered by this plugin. |
| `updates.All` | gormigrate steps registered by this plugin. |
| `classes.CheckHTML` | The `FormatHTML` safety gate for HTML stored as is, such as category content. |
## CLI commands

View File

@@ -0,0 +1,34 @@
package classes
import "testing"
func TestCheckHTML(t *testing.T) {
cases := []struct {
name string
in string
ok bool
}{
{"empty", "", true},
{"heading and link", `<h2>Cordyceps</h2><p>Plain <a href="https://example.test/a">link</a></p>`, true},
{"table", `<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>`, true},
{"script", `<script>alert(1)</script>`, false},
{"iframe", `<iframe src="https://example.test/"></iframe>`, false},
{"object", `<object data=x>`, false},
{"embed", `<embed src=x>`, false},
{"event handler", `<img src=x onerror="alert(1)">`, false},
{"javascript url", `<a href="javascript:alert(1)">`, false},
{"vbscript url mixed case", `<a href="VBScript:x">`, false},
{"data url", `<img src="data:image/png;base64,AAAA">`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := CheckHTML(tc.in)
if tc.ok && err != nil {
t.Fatalf("CheckHTML(%q) = %v, want nil", tc.in, err)
}
if !tc.ok && err == nil {
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.in)
}
})
}
}

View File

@@ -37,6 +37,13 @@ func FormatHTML(src string) (string, error) {
return html, nil
}
// 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.
func CheckHTML(html string) error {
return rejectUnsafe(html)
}
func rejectUnsafe(html string) error {
if markdownUnsafeTag.MatchString(html) {
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")

View File

@@ -1,6 +1,11 @@
package controllers
import (
"context"
"errors"
"strings"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -40,9 +45,46 @@ func (categoriesAdminController) AdminFieldRelations() []cabana.FieldRelationCon
}}
}
func (categoriesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
cat, ok := model.(*models.Category)
if !ok || cat == nil {
return errors.New("categories admin: form model is not a category")
}
return prepareCategoryWrite(cat)
}
func (categoriesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
cat, ok := model.(*models.Category)
if !ok || cat == nil {
return errors.New("categories admin: form model is not a category")
}
return prepareCategoryWrite(cat)
}
// prepareCategoryWrite gates the category HTML body (D-21): blank content is
// stored as NULL, and content that fails classes.CheckHTML is a 422 on
// content. Any other content is stored exactly as submitted.
func prepareCategoryWrite(cat *models.Category) error {
if cat.Content == nil {
return nil
}
if strings.TrimSpace(*cat.Content) == "" {
cat.Content = nil
return nil
}
if err := classes.CheckHTML(*cat.Content); err != nil {
return &cabana.ValidationError{Details: map[string]any{
"content": []string{err.Error()},
}}
}
return nil
}
var (
_ pact.AdminController = categoriesAdminController{}
_ pact.AdminPermissioned = categoriesAdminController{}
_ pact.AdminRecordSource = categoriesAdminController{}
_ pact.FormBeforeCreate = categoriesAdminController{}
_ pact.FormBeforeUpdate = categoriesAdminController{}
_ cabana.FieldRelationProvider = categoriesAdminController{}
)

View File

@@ -3,11 +3,14 @@ package journal_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
@@ -129,3 +132,145 @@ func TestJournalCategoryContentAPI(t *testing.T) {
t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String())
}
}
const adminCategoriesPath = "/golem15/journal/categories"
func TestCategoriesAdminContent(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t, controllers.PermissionAccessCategories)
schema := adminDecode(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminCategoriesPath+"/schema/form"), token, nil))
var form struct {
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
Size string `json:"size"`
} `json:"fields"`
}
if err := json.Unmarshal(schema.Data, &form); err != nil {
t.Fatalf("form schema %s: %v", schema.Data, err)
}
contentAt := -1
for i, f := range form.Fields {
if f.Name != "content" {
continue
}
contentAt = i
if f.Type != "textarea" || f.Size != "huge" {
t.Fatalf("content field = %+v, want textarea size huge", f)
}
}
if contentAt < 1 || form.Fields[contentAt-1].Name != "description" {
t.Fatalf("content field must follow description: %s", schema.Data)
}
safe := "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>"
name := map[string]any{"en": "Acme news", "pl": "Wiadomości Acme"}
slug := map[string]any{"en": "acme-news", "pl": "wiadomosci-acme"}
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
"name": name,
"slug": slug,
"content": safe,
})
var created struct {
ID uint `json:"id"`
}
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
t.Fatalf("create data %s: %v", rec.Body.String(), err)
}
storedContent := func(t *testing.T) *string {
t.Helper()
var cat models.Category
if err := env.gdb.First(&cat, created.ID).Error; err != nil {
t.Fatal(err)
}
return cat.Content
}
if got := storedContent(t); got == nil || *got != safe {
t.Fatalf("stored content = %#v, want %q", got, safe)
}
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
if got, ok := rows["acme-news"]["content"].(string); !ok || got != safe {
t.Fatalf("API content = %#v, want %q", rows["acme-news"]["content"], safe)
}
assertContentRefused := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422: %s", rec.Code, rec.Body.String())
}
var body struct {
Error struct {
Code string `json:"code"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("422 body %s: %v", rec.Body.String(), err)
}
if body.Error.Code != "validation_failed" {
t.Fatalf("error code = %q, want validation_failed", body.Error.Code)
}
if _, ok := body.Error.Details["content"]; !ok {
t.Fatalf("details do not name content: %s", rec.Body.String())
}
}
t.Run("unsafe create refused", func(t *testing.T) {
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
"name": map[string]any{"en": "Unsafe", "pl": "Niebezpieczna"},
"slug": map[string]any{"en": "unsafe-acme", "pl": "niebezpieczna-acme"},
"content": "<p onclick=\"steal()\">x</p>",
})
assertContentRefused(t, rec)
var n int64
if err := env.gdb.Model(&models.Category{}).Where("slug = ?", "unsafe-acme").Count(&n).Error; err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("unsafe-acme rows = %d, want 0", n)
}
})
itemPath := adminAPI(adminCategoriesPath + "/" + strconv.FormatUint(uint64(created.ID), 10))
t.Run("unsafe update refused", func(t *testing.T) {
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
"name": name,
"slug": slug,
"content": "<a href=\"javascript:alert(1)\">x</a>",
})
assertContentRefused(t, rec)
if got := storedContent(t); got == nil || *got != safe {
t.Fatalf("stored content after refused update = %#v, want %q", got, safe)
}
})
t.Run("blank update stores null", func(t *testing.T) {
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
"name": name,
"slug": slug,
"content": " ",
})
if rec.Code < 200 || rec.Code > 299 {
t.Fatalf("blank update = %d, want 2xx: %s", rec.Code, rec.Body.String())
}
if got := storedContent(t); got != nil {
t.Fatalf("stored content = %q, want NULL", *got)
}
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
assertNullContent(t, rows, "acme-news")
})
t.Run("posts-only token forbidden", func(t *testing.T) {
postsOnly := env.admin(t, controllers.PermissionAccessPosts)
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), postsOnly, map[string]any{
"name": map[string]any{"en": "Denied", "pl": "Odmowa"},
"slug": map[string]any{"en": "denied-acme", "pl": "odmowa-acme"},
"content": safe,
})
if rec.Code != http.StatusForbidden {
t.Fatalf("posts-only create = %d, want 403: %s", rec.Code, rec.Body.String())
}
})
}

View File

@@ -110,6 +110,8 @@ category:
name: Name
name_placeholder: New category name
description: Description
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."
slug: Slug
slug_placeholder: new-category-slug
posts: Posts

View File

@@ -74,6 +74,8 @@ category:
name: Nazwa
name_placeholder: Nazwa nowej kategorii
description: Opis
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."
slug: Alias
slug_placeholder: alias-nowej-kategorii
posts: Posty

View File

@@ -29,7 +29,7 @@ func (Category) TranslatableIndexes() []string { return []string{"slug"} }
// Fillable is JOURNAL-002: nest_* stay out of mass assignment.
func (Category) Fillable() []string {
return []string{"name", "slug", "code", "description", "parent_id"}
return []string{"name", "slug", "code", "description", "content", "parent_id"}
}
func (Category) Rules() map[string]string {

View File

@@ -22,3 +22,9 @@ fields:
type: mltextarea
size: large
span: full
content:
label: golem15.journal::lang.category.content
comment: golem15.journal::lang.category.content_comment
type: textarea
size: huge
span: full

View File

@@ -39,7 +39,7 @@ func TestFillable(t *testing.T) {
})
t.Run("category_excludes_nest", func(t *testing.T) {
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "parent_id"}) {
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "content", "parent_id"}) {
t.Fatalf("Category.Fillable = %#v", got)
}
left := 3
@@ -48,6 +48,7 @@ func TestFillable(t *testing.T) {
"name": "Essays",
"slug": "essays",
"code": "ess",
"content": "<p>Essay hub</p>",
"nest_left": 9,
"nest_right": 10,
"nest_depth": 2,
@@ -58,6 +59,9 @@ func TestFillable(t *testing.T) {
if cat.Name != "Essays" || cat.Slug != "essays" {
t.Fatalf("category fill = %+v", cat)
}
if cat.Content == nil || *cat.Content != "<p>Essay hub</p>" {
t.Fatalf("category content fill = %#v", cat.Content)
}
if cat.ID != 0 {
t.Fatalf("id was mass-assigned: %d", cat.ID)
}