diff --git a/README.md b/README.md index 8beaffa..88c8626 100644 --- a/README.md +++ b/README.md @@ -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":"

Acme news

Pillar copy

","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 diff --git a/classes/check_html_test.go b/classes/check_html_test.go new file mode 100644 index 0000000..cc6f2fd --- /dev/null +++ b/classes/check_html_test.go @@ -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", `

Cordyceps

Plain link

`, true}, + {"table", `
A
1
`, true}, + {"script", ``, false}, + {"iframe", ``, false}, + {"object", ``, false}, + {"embed", ``, false}, + {"event handler", ``, false}, + {"javascript url", ``, false}, + {"vbscript url mixed case", ``, false}, + {"data url", ``, 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) + } + }) + } +} diff --git a/classes/format_html.go b/classes/format_html.go index 58c3274..e6588db 100644 --- a/classes/format_html.go +++ b/classes/format_html.go @@ -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") diff --git a/controllers/categories.go b/controllers/categories.go index caaa237..794ae1a 100644 --- a/controllers/categories.go +++ b/controllers/categories.go @@ -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{} ) diff --git a/journal_api_categories_test.go b/journal_api_categories_test.go index b096198..3e81e95 100644 --- a/journal_api_categories_test.go +++ b/journal_api_categories_test.go @@ -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 := "

Acme

\n

Pillar copy

" + 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": "

x

", + }) + 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": "x", + }) + 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()) + } + }) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 4be9e74..8bd9aef 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 3868e65..dbdad7b 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 diff --git a/models/category.go b/models/category.go index 0dc3659..a6d427e 100644 --- a/models/category.go +++ b/models/category.go @@ -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 { diff --git a/models/category/fields.yaml b/models/category/fields.yaml index bd57cb3..05f90c3 100644 --- a/models/category/fields.yaml +++ b/models/category/fields.yaml @@ -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 diff --git a/models/fillable_test.go b/models/fillable_test.go index 22c1603..7d98d16 100644 --- a/models/fillable_test.go +++ b/models/fillable_test.go @@ -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": "

Essay hub

", "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 != "

Essay hub

" { + t.Fatalf("category content fill = %#v", cat.Content) + } if cat.ID != 0 { t.Fatalf("id was mass-assigned: %d", cat.ID) }