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:
@@ -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
|
||||
|
||||
|
||||
34
classes/check_html_test.go
Normal file
34
classes/check_html_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user