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