package api import ( "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" "unicode" "unicode/utf8" "git.golem15.com/golem15/sm-journal-plugin/classes" "git.golem15.com/golem15/sm-journal-plugin/models" translate "git.golem15.com/golem15/sm-translate-plugin/classes" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "gorm.io/gorm" ) var slugPattern = regexp.MustCompile(`(?i)^[a-z0-9/\:_\-\*\[\]\+\?\|]*$`) func postIsPublished(p *models.Post) bool { if p == nil || !p.Published || p.PublishedAt == nil { return false } return p.PublishedAt.Before(time.Now()) } func canViewDraft(p *models.Post, pr *bouncer.Principal) bool { if p == nil || pr == nil { return false } if p.UserID != nil && *p.UserID == pr.ID { return true } return cabana.Allows(pr, []string{permAccessOtherPosts}) } func ctypeDigit(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func findPostBySlugOrID(db *gorm.DB, slug string) (*models.Post, error) { q := postQuery(db) var post models.Post if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil { return &post, nil } if ctypeDigit(slug) { id, err := strconv.ParseUint(slug, 10, 64) if err == nil && id > 0 { var byID models.Post if err := postQuery(db).Where("id = ?", id).Take(&byID).Error; err == nil { return &byID, nil } } } return nil, gorm.ErrRecordNotFound } func findPostByID(db *gorm.DB, raw string) (*models.Post, error) { id, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) if err != nil || id == 0 { return nil, gorm.ErrRecordNotFound } var post models.Post if err := postQuery(db).Where("id = ?", id).Take(&post).Error; err != nil { return nil, err } return &post, nil } func postQuery(db *gorm.DB) *gorm.DB { return db.Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags") } func loadPostAssocs(db *gorm.DB, post *models.Post) error { if post == nil { return nil } return db.Preload("User").Preload("Categories").Preload("Tags").First(post, post.ID).Error } func serializeSibling(p *models.Post) any { if p == nil { return nil } return map[string]any{"id": p.ID, "title": p.Title, "slug": p.Slug} } func previousPost(db *gorm.DB, p *models.Post) *models.Post { return siblingPost(db, p, true) } func nextPost(db *gorm.DB, p *models.Post) *models.Post { return siblingPost(db, p, false) } func siblingPost(db *gorm.DB, p *models.Post, previous bool) *models.Post { if p == nil { return nil } op, dir := "<", "DESC" if previous { op, dir = ">", "ASC" } q := applyPublished(db.Model(&models.Post{})).Where("id <> ?", p.ID) if p.PublishedAt != nil { q = q.Where( "(published_at "+op+" ?) OR (published_at = ? AND id "+op+" ?)", p.PublishedAt, p.PublishedAt, p.ID, ) } var out models.Post if err := q.Order("published_at " + dir + ", id " + dir).Take(&out).Error; err != nil { return nil } return &out } func relatedPosts(db *gorm.DB, p *models.Post, editor bool) []models.Post { if p == nil || len(p.Categories) == 0 { return []models.Post{} } ids := make([]uint, 0, len(p.Categories)) for _, c := range p.Categories { ids = append(ids, c.ID) } q := db.Model(&models.Post{}). Preload("User").Preload("Categories").Preload("Tags"). Where("id <> ?", p.ID). Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids) if !editor { q = applyPublished(q) } var rows []models.Post if err := q.Order("published_at DESC").Limit(3).Find(&rows).Error; err != nil { return []models.Post{} } return rows } func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, error) { var cats []models.Category if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil { return nil, err } counts := publishedPostCounts(db, "golem15_journal_posts_categories", "category_id") byParent := map[uint][]models.Category{} var roots []models.Category for _, c := range cats { if c.ParentID == nil || *c.ParentID == 0 { roots = append(roots, c) continue } byParent[*c.ParentID] = append(byParent[*c.ParentID], c) } out := make([]map[string]any, 0, len(roots)) for i := range roots { if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty); row != nil { out = append(out, row) } } return out, nil } func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty bool) map[string]any { if c == nil { return nil } kids := byParent[c.ID] childRows := make([]map[string]any, 0, len(kids)) for i := range kids { if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty); row != nil { childRows = append(childRows, row) } } postCount := counts[c.ID] if !includeEmpty && postCount == 0 && len(childRows) == 0 { return nil } var desc any if c.Description != nil { desc = *c.Description } var parent any if c.ParentID != nil { parent = *c.ParentID } var depth any if c.NestDepth != nil { depth = *c.NestDepth } row := map[string]any{ "id": c.ID, "name": c.Name, "slug": c.Slug, "description": desc, "parent_id": parent, "nest_depth": depth, "post_count": postCount, } if len(childRows) > 0 { row["children"] = childRows } return row } func serializeTagList(db *gorm.DB) ([]map[string]any, error) { var tags []models.Tag if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil { return nil, err } counts := publishedPostCounts(db, "golem15_journal_posts_tags", "tag_id") out := make([]map[string]any, 0, len(tags)) for _, tag := range tags { out = append(out, map[string]any{ "id": tag.ID, "name": tag.Name, "slug": tag.Slug, "post_count": counts[tag.ID], }) } return out, nil } func publishedPostCounts(db *gorm.DB, pivot, fk string) map[uint]int64 { type row struct { ID uint Count int64 } var rows []row q := fmt.Sprintf( `SELECT p.%s AS id, COUNT(*) AS count FROM %s p INNER JOIN golem15_journal_posts posts ON posts.id = p.post_id WHERE posts.published = TRUE AND posts.published_at IS NOT NULL AND posts.published_at < ? GROUP BY p.%s`, fk, pivot, fk, ) _ = db.Raw(q, time.Now()).Scan(&rows).Error out := map[uint]int64{} for _, r := range rows { out[r.ID] = r.Count } return out } func translationsOf(db *gorm.DB, p *models.Post) any { if db == nil || p == nil || p.ID == 0 { return []any{} } var locales []struct { Code string IsDefault bool } if err := db.Table("golem15_translate_locales"). Select("code, is_default"). Where("is_enabled = ?", true). Find(&locales).Error; err != nil { return []any{} } out := map[string]any{} for _, loc := range locales { if loc.IsDefault { continue } attrs := map[string]any{} ctx := db.Statement.Context if ctx == nil { ctx = context.Background() } for _, field := range []string{"title", "slug", "content", "content_html", "excerpt", "metadata"} { val, ok, err := translate.TranslatedExact(ctx, db, p, field, loc.Code) if err != nil || !ok || val == nil { continue } s := strings.TrimSpace(fmt.Sprint(val)) if s == "" { continue } attrs[field] = val } if len(attrs) > 0 { out[loc.Code] = attrs } } if len(out) == 0 { return []any{} } return out } func readJSONObject(r *http.Request) (map[string]any, error) { if r == nil || r.Body == nil { return map[string]any{}, nil } defer r.Body.Close() raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { return nil, err } if len(strings.TrimSpace(string(raw))) == 0 { return map[string]any{}, nil } dec := json.NewDecoder(strings.NewReader(string(raw))) dec.UseNumber() var body map[string]any if err := dec.Decode(&body); err != nil { return nil, err } if body == nil { body = map[string]any{} } return body, nil } func publishedFlag(body map[string]any) bool { v, ok := body["published"] if !ok { return false } return asBool(v) } func asBool(v any) bool { switch t := v.(type) { case bool: return t case json.Number: n, _ := t.Int64() return n != 0 case float64: return t != 0 case string: s := strings.ToLower(strings.TrimSpace(t)) return s == "1" || s == "true" || s == "on" || s == "yes" default: return false } } func asString(v any) string { if v == nil { return "" } switch t := v.(type) { case string: return t case json.Number: return t.String() default: return strings.TrimSpace(fmt.Sprint(v)) } } func asUint(v any) (uint, bool) { switch t := v.(type) { case json.Number: n, err := t.Int64() if err != nil || n <= 0 { return 0, false } return uint(n), true case float64: if t <= 0 || t != float64(uint64(t)) { return 0, false } return uint(t), true case string: n, err := strconv.ParseUint(strings.TrimSpace(t), 10, 64) if err != nil || n == 0 { return 0, false } return uint(n), true default: return 0, false } } func parsePublishedAt(v any) (*time.Time, error) { if v == nil { return nil, nil } s := strings.TrimSpace(asString(v)) if s == "" { return nil, nil } for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { if ts, err := time.Parse(layout, s); err == nil { return &ts, nil } } return nil, fmt.Errorf("invalid date") } func jsonPtr(v any) *string { if v == nil { return nil } switch t := v.(type) { case string: s := strings.TrimSpace(t) if s == "" { return nil } return &s default: raw, err := json.Marshal(v) if err != nil { return nil } s := string(raw) return &s } } func slugify(title string) string { s := strings.ToLower(strings.TrimSpace(title)) var b strings.Builder prevDash := false for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false case unicode.IsLetter(r) || unicode.IsNumber(r): b.WriteRune(unicode.ToLower(r)) prevDash = false default: if !prevDash && b.Len() > 0 { b.WriteByte('-') prevDash = true } } } return strings.Trim(b.String(), "-") } func generateUniqueSlug(db *gorm.DB, title string, exceptID uint) string { base := slugify(title) if base == "" { base = "post" } slug := base for n := 1; ; n++ { q := db.Model(&models.Post{}).Where("slug = ?", slug) if exceptID > 0 { q = q.Where("id <> ?", exceptID) } var count int64 _ = q.Count(&count).Error if count == 0 { return slug } slug = fmt.Sprintf("%s-%d", base, n) } } func buildNewPost(r *http.Request, db *gorm.DB, pr *bouncer.Principal, body map[string]any) (*models.Post, map[string][]string) { errs := map[string][]string{} title := strings.TrimSpace(asString(body["title"])) if title == "" { errs["title"] = []string{"The title field is required."} } else if utf8.RuneCountInString(title) > 255 { errs["title"] = []string{"The title may not be greater than 255 characters."} } content := asString(body["content"]) if strings.TrimSpace(content) == "" { errs["content"] = []string{"The content field is required."} } slug := strings.TrimSpace(asString(body["slug"])) if slug == "" { slug = generateUniqueSlug(db, title, 0) } else if !slugPattern.MatchString(slug) { errs["slug"] = []string{"The slug format is invalid."} } else if slugTaken(db, slug, 0) { errs["slug"] = []string{"The slug has already been taken."} } if _, has := body["published_at"]; has { if _, err := parsePublishedAt(body["published_at"]); err != nil { errs["published_at"] = []string{"The published at is not a valid date."} } } if catErr := validateCategoryIDs(db, body["categories"]); catErr != nil { errs["categories"] = []string{catErr.Error()} } if transErr := validateTranslations(body["translations"]); transErr != nil { errs["translations"] = []string{transErr.Error()} } if len(errs) > 0 { return nil, errs } html, err := classes.FormatHTML(content) if err != nil { return nil, map[string][]string{"content": {err.Error()}} } userID := pr.ID post := &models.Post{ Title: title, Slug: slug, Content: content, ContentHTML: html, Excerpt: strings.TrimSpace(asString(body["excerpt"])), Published: asBool(body["published"]), IsPinned: asBool(body["is_pinned"]), UserID: &userID, } if _, has := body["published_at"]; has { ts, _ := parsePublishedAt(body["published_at"]) post.PublishedAt = ts } if _, has := body["metadata"]; has { post.Metadata = jsonPtr(body["metadata"]) } if _, has := body["sources"]; has { post.Sources = jsonPtr(body["sources"]) } _ = r return post, nil } func applyPostUpdate(r *http.Request, db *gorm.DB, post *models.Post, body map[string]any) map[string][]string { errs := map[string][]string{} if _, has := body["title"]; has { title := strings.TrimSpace(asString(body["title"])) if title == "" { errs["title"] = []string{"The title field is required."} } else if utf8.RuneCountInString(title) > 255 { errs["title"] = []string{"The title may not be greater than 255 characters."} } else { post.Title = title } } if _, has := body["slug"]; has { slug := strings.TrimSpace(asString(body["slug"])) if slug != "" && !slugPattern.MatchString(slug) { errs["slug"] = []string{"The slug format is invalid."} } else if slug != "" && slugTaken(db, slug, post.ID) { errs["slug"] = []string{"The slug has already been taken."} } else if slug != "" { post.Slug = slug } } if _, has := body["content"]; has { content := asString(body["content"]) if strings.TrimSpace(content) == "" { errs["content"] = []string{"The content field is required."} } else { html, err := classes.FormatHTML(content) if err != nil { errs["content"] = []string{err.Error()} } else { post.Content = content post.ContentHTML = html } } } if _, has := body["excerpt"]; has { post.Excerpt = strings.TrimSpace(asString(body["excerpt"])) } if _, has := body["published"]; has { post.Published = asBool(body["published"]) } if _, has := body["is_pinned"]; has { post.IsPinned = asBool(body["is_pinned"]) } if _, has := body["published_at"]; has { ts, err := parsePublishedAt(body["published_at"]) if err != nil { errs["published_at"] = []string{"The published at is not a valid date."} } else { post.PublishedAt = ts } } if _, has := body["metadata"]; has { post.Metadata = jsonPtr(body["metadata"]) } if _, has := body["sources"]; has { post.Sources = jsonPtr(body["sources"]) } if catErr := validateCategoryIDs(db, body["categories"]); catErr != nil { errs["categories"] = []string{catErr.Error()} } if transErr := validateTranslations(body["translations"]); transErr != nil { errs["translations"] = []string{transErr.Error()} } if len(errs) > 0 { return errs } _ = r return nil } func slugTaken(db *gorm.DB, slug string, exceptID uint) bool { q := db.Model(&models.Post{}).Where("slug = ?", slug) if exceptID > 0 { q = q.Where("id <> ?", exceptID) } var n int64 _ = q.Count(&n).Error return n > 0 } func validateCategoryIDs(db *gorm.DB, raw any) error { if raw == nil { return nil } ids, ok := asUintSlice(raw) if !ok { return fmt.Errorf("The categories must be an array.") } for _, id := range ids { var n int64 if err := db.Model(&models.Category{}).Where("id = ?", id).Count(&n).Error; err != nil || n == 0 { return fmt.Errorf("The selected categories is invalid.") } } return nil } func validateTranslations(raw any) error { if raw == nil { return nil } m, ok := raw.(map[string]any) if !ok { return fmt.Errorf("The translations must be an array.") } for _, v := range m { attrs, ok := v.(map[string]any) if !ok { continue } if slug := strings.TrimSpace(asString(attrs["slug"])); slug != "" && !slugPattern.MatchString(slug) { return fmt.Errorf("The translations slug format is invalid.") } if title := asString(attrs["title"]); utf8.RuneCountInString(title) > 255 { return fmt.Errorf("The translations title may not be greater than 255 characters.") } } return nil } func asUintSlice(raw any) ([]uint, bool) { switch t := raw.(type) { case []any: out := make([]uint, 0, len(t)) for _, item := range t { id, ok := asUint(item) if !ok { return nil, false } out = append(out, id) } return out, true default: return nil, false } } func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error { if _, has := body["categories"]; has { ids, _ := asUintSlice(body["categories"]) if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil { return err } for _, id := range ids { if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil { return err } } } if _, has := body["tags"]; has { ids, err := resolveTagIDs(db, body["tags"]) if err != nil { return err } if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil { return err } for _, id := range ids { if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil { return err } } } if raw, has := body["translations"]; has { if err := syncTranslations(ctx, db, post, raw); err != nil { return err } } return nil } func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) { items, ok := raw.([]any) if !ok { if raw == nil { return nil, nil } return nil, fmt.Errorf("tags must be an array") } var ids []uint for _, item := range items { if id, ok := asUint(item); ok { var n int64 if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil { return nil, err } if n > 0 { ids = append(ids, id) } continue } name := strings.TrimSpace(asString(item)) if name == "" { continue } slug := slugify(name) var tag models.Tag err := db.Where("name = ? OR slug = ?", name, slug).Take(&tag).Error if err == nil { ids = append(ids, tag.ID) continue } if slug == "" { slug = "tag" } tag = models.Tag{Name: name, Slug: slug} if err := db.Create(&tag).Error; err != nil { return nil, err } ids = append(ids, tag.ID) } return ids, nil } func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error { m, ok := raw.(map[string]any) if !ok { return nil } fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"} for locale, v := range m { attrs, ok := v.(map[string]any) if !ok { continue } if content := asString(attrs["content"]); strings.TrimSpace(content) != "" && strings.TrimSpace(asString(attrs["content_html"])) == "" { html, err := classes.FormatHTML(content) if err != nil { return err } attrs["content_html"] = html } for _, field := range fields { if val, has := attrs[field]; has && val != nil { if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil { return err } } } } return nil }