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)) } }