diff --git a/README.md b/README.md index 6630c1a..600d055 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ The admin Categories form edits content with the `blogeditor` field type: a save 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"}} +{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}} ``` -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. +The author object has exactly these seven 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`. `subtitle` and `description` are the `subtitle` and `description` keys of `backend_users.metadata` (the subtitle the admin's My account form edits, read as `cabana.BackendUser.Subtitle` reads it); `avatar` is the public URL of the admin avatar (`cabana.BackendUserAvatar`), or `null` when there is none. Missing names, subtitles and descriptions 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`: diff --git a/controllers/api/authors.go b/controllers/api/authors.go index dce8278..56d1cc1 100644 --- a/controllers/api/authors.go +++ b/controllers/api/authors.go @@ -1,6 +1,9 @@ package api import ( + "context" + "encoding/json" + "log/slog" "net/http" "strings" "unicode" @@ -8,6 +11,7 @@ import ( "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" @@ -19,18 +23,20 @@ const ( // 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. + // never selected, so they cannot reach a public payload. metadata is + // read for its subtitle and description keys only. authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " + - "login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug" + "login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug, metadata" ) // 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"` + 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"` + Metadata *string `gorm:"column:metadata"` } // validAuthorParam reports whether an already trimmed {author} path value is @@ -78,20 +84,59 @@ func findAuthorRow(db *gorm.DB, cond, param string) (*authorRow, error) { } // 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, else the login), first_name, last_name, subtitle and description +// (backend_users.metadata, "" when unset) and avatar (the public URL of the +// admin avatar, or null). +func authorProfile(ctx context.Context, db *gorm.DB, 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, + "id": row.ID, + "slug": slug, + "first_name": row.FirstName, + "last_name": row.LastName, + "subtitle": cabana.BackendUser{Metadata: row.Metadata}.Subtitle(), + "description": metadataText(row.Metadata, "description"), + "avatar": authorAvatar(ctx, db, row.ID), } } +// metadataText is the string value of key in a backend user's metadata JSON, +// or "" when the metadata, the key or a string value is missing. +func metadataText(raw *string, key string) string { + if raw == nil || strings.TrimSpace(*raw) == "" { + return "" + } + var values map[string]json.RawMessage + if err := json.Unmarshal([]byte(*raw), &values); err != nil { + return "" + } + var s string + if err := json.Unmarshal(values[key], &s); err != nil { + return "" + } + return s +} + +// authorAvatar is the public URL of the author's admin avatar, or nil when +// there is none, it is not public or the lookup failed (logged). +func authorAvatar(ctx context.Context, db *gorm.DB, userID uint) any { + if db == nil { + return nil + } + f, err := cabana.BackendUserAvatar(ctx, db, userID) + if err != nil { + slog.Default().WarnContext(ctx, "journal api: author avatar lookup failed", "author", userID, "error", err) + return nil + } + if f == nil || !f.Public() { + return nil + } + return f.URL() +} + // 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. @@ -123,11 +168,11 @@ func authorOrRespond(app *backpack.App, w http.ResponseWriter, r *http.Request) // 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) + db, row, ok := authorOrRespond(app, w, r) if !ok { return } - wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(row)}) + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(r.Context(), db, row)}) } } diff --git a/journal_api_authors_test.go b/journal_api_authors_test.go index 90aa8a1..83a2733 100644 --- a/journal_api_authors_test.go +++ b/journal_api_authors_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "slices" + "strconv" "strings" "testing" "time" @@ -70,7 +71,7 @@ func authorProfileOK(t *testing.T, env *adminEnv, param string) (map[string]any, keys = append(keys, k) } slices.Sort(keys) - if want := []string{"first_name", "id", "last_name", "slug"}; !slices.Equal(keys, want) { + if want := []string{"avatar", "description", "first_name", "id", "last_name", "slug", "subtitle"}; !slices.Equal(keys, want) { t.Fatalf("author keys = %v, want %v", keys, want) } return body.Data, rec.Body.String() @@ -102,6 +103,10 @@ func TestJournalAuthorProfile(t *testing.T) { if id, _ := data["id"].(float64); uint(id) != janeID { t.Fatalf("jane-doe id = %v, want %d", data["id"], janeID) } + // No metadata and no avatar: empty strings and null. + if data["subtitle"] != "" || data["description"] != "" || data["avatar"] != nil { + t.Fatalf("jane-doe profile extras = %v, want empty subtitle and description and a null avatar", data) + } for _, secret := range []string{"jdoe@example.test", "authorSecretMarker", authorPersistCode, `"jdoe"`} { if strings.Contains(raw, secret) { t.Fatalf("author body leaks %q: %s", secret, raw) @@ -143,6 +148,27 @@ func TestJournalAuthorProfile(t *testing.T) { expectAuthorNotFound(t, env, authorAPIPrefix+strings.Repeat("a", 300)) expectAuthorNotFound(t, env, authorAPIPrefix+"a%09b") + // subtitle and description come from backend_users.metadata, avatar is + // the public URL of the admin avatar (system_files, Backend\Models\User). + teamID := insertBackendUser(t, env, "Summer", "Team", "team", "team", false) + if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, + `{"subtitle":"Golem15","description":"We build SummerCMS.","preferences":{"x":1}}`, teamID).Error; err != nil { + t.Fatal(err) + } + const avatarDisk = "0123456789abcdef01234567.webp" + if err := env.gdb.Exec(`INSERT INTO system_files (disk_name, file_name, file_size, content_type, field, attachment_id, attachment_type, is_public, sort_order, created_at, updated_at) + VALUES (?, 'team.webp', 10, 'image/webp', 'avatar', ?, 'Backend\Models\User', TRUE, 0, NOW(), NOW())`, + avatarDisk, strconv.FormatUint(uint64(teamID), 10)).Error; err != nil { + t.Fatal(err) + } + data, _ = authorProfileOK(t, env, "team") + if data["subtitle"] != "Golem15" || data["description"] != "We build SummerCMS." { + t.Fatalf("team profile = %v, want subtitle Golem15 and the description", data) + } + if avatar, _ := data["avatar"].(string); !strings.HasSuffix(avatar, avatarDisk) { + t.Fatalf("team avatar = %v, want a URL ending in %s", data["avatar"], avatarDisk) + } + // 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")