Files
sm-journal-plugin/controllers/api/authors.go
Jakub Zych fe1bbc2ba6 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
2026-10-20 15:00:39 +02:00

212 lines
6.8 KiB
Go

package api
import (
"context"
"encoding/json"
"log/slog"
"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/cabana"
"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. 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, 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"`
Metadata *string `gorm:"column:metadata"`
}
// 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, 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,
"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.
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) {
db, row, ok := authorOrRespond(app, w, r)
if !ok {
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(r.Context(), db, 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))
}
}