Files
sm-journal-plugin/console/import_posts.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

363 lines
9.2 KiB
Go

package console
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"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/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"gorm.io/gorm"
)
// UnpublishedTitlePrefix is PHP Post::UNPUBLISHED_TITLE_PREFIX. Import strips
// it from titles so a draft-accessor export does not persist the lock mark.
const UnpublishedTitlePrefix = "🔒 "
// ImportGuard is an optional per-post check (toolbar publish permission).
type ImportGuard func(post *models.Post) error
// ImportStats is the PHP ImportPosts result line.
type ImportStats struct {
Created int
Updated int
Skipped int
Failed int
}
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
func ImportPosts(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:import-posts",
Description: "Import journal posts with translations from JSON files.",
Flags: []bonfire.Flag{
{Name: "path", Description: "Import directory (default: journal-export)"},
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
path := flagOrDefault(in, "path", defaultExportPath)
force := flagOn(in, "force")
dry := flagOn(in, "dry-run")
stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil)
if err != nil {
return err
}
if dry {
out.Info("Dry run complete (no changes made).")
}
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
return nil
},
}
}
// ImportFromDir reads *.json files from dir and creates or updates posts.
func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
var stats ImportStats
if gdb == nil {
return stats, fmt.Errorf("journal: database is not configured")
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return stats, fmt.Errorf("Import directory not found: %s", dir)
}
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
return stats, err
}
if len(files) == 0 {
if out != nil {
out.Warning("No JSON files found in: " + dir)
}
return stats, fmt.Errorf("no JSON files found in: %s", dir)
}
work := func(tx *gorm.DB) error {
for _, file := range files {
raw, err := os.ReadFile(file)
if err != nil {
if out != nil {
out.Error(" Invalid JSON: " + filepath.Base(file))
}
stats.Failed++
continue
}
var data PostFile
if err := json.Unmarshal(raw, &data); err != nil {
if out != nil {
out.Error(" Invalid JSON: " + filepath.Base(file))
}
stats.Failed++
continue
}
if strings.TrimSpace(data.Slug) == "" {
if out != nil {
out.Error(" Missing slug in: " + filepath.Base(file))
}
stats.Failed++
continue
}
result, err := importPost(ctx, tx, data, force, dry, out, guard)
if err != nil {
return err
}
switch result {
case "created":
stats.Created++
case "updated":
stats.Updated++
case "skipped":
stats.Skipped++
default:
stats.Failed++
}
}
return nil
}
if dry {
if err := work(gdb.WithContext(ctx).Session(&gorm.Session{NewDB: true})); err != nil {
return stats, err
}
return stats, nil
}
err = gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return work(tx)
})
return stats, err
}
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool, out bonfire.Output, guard ImportGuard) (string, error) {
var existing models.Post
err := db.Where("slug = ?", data.Slug).Take(&existing).Error
found := err == nil
if err != nil && err != gorm.ErrRecordNotFound {
return "failed", err
}
if found && !force {
line(out, " [skip] %s (already exists)", data.Slug)
return "skipped", nil
}
post := &models.Post{}
label := "created"
if found {
post = &existing
label = "updated"
if dry {
label = "would update"
}
} else if dry {
label = "would create"
}
line(out, " [%s] %s", label, data.Slug)
if dry {
draft := &models.Post{}
if found {
*draft = existing
}
fillPost(db, draft, data)
if guard != nil {
if err := guard(draft); err != nil {
return "failed", err
}
}
if found {
return "updated", nil
}
return "created", nil
}
fillPost(db, post, data)
if guard != nil {
if err := guard(post); err != nil {
return "failed", err
}
}
html, err := classes.FormatHTML(post.Content)
if err != nil {
return "failed", err
}
post.ContentHTML = html
if found {
if err := db.Save(post).Error; err != nil {
return "failed", err
}
} else {
if err := db.Create(post).Error; err != nil {
return "failed", err
}
}
if err := syncCategories(ctx, db, post, data, out); err != nil {
return "failed", err
}
if err := syncTranslations(ctx, db, post, data.Translations); err != nil {
return "failed", err
}
if found {
return "updated", nil
}
return "created", nil
}
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
post.Title = stripUnpublishedTitlePrefix(data.Title)
post.Slug = data.Slug
post.Content = data.Content
post.Excerpt = data.Excerpt
post.Metadata = encodeJSONField(data.Metadata)
post.Sources = encodeJSONField(data.Sources)
post.Published = data.Published
post.PublishedAt = parsePublishedAt(data.PublishedAt)
if data.AuthorEmail == "" || post.UserID != nil {
return
}
var author cabana.BackendUser
if err := db.Where("email = ?", data.AuthorEmail).Take(&author).Error; err != nil {
return
}
id := author.ID
post.UserID = &id
}
func parsePublishedAt(s *string) *time.Time {
if s == nil || strings.TrimSpace(*s) == "" {
return nil
}
raw := strings.TrimSpace(*s)
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z", "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return &t
}
}
return nil
}
func encodeJSONField(v any) *string {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if strings.TrimSpace(s) == "" {
return nil
}
return &s
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
t := string(b)
return &t
}
func syncCategories(ctx context.Context, db *gorm.DB, post *models.Post, data PostFile, out bonfire.Output) error {
if len(data.Categories) == 0 {
return nil
}
var cats []models.Category
for _, slug := range data.Categories {
slug = strings.TrimSpace(slug)
if slug == "" {
continue
}
var cat models.Category
err := db.Where("slug = ?", slug).Take(&cat).Error
if err == gorm.ErrRecordNotFound {
name := data.CategoryNames[slug]
if name == "" {
name = titleFromSlug(slug)
}
cat = models.Category{Name: name, Slug: slug}
if err := db.Create(&cat).Error; err != nil {
return err
}
if out != nil {
out.Info(" Created category: " + slug)
}
} else if err != nil {
return err
}
cats = append(cats, cat)
}
if len(cats) == 0 {
return nil
}
return db.WithContext(ctx).Model(post).Association("Categories").Replace(cats)
}
func titleFromSlug(slug string) string {
parts := strings.Split(strings.ReplaceAll(slug, "-", " "), " ")
for i, p := range parts {
if p == "" {
continue
}
r, size := utf8.DecodeRuneInString(p)
parts[i] = string(unicode.ToUpper(r)) + p[size:]
}
return strings.Join(parts, " ")
}
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any) error {
if len(translations) == 0 {
return nil
}
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
for locale, attrs := range translations {
// A translated content_html is derived from content or sanitized,
// as the host column is; Winter's ImportPosts stored it verbatim.
if err := classes.FormatTranslation(attrs); err != nil {
return err
}
for _, field := range fields {
val, ok := attrs[field]
if !ok || val == nil {
continue
}
if field == "title" {
if s, isStr := val.(string); isStr {
val = stripUnpublishedTitlePrefix(s)
}
}
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
return err
}
}
}
return nil
}
func stripUnpublishedTitlePrefix(title string) string {
for strings.HasPrefix(title, UnpublishedTitlePrefix) {
title = strings.TrimPrefix(title, UnpublishedTitlePrefix)
}
return title
}
func IOPayloadPath(raw []byte, fallback string) (path string, dry, force bool) {
path = fallback
if len(raw) == 0 {
return path, false, false
}
var body struct {
Path string `json:"path"`
DryRun bool `json:"dry_run"`
Force bool `json:"force"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return path, false, false
}
if strings.TrimSpace(body.Path) != "" {
path = strings.TrimSpace(body.Path)
}
return path, body.DryRun, body.Force
}