Files
sm-journal-plugin/controllers/api/posts_helpers.go
Jakub Zych 0d00ec8e0d fix(api): sanitize translated content_html on every write
A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.

classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.

PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
2026-10-10 00:09:47 +02:00

783 lines
19 KiB
Go

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 content any
if c.Content != nil {
content = *c.Content
}
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,
"content": content,
"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
}
// Every translated content_html goes through the host column's
// policy: derived from content, or sanitized when only content_html
// is supplied. Winter stored a supplied content_html verbatim.
if err := classes.FormatTranslation(attrs); err != nil {
return err
}
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
}