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:
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user