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/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "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) 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 } 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 } 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 } 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 } 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" 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, }, }) } } // 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) } 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()) }