Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d533d49f0 | |||
| f22d08902e | |||
| 740dcd086d | |||
| 57a347c97b | |||
| 908ade9c78 | |||
|
|
f5cd5b16fe |
66
README.md
66
README.md
@@ -19,13 +19,13 @@ 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`, and `/rss`. Writes under the same prefix require a cabana backend JWT (`aud=backend`).
|
||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`.
|
||||
- **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.
|
||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||
- **Schema.** Seven gormigrate steps create the six journal tables and the author-slug column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -76,8 +76,59 @@ GET /_journal/api/v1/posts/hello-world
|
||||
GET /_journal/api/v1/categories
|
||||
GET /_journal/api/v1/tags
|
||||
GET /_journal/api/v1/rss
|
||||
GET /_journal/api/v1/authors/jane-doe
|
||||
GET /_journal/api/v1/authors/jane-doe/posts?page=2
|
||||
```
|
||||
|
||||
### Public categories API
|
||||
|
||||
`GET /_journal/api/v1/categories` returns `{"data":[...]}`, a tree of category rows. Each row has the keys `id`, `name`, `slug`, `description`, `content`, `parent_id`, `nest_depth` and `post_count`, plus `children` only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds `include_empty=1`.
|
||||
|
||||
`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}]}
|
||||
```
|
||||
|
||||
### Public author API
|
||||
|
||||
`GET /_journal/api/v1/authors/{author}` returns one author's public profile without a token. `{author}` resolves in this order:
|
||||
|
||||
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
||||
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
||||
|
||||
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug.
|
||||
|
||||
```json
|
||||
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}
|
||||
```
|
||||
|
||||
The author object has exactly these four keys. `slug` is the canonical identifier: the author slug when set, otherwise the login, so a consumer that links by login sets its canonical URL from `slug`. Missing names are empty strings. The object carries no contact, credential, permission or role data and has no `login` key; `slug` falls back to the login only for an author whose login is already public through a published post.
|
||||
|
||||
An unknown author, a login without published posts, a soft-deleted user and a malformed value (empty, over 255 bytes, invalid UTF-8 or containing a control character) all answer `404`:
|
||||
|
||||
```json
|
||||
{"error":"Author not found"}
|
||||
```
|
||||
|
||||
`GET /_journal/api/v1/authors/{author}/posts` lists that author's published posts (`published` true and `published_at` in the past), resolving `{author}` the same way. Rows have the same shape as `GET /_journal/api/v1/posts` rows. Bearer tokens are ignored, so an editor also sees published posts only. Query keys:
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `page` | `1` | Values below 1 or non-numeric fall back to page 1. |
|
||||
| `per_page` | `9` | Clamped to 1..30. |
|
||||
| `sort` | `published_at desc` | Same columns as `/posts`: `published_at`, `created_at`, `updated_at`, `title`, `id`, each `asc` or `desc`. |
|
||||
|
||||
The response uses the `/posts` pagination envelope (rows trimmed here):
|
||||
|
||||
```json
|
||||
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
||||
```
|
||||
|
||||
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
||||
|
||||
Editors create a post with a cabana backend JWT (audience `backend`):
|
||||
|
||||
```http
|
||||
@@ -108,7 +159,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
||||
| `journal.Plugin.Routes` | Public GETs and backend-Bearer writes under `/_journal/api/v1`. |
|
||||
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
||||
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
||||
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
||||
@@ -118,14 +169,15 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
||||
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`. |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
)
|
||||
|
||||
// Navigation is the plugin's Winter registerNavigation() entry: the main
|
||||
// item journal (order 300) with its side menu.
|
||||
// item journal (order 300) with its side menu. Winter's new_post side item is
|
||||
// left out: a side item can only point at a controller's list, and the admin
|
||||
// names each list page after the first side item of its controller.
|
||||
func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
return []pact.NavigationItem{
|
||||
{
|
||||
@@ -17,13 +19,6 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
Order: 300,
|
||||
Controller: controllers.PostsControllerID,
|
||||
SideMenu: []pact.NavigationItem{
|
||||
{
|
||||
Code: "new_post",
|
||||
Label: "golem15.journal::lang.posts.new_post",
|
||||
Icon: "plus",
|
||||
Permissions: []string{controllers.PermissionAccessPosts},
|
||||
Controller: controllers.PostsControllerID,
|
||||
},
|
||||
{
|
||||
Code: "posts",
|
||||
Label: "golem15.journal::lang.journal.posts",
|
||||
|
||||
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")
|
||||
|
||||
166
controllers/api/authors.go
Normal file
166
controllers/api/authors.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
errAuthorNotFound = "Author not found"
|
||||
maxAuthorParamBytes = 255
|
||||
|
||||
// authorColumns is the only projection read from backend_users. Contact,
|
||||
// credential, activation, persist, reset, permission and role columns are
|
||||
// never selected, so they cannot reach a public payload.
|
||||
authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
|
||||
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug"
|
||||
)
|
||||
|
||||
// authorRow is the public projection of one backend_users row.
|
||||
type authorRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
LastName string `gorm:"column:last_name"`
|
||||
Login string `gorm:"column:login"`
|
||||
AuthorSlug string `gorm:"column:author_slug"`
|
||||
}
|
||||
|
||||
// validAuthorParam reports whether an already trimmed {author} path value is
|
||||
// worth a lookup: non-empty, at most 255 bytes, valid UTF-8, no control runes.
|
||||
func validAuthorParam(s string) bool {
|
||||
if s == "" || len(s) > maxAuthorParamBytes || !utf8.ValidString(s) {
|
||||
return false
|
||||
}
|
||||
return strings.IndexFunc(s, unicode.IsControl) < 0
|
||||
}
|
||||
|
||||
// resolveAuthor finds a live backend user by author slug, then by login. A
|
||||
// login counts only when that user has at least one published post. A miss
|
||||
// is (nil, nil); only database failures return an error.
|
||||
func resolveAuthor(db *gorm.DB, param string) (*authorRow, error) {
|
||||
row, err := findAuthorRow(db, "golem15_bloghub_author_slug = ?", param)
|
||||
if err != nil || row != nil {
|
||||
return row, err
|
||||
}
|
||||
row, err = findAuthorRow(db, "login = ?", param)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
var n int64
|
||||
if err := applyPublished(db.Model(&models.Post{})).Where("user_id = ?", row.ID).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func findAuthorRow(db *gorm.DB, cond, param string) (*authorRow, error) {
|
||||
var rows []authorRow
|
||||
if err := db.Table("backend_users").Select(authorColumns).
|
||||
Where("deleted_at IS NULL").Where(cond, param).
|
||||
Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// authorProfile is the public author object: id, slug (the canonical author
|
||||
// slug, else the login), first_name and last_name.
|
||||
func authorProfile(row *authorRow) map[string]any {
|
||||
slug := row.AuthorSlug
|
||||
if slug == "" {
|
||||
slug = row.Login
|
||||
}
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"slug": slug,
|
||||
"first_name": row.FirstName,
|
||||
"last_name": row.LastName,
|
||||
}
|
||||
}
|
||||
|
||||
// authorOrRespond resolves the {author} path value for both author routes. It
|
||||
// writes the JSON 404 or 500 itself and returns ok=false when the handler
|
||||
// must stop.
|
||||
func authorOrRespond(app *backpack.App, w http.ResponseWriter, r *http.Request) (*gorm.DB, *authorRow, bool) {
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return nil, nil, false
|
||||
}
|
||||
db = db.WithContext(r.Context())
|
||||
param := strings.TrimSpace(r.PathValue("author"))
|
||||
if !validAuthorParam(param) {
|
||||
writeAPIError(w, http.StatusNotFound, errAuthorNotFound)
|
||||
return nil, nil, false
|
||||
}
|
||||
row, err := resolveAuthor(db, param)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return nil, nil, false
|
||||
}
|
||||
if row == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errAuthorNotFound)
|
||||
return nil, nil, false
|
||||
}
|
||||
return db, row, true
|
||||
}
|
||||
|
||||
// AuthorShow is GET /_journal/api/v1/authors/{author}. {author} is an author
|
||||
// slug, or a login whose user has a published post. Bearer tokens are ignored.
|
||||
func AuthorShow(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, row, ok := authorOrRespond(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(row)})
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorPosts is GET /_journal/api/v1/authors/{author}/posts: that author's
|
||||
// published posts, paginated like GET /posts. Bearer tokens are ignored.
|
||||
func AuthorPosts(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, author, ok := authorOrRespond(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
||||
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
||||
|
||||
q := applyPublished(db.Model(&models.Post{}).
|
||||
Preload("User").Preload("Categories").Preload("Tags")).
|
||||
Where("user_id = ?", author.ID)
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
order := listOrder(strings.TrimSpace(r.URL.Query().Get("sort")), false)
|
||||
var posts []models.Post
|
||||
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(posts))
|
||||
for i := range posts {
|
||||
rows = append(rows, serializePost(&posts[i], db))
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||
}
|
||||
}
|
||||
34
controllers/api/authors_test.go
Normal file
34
controllers/api/authors_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidAuthorParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"slug", "jane-doe", true},
|
||||
{"dotted login", "jan.kowalski", true},
|
||||
{"at sign", "a@b", true},
|
||||
{"max bytes", strings.Repeat("a", 255), true},
|
||||
{"multibyte", "zażółć", true},
|
||||
{"empty", "", false},
|
||||
{"whitespace only", strings.TrimSpace(" \t "), false},
|
||||
{"over max bytes", strings.Repeat("a", 256), false},
|
||||
{"nul byte", "a\x00b", false},
|
||||
{"newline", "a\nb", false},
|
||||
{"tab", "a\tb", false},
|
||||
{"invalid utf8", "\xff", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := validAuthorParam(tc.in); got != tc.want {
|
||||
t.Fatalf("validAuthorParam(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
62
controllers/api/categories_test.go
Normal file
62
controllers/api/categories_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
func sortedCategoryKeys(row map[string]any) []string {
|
||||
keys := make([]string, 0, len(row))
|
||||
for k := range row {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestSerializeCategoryNodeContent(t *testing.T) {
|
||||
html := "<h2>Acme</h2>\n<p>Pillar copy</p>\n"
|
||||
var parentID uint = 1
|
||||
root := models.Category{ID: 1, Name: "News", Slug: "news", Content: &html}
|
||||
child := models.Category{ID: 2, Name: "Quiet", Slug: "quiet", ParentID: &parentID}
|
||||
byParent := map[uint][]models.Category{1: {child}}
|
||||
counts := map[uint]int64{1: 2}
|
||||
|
||||
row := serializeCategoryNode(&root, byParent, counts, true)
|
||||
if row == nil {
|
||||
t.Fatal("root row is nil")
|
||||
}
|
||||
if got, ok := row["content"].(string); !ok || got != html {
|
||||
t.Fatalf("root content = %#v, want %q", row["content"], html)
|
||||
}
|
||||
wantRoot := []string{"children", "content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||
if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) {
|
||||
t.Fatalf("root keys = %v, want %v", got, wantRoot)
|
||||
}
|
||||
kids, ok := row["children"].([]map[string]any)
|
||||
if !ok || len(kids) != 1 {
|
||||
t.Fatalf("children = %#v, want one row", row["children"])
|
||||
}
|
||||
v, present := kids[0]["content"]
|
||||
if !present || v != nil {
|
||||
t.Fatalf("child content = %#v (present %v), want nil", v, present)
|
||||
}
|
||||
wantChild := []string{"content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||
if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) {
|
||||
t.Fatalf("child keys = %v, want %v", got, wantChild)
|
||||
}
|
||||
|
||||
listed := serializeCategoryNode(&root, byParent, counts, false)
|
||||
if listed == nil {
|
||||
t.Fatal("root omitted without include_empty")
|
||||
}
|
||||
if _, has := listed["children"]; has {
|
||||
t.Fatalf("empty child listed without include_empty: %#v", listed["children"])
|
||||
}
|
||||
if got, ok := listed["content"].(string); !ok || got != html {
|
||||
t.Fatalf("root content without include_empty = %#v, want %q", listed["content"], html)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,10 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
if c.Description != nil {
|
||||
desc = *c.Description
|
||||
}
|
||||
var content any
|
||||
if c.Content != nil {
|
||||
content = *c.Content
|
||||
}
|
||||
var parent any
|
||||
if c.ParentID != nil {
|
||||
parent = *c.ParentID
|
||||
@@ -208,6 +212,7 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
"name": c.Name,
|
||||
"slug": c.Slug,
|
||||
"description": desc,
|
||||
"content": content,
|
||||
"parent_id": parent,
|
||||
"nest_depth": depth,
|
||||
"post_count": postCount,
|
||||
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
285
journal_api_authors_test.go
Normal file
285
journal_api_authors_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
const (
|
||||
authorSecretHash = "$2y$10$authorSecretMarker"
|
||||
authorPersistCode = "persist-marker"
|
||||
authorAPIPrefix = "/_journal/api/v1/authors/"
|
||||
authorNotFoundText = "Author not found"
|
||||
)
|
||||
|
||||
// insertBackendUser writes one backend_users row with private columns set to
|
||||
// marker values, so a test can prove they never reach a public payload.
|
||||
func insertBackendUser(t *testing.T, env *adminEnv, first, last, login, slug string, deleted bool) uint {
|
||||
t.Helper()
|
||||
var id uint
|
||||
if err := env.gdb.Raw(`INSERT INTO backend_users
|
||||
(first_name, last_name, login, email, password, persist_code, is_activated, golem15_bloghub_author_slug, deleted_at, created_at, updated_at)
|
||||
VALUES (NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, TRUE, NULLIF(?, ''), CASE WHEN ? THEN NOW() ELSE NULL END, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
first, last, login, login+"@example.test", authorSecretHash, authorPersistCode, slug, deleted,
|
||||
).Scan(&id).Error; err != nil || id == 0 {
|
||||
t.Fatalf("insert backend user %s: id=%d err=%v", login, id, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// insertAuthorPost creates one post owned by userID. at is published_at.
|
||||
func insertAuthorPost(t *testing.T, env *adminEnv, userID uint, slug string, published bool, at *time.Time) {
|
||||
t.Helper()
|
||||
uid := userID
|
||||
post := models.Post{
|
||||
UserID: &uid,
|
||||
Title: "Post " + slug,
|
||||
Slug: slug,
|
||||
Content: "body " + slug,
|
||||
ContentHTML: "<p>body " + slug + "</p>",
|
||||
Published: published,
|
||||
PublishedAt: at,
|
||||
}
|
||||
if err := env.gdb.Create(&post).Error; err != nil {
|
||||
t.Fatalf("insert post %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
func authorProfileOK(t *testing.T, env *adminEnv, param string) (map[string]any, string) {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodGet, authorAPIPrefix+param, "", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET authors/%s = %d, want 200: %s", param, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
|
||||
t.Fatalf("author body %s: err=%v", rec.Body.String(), err)
|
||||
}
|
||||
keys := make([]string, 0, len(body.Data))
|
||||
for k := range body.Data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
if want := []string{"first_name", "id", "last_name", "slug"}; !slices.Equal(keys, want) {
|
||||
t.Fatalf("author keys = %v, want %v", keys, want)
|
||||
}
|
||||
return body.Data, rec.Body.String()
|
||||
}
|
||||
|
||||
func expectAuthorNotFound(t *testing.T, env *adminEnv, path string) {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodGet, path, "", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d, want 404: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
msg, body := phpErrorString(t, rec.Body.Bytes())
|
||||
if msg != authorNotFoundText || len(body) != 1 {
|
||||
t.Fatalf("GET %s body = %s, want only {\"error\":%q}", path, rec.Body.String(), authorNotFoundText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalAuthorProfile(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
now := time.Now().UTC()
|
||||
past := now.Add(-time.Hour)
|
||||
future := now.Add(time.Hour)
|
||||
|
||||
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
||||
data, raw := authorProfileOK(t, env, "jane-doe")
|
||||
if data["slug"] != "jane-doe" || data["first_name"] != "Jane" || data["last_name"] != "Doe" {
|
||||
t.Fatalf("jane-doe profile = %v", data)
|
||||
}
|
||||
if id, _ := data["id"].(float64); uint(id) != janeID {
|
||||
t.Fatalf("jane-doe id = %v, want %d", data["id"], janeID)
|
||||
}
|
||||
for _, secret := range []string{"jdoe@example.test", "authorSecretMarker", authorPersistCode, `"jdoe"`} {
|
||||
if strings.Contains(raw, secret) {
|
||||
t.Fatalf("author body leaks %q: %s", secret, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A slugged author's login resolves once they have a published post; the
|
||||
// payload names the slug as the canonical identifier.
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"jdoe")
|
||||
insertAuthorPost(t, env, janeID, "jane-first", true, &past)
|
||||
data, raw = authorProfileOK(t, env, "jdoe")
|
||||
if data["slug"] != "jane-doe" {
|
||||
t.Fatalf("jdoe canonical slug = %v, want jane-doe", data["slug"])
|
||||
}
|
||||
if strings.Contains(raw, `"jdoe"`) {
|
||||
t.Fatalf("login fallback body leaks login: %s", raw)
|
||||
}
|
||||
|
||||
ewaID := insertBackendUser(t, env, "", "", "ewa", "", false)
|
||||
insertAuthorPost(t, env, ewaID, "ewa-first", true, &past)
|
||||
data, _ = authorProfileOK(t, env, "ewa")
|
||||
if data["slug"] != "ewa" || data["first_name"] != "" || data["last_name"] != "" {
|
||||
t.Fatalf("ewa profile = %v", data)
|
||||
}
|
||||
|
||||
// Enumeration guard: a login with only a draft and a scheduled post is 404.
|
||||
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
||||
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
||||
insertAuthorPost(t, env, carolID, "carol-future", true, &future)
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"carol")
|
||||
|
||||
// Soft-deleted users never resolve, by slug or by login.
|
||||
goneID := insertBackendUser(t, env, "Gone", "Author", "gone", "gone-author", true)
|
||||
insertAuthorPost(t, env, goneID, "gone-first", true, &past)
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"gone-author")
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"gone")
|
||||
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody")
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+strings.Repeat("a", 300))
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"a%09b")
|
||||
|
||||
// A slug is an explicit opt-in: it resolves with no posts at all.
|
||||
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
||||
data, _ = authorProfileOK(t, env, "fresh-author")
|
||||
if data["slug"] != "fresh-author" || data["first_name"] != "Fresh" {
|
||||
t.Fatalf("fresh-author profile = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
type authorPostsPage struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
LastPage int `json:"last_page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
func (p authorPostsPage) slugs() []string {
|
||||
out := make([]string, 0, len(p.Data))
|
||||
for _, row := range p.Data {
|
||||
slug, _ := row["slug"].(string)
|
||||
out = append(out, slug)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getAuthorPosts(t *testing.T, env *adminEnv, path, token string) authorPostsPage {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodGet, path, token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var page authorPostsPage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
|
||||
t.Fatalf("GET %s body %s: %v", path, rec.Body.String(), err)
|
||||
}
|
||||
if page.Data == nil {
|
||||
t.Fatalf("GET %s: data is not an array: %s", path, rec.Body.String())
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func expectMeta(t *testing.T, label string, p authorPostsPage, current, last, perPage int, total int64) {
|
||||
t.Helper()
|
||||
m := p.Meta
|
||||
if m.CurrentPage != current || m.LastPage != last || m.PerPage != perPage || m.Total != total {
|
||||
t.Fatalf("%s meta = %+v, want {current_page:%d last_page:%d per_page:%d total:%d}", label, m, current, last, perPage, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalAuthorPosts(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
now := time.Now().UTC()
|
||||
at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
|
||||
|
||||
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
||||
insertAuthorPost(t, env, janeID, "a1", true, at(-3*time.Hour))
|
||||
insertAuthorPost(t, env, janeID, "a2", true, at(-2*time.Hour))
|
||||
insertAuthorPost(t, env, janeID, "a3", true, at(-time.Hour))
|
||||
insertAuthorPost(t, env, janeID, "a-draft", false, at(-30*time.Minute))
|
||||
insertAuthorPost(t, env, janeID, "a-future", true, at(time.Hour))
|
||||
bobID := insertBackendUser(t, env, "Bob", "Brown", "bob", "", false)
|
||||
insertAuthorPost(t, env, bobID, "b1", true, at(-90*time.Minute))
|
||||
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
||||
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
||||
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
||||
|
||||
base := authorAPIPrefix + "jane-doe/posts"
|
||||
page := getAuthorPosts(t, env, base, "")
|
||||
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
||||
t.Fatalf("default order = %v, want [a3 a2 a1]", got)
|
||||
}
|
||||
expectMeta(t, "default", page, 1, 1, 9, 3)
|
||||
|
||||
// Each row has the GET /posts row shape.
|
||||
all := getAuthorPosts(t, env, "/_journal/api/v1/posts", "")
|
||||
var listRow map[string]any
|
||||
for _, row := range all.Data {
|
||||
if row["slug"] == "a3" {
|
||||
listRow = row
|
||||
}
|
||||
}
|
||||
if listRow == nil {
|
||||
t.Fatalf("a3 missing from GET /posts: %v", all.slugs())
|
||||
}
|
||||
if got, want := sortedKeys(page.Data[0]), sortedKeys(listRow); !slices.Equal(got, want) {
|
||||
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
|
||||
}
|
||||
author, _ := page.Data[0]["author"].(map[string]any)
|
||||
if author == nil || author["login"] != "jdoe" {
|
||||
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"])
|
||||
}
|
||||
|
||||
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
|
||||
if got := page.slugs(); !slices.Equal(got, []string{"a1"}) {
|
||||
t.Fatalf("page 2 = %v, want [a1]", got)
|
||||
}
|
||||
expectMeta(t, "page 2", page, 2, 2, 2, 3)
|
||||
|
||||
page = getAuthorPosts(t, env, base+"?per_page=100", "")
|
||||
expectMeta(t, "per_page=100", page, 1, 1, 30, 3)
|
||||
|
||||
page = getAuthorPosts(t, env, base+"?page=abc", "")
|
||||
expectMeta(t, "page=abc", page, 1, 1, 9, 3)
|
||||
|
||||
page = getAuthorPosts(t, env, base+"?sort=published_at+asc", "")
|
||||
if got := page.slugs(); !slices.Equal(got, []string{"a1", "a2", "a3"}) {
|
||||
t.Fatalf("asc order = %v, want [a1 a2 a3]", got)
|
||||
}
|
||||
|
||||
// An editor token is ignored: drafts and scheduled posts stay hidden.
|
||||
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||
page = getAuthorPosts(t, env, base+"?published=0", token)
|
||||
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
||||
t.Fatalf("editor list = %v, want published only [a3 a2 a1]", got)
|
||||
}
|
||||
expectMeta(t, "editor", page, 1, 1, 9, 3)
|
||||
|
||||
page = getAuthorPosts(t, env, authorAPIPrefix+"jdoe/posts", "")
|
||||
expectMeta(t, "login", page, 1, 1, 9, 3)
|
||||
|
||||
page = getAuthorPosts(t, env, authorAPIPrefix+"fresh-author/posts", "")
|
||||
if len(page.Data) != 0 {
|
||||
t.Fatalf("fresh-author rows = %v, want none", page.slugs())
|
||||
}
|
||||
expectMeta(t, "fresh-author", page, 1, 1, 9, 0)
|
||||
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody/posts")
|
||||
expectAuthorNotFound(t, env, authorAPIPrefix+"carol/posts")
|
||||
}
|
||||
276
journal_api_categories_test.go
Normal file
276
journal_api_categories_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// flattenCategoryRows walks the categories tree (data plus children) into one
|
||||
// slice of rows.
|
||||
func flattenCategoryRows(rows []map[string]any) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
if kids, ok := row["children"].([]any); ok {
|
||||
nested := make([]map[string]any, 0, len(kids))
|
||||
for _, k := range kids {
|
||||
if m, ok := k.(map[string]any); ok {
|
||||
nested = append(nested, m)
|
||||
}
|
||||
}
|
||||
out = append(out, flattenCategoryRows(nested)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil)
|
||||
var body struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("categories json: %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for _, row := range flattenCategoryRows(body.Data) {
|
||||
slug, _ := row["slug"].(string)
|
||||
out[slug] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) {
|
||||
t.Helper()
|
||||
row, ok := rows[slug]
|
||||
if !ok {
|
||||
t.Fatalf("category %s not listed", slug)
|
||||
}
|
||||
v, present := row["content"]
|
||||
if !present || v != nil {
|
||||
t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCategoryContentAPI(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
html := "<h2>Acme news</h2>\n<p>Pillar copy</p>"
|
||||
left, right, depth := 3, 4, 0
|
||||
news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth}
|
||||
if err := env.gdb.Create(&news).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quiet := models.Category{Name: "Quiet", Slug: "quiet"}
|
||||
if err := env.gdb.Create(&quiet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past}
|
||||
if err := env.gdb.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
||||
if got, ok := all["news"]["content"].(string); !ok || got != html {
|
||||
t.Fatalf("news content = %#v, want %q", all["news"]["content"], html)
|
||||
}
|
||||
assertNullContent(t, all, "quiet")
|
||||
assertNullContent(t, all, "uncategorized")
|
||||
|
||||
listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories")
|
||||
if got, ok := listed["news"]["content"].(string); !ok || got != html {
|
||||
t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html)
|
||||
}
|
||||
if _, ok := listed["quiet"]; ok {
|
||||
t.Fatal("empty category quiet listed without include_empty")
|
||||
}
|
||||
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
||||
var posts struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil {
|
||||
t.Fatalf("posts json: %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range posts.Data {
|
||||
if p["slug"] != "listed-content" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
cats, ok := p["categories"].([]any)
|
||||
if !ok || len(cats) == 0 {
|
||||
t.Fatalf("post categories = %#v", p["categories"])
|
||||
}
|
||||
first, ok := cats[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("post category entry = %#v", cats[0])
|
||||
}
|
||||
keys := make([]string, 0, len(first))
|
||||
for k := range first {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("post category keys = %v, want %v", keys, want)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ type Category struct {
|
||||
Slug string `gorm:"column:slug"`
|
||||
Code *string `gorm:"column:code"`
|
||||
Description *string `gorm:"column:description"`
|
||||
Content *string `gorm:"column:content"` // HTML body (D-21): stored and served as is, not translatable
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
NestLeft *int `gorm:"column:nest_left"`
|
||||
NestRight *int `gorm:"column:nest_right"`
|
||||
@@ -28,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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
// Routes mounts public GETs and backend-Bearer writes under /_journal/api/v1
|
||||
// (D-14). The public group must not attach cabana middleware name "backend"
|
||||
// (anonymous callers would 401 — pitfall 4).
|
||||
// (anonymous callers would 401 — pitfall 4). The public author routes resolve
|
||||
// {author} in the handler rather than with Where, so every miss is the JSON
|
||||
// 404 {"error":"Author not found"}.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/posts", apicontrollers.Index(p.app))
|
||||
@@ -17,6 +19,8 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/categories", apicontrollers.Categories(p.app))
|
||||
g.Get("/tags", apicontrollers.Tags(p.app))
|
||||
g.Get("/rss", apicontrollers.RSS(p.app))
|
||||
g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app))
|
||||
g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app))
|
||||
})
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) {
|
||||
g.Post("/media/upload", apicontrollers.UploadMedia(p.app))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// addCategoryContent adds the category HTML body (D-21). It must stay the last
|
||||
// migration so RollbackLast drops only this column and keeps the rows.
|
||||
var addCategoryContent = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610090001_add_content_to_golem15_journal_categories",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories ADD COLUMN IF NOT EXISTS content TEXT NULL`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories DROP COLUMN IF EXISTS content`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(addCategoryContent...)
|
||||
}
|
||||
82
updates/category_content_migration_test.go
Normal file
82
updates/category_content_migration_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
func TestCategoryContentMigration(t *testing.T) {
|
||||
sqlDB := dedicatedDB(t, fmt.Sprintf("journal_catcontent_%d", time.Now().UnixNano()))
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_summercms_cabana",
|
||||
UseTransaction: true,
|
||||
}, lagoon.BackendAdminMigrations)
|
||||
if err := backend.Migrate(); err != nil {
|
||||
t.Fatalf("backend_users migrations: %v", err)
|
||||
}
|
||||
m := journalMigrator(gdb)
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
all := All()
|
||||
if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" {
|
||||
t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("golem15_journal_categories.content missing")
|
||||
}
|
||||
assertUdt(t, gdb, "golem15_journal_categories", "content", "text")
|
||||
var nullable string
|
||||
if err := gdb.WithContext(t.Context()).Raw(
|
||||
`SELECT is_nullable FROM information_schema.columns WHERE table_name = 'golem15_journal_categories' AND column_name = 'content'`,
|
||||
).Scan(&nullable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullable != "YES" {
|
||||
t.Fatalf("content is_nullable = %q, want YES", nullable)
|
||||
}
|
||||
|
||||
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatalf("rollback last: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("golem15_journal_categories") {
|
||||
t.Fatal("golem15_journal_categories dropped by the content rollback")
|
||||
}
|
||||
if gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column survived rollback")
|
||||
}
|
||||
var rows int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("uncategorized rows after rollback = %d, want 1", rows)
|
||||
}
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column missing after re-migrate")
|
||||
}
|
||||
var content sql.NullString
|
||||
if err := gdb.Raw(`SELECT content FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&content).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.Valid {
|
||||
t.Fatalf("uncategorized content after re-migrate = %q, want NULL", content.String)
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestJournalTables(t *testing.T) {
|
||||
assertUniqueSlug(t, gdb, "golem15_journal_tags", "unique-tag-slug")
|
||||
assertUniqueSlug(t, gdb, "golem15_journal_categories", "unique-cat-slug")
|
||||
|
||||
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)
|
||||
}
|
||||
if got := (models.Tag{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "description"}) {
|
||||
|
||||
Reference in New Issue
Block a user