diff --git a/README.md b/README.md index be017e8..4a7dbd5 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **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`. - **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`, and `/authors/{author}`. 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. @@ -76,6 +76,28 @@ 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 +``` + +### 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"} ``` Editors create a post with a cabana backend JWT (audience `backend`): @@ -108,7 +130,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`. | diff --git a/controllers/api/authors.go b/controllers/api/authors.go new file mode 100644 index 0000000..90031ca --- /dev/null +++ b/controllers/api/authors.go @@ -0,0 +1,131 @@ +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/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)}) + } +} diff --git a/controllers/api/authors_test.go b/controllers/api/authors_test.go new file mode 100644 index 0000000..d41dbf1 --- /dev/null +++ b/controllers/api/authors_test.go @@ -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) + } + }) + } +} diff --git a/journal_api_authors_test.go b/journal_api_authors_test.go new file mode 100644 index 0000000..6c593a4 --- /dev/null +++ b/journal_api_authors_test.go @@ -0,0 +1,151 @@ +package journal_test + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "testing" + "time" + + "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: "

body " + slug + "

", + 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) + } +} diff --git a/routes.go b/routes.go index ba599b7..d4c559a 100644 --- a/routes.go +++ b/routes.go @@ -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,7 @@ 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)) }) r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) { g.Post("/media/upload", apicontrollers.UploadMedia(p.app))