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/wire" "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 != "" { 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}. Task 2 fills JOURNAL-005. func Show(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { r = optionalBackendPrincipal(app, r) writeAPIError(w, http.StatusNotFound, errPostNotFound) } } // Categories is GET /_journal/api/v1/categories. Task 2 fills the PHP list. func Categories(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}}) } } // Tags is GET /_journal/api/v1/tags. Task 2 fills the PHP list. func Tags(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}}) } } // RSS is GET /_journal/api/v1/rss. Task 3 fills the PHP RSS contract. func RSS(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(``)) } } // Store is POST /_journal/api/v1/posts. Task 2 fills field-by-field create. func Store(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := requireBackendPrincipal(app, w, r); !ok { return } writeAPIError(w, http.StatusForbidden, errInsufficientPerms) } } // Update is PUT /_journal/api/v1/posts/{id}. Task 2 fills field-by-field update. func Update(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := requireBackendPrincipal(app, w, r); !ok { return } writeAPIError(w, http.StatusForbidden, errInsufficientPerms) } } // Destroy is DELETE /_journal/api/v1/posts/{id}. Task 2 fills canEdit delete. func Destroy(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := requireBackendPrincipal(app, w, r); !ok { return } writeAPIError(w, http.StatusForbidden, errInsufficientPerms) } } // 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) { if _, _, ok := requireBackendPrincipal(app, w, r); !ok { return } writeAPIError(w, http.StatusForbidden, errEditPermission) } } // 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) { if _, _, ok := requireBackendPrincipal(app, w, r); !ok { return } writeAPIError(w, http.StatusForbidden, errEditPermission) } } 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": []any{}, "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(_ *gorm.DB, _ *models.Post) []map[string]any { return []map[string]any{} } 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()) }