feat: subtitle, description and avatar on the public author object
- GET /_journal/api/v1/authors/{author} adds subtitle and description from
backend_users.metadata and avatar (the admin avatar's public URL or null)
- TestJournalAuthorProfile pins the seven keys and checks the three values
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user