feat(api): anonymous author profile at /_journal/api/v1/authors/{author}
- Resolve {author} by golem15_bloghub_author_slug, then by login for users with a published post
- Project only id, names, login and author slug from backend_users; payload is id, slug, first_name, last_name
- JSON 404 {"error":"Author not found"} for unknown, soft-deleted and malformed values
- Document the route in README
This commit is contained in:
28
README.md
28
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`. |
|
||||
|
||||
131
controllers/api/authors.go
Normal file
131
controllers/api/authors.go
Normal file
@@ -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)})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
151
journal_api_authors_test.go
Normal file
151
journal_api_authors_test.go
Normal file
@@ -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: "<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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user