GET /_journal/api/v1/posts/{slug} now looks the slug up in translate's
slug index for the request locale after the default-locale slug and
before the numeric id. TestTranslatedSlug covers the translated slug,
the default slug, the id and an unknown slug.
688 lines
21 KiB
Go
688 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"html"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"gocloud.dev/blob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
defaultPerPage = 9
|
|
maxPerPage = 30
|
|
)
|
|
|
|
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
|
// only; an editor with access_posts may include drafts unless ?published=true.
|
|
func Index(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r = optionalBackendPrincipal(app, r)
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
pr, _ := bouncer.User(r.Context())
|
|
editor := isEditor(pr)
|
|
|
|
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
|
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
|
|
|
q := db.WithContext(r.Context()).Model(&models.Post{}).
|
|
Preload("User").Preload("Categories").Preload("Tags")
|
|
|
|
publishedOnly := true
|
|
if editor {
|
|
if _, ok := r.URL.Query()["published"]; ok {
|
|
publishedOnly = queryBool(r.URL.Query().Get("published"))
|
|
} else {
|
|
publishedOnly = false
|
|
}
|
|
}
|
|
if publishedOnly {
|
|
q = applyPublished(q)
|
|
}
|
|
if editor {
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("author")); raw != "" {
|
|
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
|
|
q = q.Where("user_id = ?", id)
|
|
}
|
|
}
|
|
}
|
|
if cat := strings.TrimSpace(r.URL.Query().Get("category")); cat != "" {
|
|
if ids := resolveCategoryIDs(db.WithContext(r.Context()), cat); len(ids) > 0 {
|
|
q = q.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)
|
|
} else {
|
|
q = q.Where("1 = 0")
|
|
}
|
|
}
|
|
if tag := strings.TrimSpace(r.URL.Query().Get("tag")); tag != "" {
|
|
q = applyTagFilter(q, db.WithContext(r.Context()), tag)
|
|
}
|
|
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
|
if len([]rune(search)) < 3 {
|
|
search = ""
|
|
}
|
|
if search != "" {
|
|
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used {
|
|
if len(ids) == 0 {
|
|
q = q.Where("1 = 0")
|
|
} else {
|
|
q = q.Where("id IN ?", ids)
|
|
}
|
|
} else {
|
|
like := "%" + escapeLike(search) + "%"
|
|
q = q.Where(
|
|
"(title ILIKE ? ESCAPE '\\' OR slug ILIKE ? ESCAPE '\\' OR excerpt ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')",
|
|
like, like, like, like,
|
|
)
|
|
}
|
|
}
|
|
|
|
var total int64
|
|
if err := q.Count(&total).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
|
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
|
order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first")))
|
|
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.WithContext(r.Context())))
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
|
}
|
|
}
|
|
|
|
// Show is GET /_journal/api/v1/posts/{slug}. Numeric slugs fall back to id
|
|
// (PHP 192-201). Unpublished or future published_at is 404 with no data key
|
|
// unless the caller is the owner or holds access_other_posts (JOURNAL-005).
|
|
func Show(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r = optionalBackendPrincipal(app, r)
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
db = db.WithContext(r.Context())
|
|
slug := strings.TrimSpace(r.PathValue("slug"))
|
|
post, err := findPostBySlugOrID(db, slug, requestLocale(r))
|
|
if err != nil || post == nil {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
pr, _ := bouncer.User(r.Context())
|
|
if !postIsPublished(post) && !canViewDraft(post, pr) {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
if err := loadPostAssocs(db, post); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
payload := serializePost(post, db)
|
|
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
|
payload["next_post"] = serializeSibling(nextPost(db, post))
|
|
related := relatedPosts(db, post, isEditor(pr))
|
|
rows := make([]map[string]any, 0, len(related))
|
|
for i := range related {
|
|
rows = append(rows, serializePost(&related[i], db))
|
|
}
|
|
payload["related_posts"] = rows
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
|
}
|
|
}
|
|
|
|
// Categories is GET /_journal/api/v1/categories. Empty categories are omitted
|
|
// unless ?include_empty=1.
|
|
func Categories(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
|
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
|
}
|
|
}
|
|
|
|
// Tags is GET /_journal/api/v1/tags.
|
|
func Tags(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
data, err := serializeTagList(db.WithContext(r.Context()))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
|
}
|
|
}
|
|
|
|
// Store is POST /_journal/api/v1/posts. Field-by-field create; never lagoon.Fill.
|
|
func Store(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireAccessPosts(w, pr) {
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
body, err := readJSONObject(r)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
|
return
|
|
}
|
|
post, verrs := buildNewPost(r, db.WithContext(r.Context()), pr, body)
|
|
if verrs != nil {
|
|
writeValidation(w, verrs)
|
|
return
|
|
}
|
|
if publishedFlag(body) && !cabana.Allows(pr, []string{permAccessPublish}) {
|
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
|
return
|
|
}
|
|
if err := db.WithContext(r.Context()).Create(post).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
purgeSiteCache(r, app, post)
|
|
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body))
|
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
|
}
|
|
}
|
|
|
|
// Update is PUT /_journal/api/v1/posts/{id}.
|
|
func Update(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireAccessPosts(w, pr) {
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
|
if err != nil || post == nil {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
if !post.CanEdit(pr) {
|
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
|
return
|
|
}
|
|
body, err := readJSONObject(r)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
|
return
|
|
}
|
|
// D-92/D-102: the values before the write, for the RecordSaved
|
|
// subscribers hear (a slug changed over MCP gets its redirect).
|
|
before := snapshotPost(r.Context(), db.WithContext(r.Context()), post, body)
|
|
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
|
|
writeValidation(w, verrs)
|
|
return
|
|
}
|
|
if _, has := body["published"]; has && asBool(body["published"]) && !cabana.Allows(pr, []string{permAccessPublish}) {
|
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
|
return
|
|
}
|
|
if err := db.WithContext(r.Context()).Save(post).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
purgeSiteCache(r, app, post)
|
|
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body))
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
|
}
|
|
}
|
|
|
|
// Destroy is DELETE /_journal/api/v1/posts/{id}.
|
|
func Destroy(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireAccessPosts(w, pr) {
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
|
if err != nil || post == nil {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
if !post.CanEdit(pr) {
|
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
|
return
|
|
}
|
|
if err := db.WithContext(r.Context()).Delete(post).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
purgeSiteCache(r, app, post)
|
|
cabana.EmitRecordDeleted(r.Context(), app, nil, pact.RecordDeleted{
|
|
Model: post.MorphName(),
|
|
ID: strconv.FormatUint(uint64(post.ID), 10),
|
|
})
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
|
|
}
|
|
}
|
|
|
|
// UploadFeaturedImage is POST /_journal/api/v1/posts/{id}/featured-images.
|
|
func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
|
if err != nil || post == nil {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
if !post.CanEdit(pr) {
|
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeValidation(w, map[string][]string{"file": {"The file field is required."}})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
|
if !ok || bucket == nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
stored, err := attach.Store(r.Context(), db.WithContext(r.Context()), bucket, attach.Upload{
|
|
FileName: header.Filename,
|
|
Body: file,
|
|
Public: true,
|
|
}, attach.Limits{
|
|
MaxBytes: 10240 << 10,
|
|
Image: true,
|
|
MIMETypes: []string{"image/jpeg", "image/png", "image/gif", "image/webp"},
|
|
})
|
|
if err != nil {
|
|
writeValidation(w, map[string][]string{"file": {err.Error()}})
|
|
return
|
|
}
|
|
idStr := strconv.FormatUint(uint64(post.ID), 10)
|
|
if err := db.WithContext(r.Context()).Model(stored).Updates(map[string]any{
|
|
"attachment_type": post.MorphName(),
|
|
"attachment_id": idStr,
|
|
"field": "featured_images",
|
|
}).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
stored.AttachmentType = post.MorphName()
|
|
stored.AttachmentID = idStr
|
|
stored.Field = "featured_images"
|
|
purgeSiteCache(r, app, post)
|
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
|
"data": map[string]any{
|
|
"id": stored.ID,
|
|
"url": stored.URL(),
|
|
"file_name": stored.FileName,
|
|
"file_size": stored.FileSize,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// purgeSiteCache queues the host site's cache purge for post, as the admin
|
|
// save hooks do. The API writes run outside a transaction, so the purge is
|
|
// queued at once and never delays the response. Without site_cache.purge_url
|
|
// it does nothing.
|
|
func purgeSiteCache(r *http.Request, app *backpack.App, post *models.Post) {
|
|
cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, cooler.SiteCacheFromApp(app), "post", post.Slug)
|
|
}
|
|
|
|
// DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}.
|
|
func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
|
if err != nil || post == nil {
|
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
|
return
|
|
}
|
|
if !post.CanEdit(pr) {
|
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
|
return
|
|
}
|
|
fileID, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("fileId")), 10, 64)
|
|
if err != nil || fileID == 0 {
|
|
writeAPIError(w, http.StatusNotFound, errImageNotFound)
|
|
return
|
|
}
|
|
var file attach.File
|
|
err = db.WithContext(r.Context()).Where(
|
|
"id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?",
|
|
fileID, post.MorphName(), strconv.FormatUint(uint64(post.ID), 10), "featured_images",
|
|
).Take(&file).Error
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusNotFound, errImageNotFound)
|
|
return
|
|
}
|
|
keys := attach.BlobKeys(file)
|
|
if err := db.WithContext(r.Context()).Delete(&file).Error; err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
|
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
|
}
|
|
purgeSiteCache(r, app, post)
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgImageRemoved})
|
|
}
|
|
}
|
|
|
|
func clampPerPage(raw string) int {
|
|
n := atoiDefault(raw, defaultPerPage)
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
if n > maxPerPage {
|
|
return maxPerPage
|
|
}
|
|
return n
|
|
}
|
|
|
|
func atoiDefault(raw string, fallback int) int {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func queryBool(raw string) bool {
|
|
s := strings.ToLower(strings.TrimSpace(raw))
|
|
return s == "1" || s == "true" || s == "on" || s == "yes"
|
|
}
|
|
|
|
func applyPublished(q *gorm.DB) *gorm.DB {
|
|
return q.Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now())
|
|
}
|
|
|
|
func listOrder(sort string, pinnedFirst bool) string {
|
|
col, dir := "published_at", "DESC"
|
|
parts := strings.Fields(sort)
|
|
if len(parts) > 0 {
|
|
switch strings.ToLower(parts[0]) {
|
|
case "published_at", "created_at", "updated_at", "title", "id":
|
|
col = strings.ToLower(parts[0])
|
|
}
|
|
}
|
|
if len(parts) > 1 && strings.EqualFold(parts[1], "asc") {
|
|
dir = "ASC"
|
|
}
|
|
order := col + " " + dir + ", id DESC"
|
|
if pinnedFirst {
|
|
return "is_pinned DESC, " + order
|
|
}
|
|
return order
|
|
}
|
|
|
|
func escapeLike(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
|
return s
|
|
}
|
|
|
|
func resolveCategoryIDs(db *gorm.DB, input string) []uint {
|
|
var cat models.Category
|
|
if id, err := strconv.ParseUint(input, 10, 64); err == nil && id > 0 {
|
|
if err := db.Where("id = ?", id).Take(&cat).Error; err != nil {
|
|
return nil
|
|
}
|
|
} else if err := db.Where("slug = ?", input).Take(&cat).Error; err != nil {
|
|
return nil
|
|
}
|
|
ids := []uint{cat.ID}
|
|
if cat.NestLeft == nil || cat.NestRight == nil {
|
|
return ids
|
|
}
|
|
var kids []models.Category
|
|
if err := db.Where("nest_left > ? AND nest_right < ?", *cat.NestLeft, *cat.NestRight).Find(&kids).Error; err != nil {
|
|
return ids
|
|
}
|
|
for _, kid := range kids {
|
|
ids = append(ids, kid.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func applyTagFilter(q, db *gorm.DB, input string) *gorm.DB {
|
|
var tag models.Tag
|
|
if id, err := strconv.ParseUint(input, 10, 64); err == nil && id > 0 {
|
|
if err := db.Where("id = ?", id).Take(&tag).Error; err != nil {
|
|
return q.Where("1 = 0")
|
|
}
|
|
} else if err := db.Where("slug = ?", input).Take(&tag).Error; err != nil {
|
|
return q.Where("1 = 0")
|
|
}
|
|
return q.Where("id IN (SELECT post_id FROM golem15_journal_posts_tags WHERE tag_id = ?)", tag.ID)
|
|
}
|
|
|
|
func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|
if p == nil {
|
|
return map[string]any{}
|
|
}
|
|
cats := make([]map[string]any, 0, len(p.Categories))
|
|
for _, c := range p.Categories {
|
|
cats = append(cats, map[string]any{"id": c.ID, "name": c.Name, "slug": c.Slug})
|
|
}
|
|
tags := make([]map[string]any, 0, len(p.Tags))
|
|
for _, t := range p.Tags {
|
|
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
|
}
|
|
featured := featuredImages(db, p)
|
|
var featuredURL any
|
|
if len(featured) > 0 {
|
|
featuredURL = featured[0]["url"]
|
|
}
|
|
row := map[string]any{
|
|
"id": p.ID,
|
|
"title": p.Title,
|
|
"slug": p.Slug,
|
|
"excerpt": p.Excerpt,
|
|
"content": p.Content,
|
|
"content_html": p.ContentHTML,
|
|
"reading_time": readingTime(p.ContentHTML),
|
|
"published": p.Published,
|
|
"published_at": carbonTime(p.PublishedAt),
|
|
"is_pinned": p.IsPinned,
|
|
"metadata": jsonField(p.Metadata),
|
|
"sources": jsonField(p.Sources),
|
|
"author": serializeAuthor(p.User),
|
|
"categories": cats,
|
|
"tags": tags,
|
|
"featured_images": featured,
|
|
"featured_image_url": featuredURL,
|
|
"translations": translationsOf(db, p),
|
|
"created_at": carbonTime(p.CreatedAt),
|
|
"updated_at": carbonTime(p.UpdatedAt),
|
|
}
|
|
return row
|
|
}
|
|
|
|
func serializeAuthor(user *cabana.BackendUser) any {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"id": user.ID,
|
|
"first_name": user.FirstName,
|
|
"last_name": user.LastName,
|
|
"login": user.Login,
|
|
}
|
|
}
|
|
|
|
func featuredImages(db *gorm.DB, p *models.Post) []map[string]any {
|
|
if db == nil || p == nil || p.ID == 0 {
|
|
return []map[string]any{}
|
|
}
|
|
var files []attach.File
|
|
if err := db.Where(
|
|
"attachment_type = ? AND attachment_id = ? AND field = ?",
|
|
p.MorphName(), strconv.FormatUint(uint64(p.ID), 10), "featured_images",
|
|
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
|
return []map[string]any{}
|
|
}
|
|
out := make([]map[string]any, 0, len(files))
|
|
for i := range files {
|
|
out = append(out, map[string]any{
|
|
"id": files[i].ID,
|
|
"url": files[i].URL(),
|
|
"file_name": files[i].FileName,
|
|
"file_size": files[i].FileSize,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func jsonField(raw *string) any {
|
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
|
return nil
|
|
}
|
|
var v any
|
|
if err := json.Unmarshal([]byte(*raw), &v); err != nil {
|
|
return *raw
|
|
}
|
|
return v
|
|
}
|
|
|
|
func carbonTime(t *time.Time) any {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return wire.Time{Time: *t}
|
|
}
|
|
|
|
func readingTime(htmlSrc string) int {
|
|
plain := stripTags(htmlSrc)
|
|
words := 0
|
|
inWord := false
|
|
for _, r := range plain {
|
|
if unicode.IsSpace(r) {
|
|
inWord = false
|
|
continue
|
|
}
|
|
if !inWord {
|
|
words++
|
|
inWord = true
|
|
}
|
|
}
|
|
if words == 0 {
|
|
return 0
|
|
}
|
|
n := (words + 199) / 200
|
|
if n < 1 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
func stripTags(s string) string {
|
|
var b strings.Builder
|
|
inTag := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return html.UnescapeString(b.String())
|
|
}
|