Files
sm-journal-plugin/controllers/api/maintenance.go
Jakub Zych cf13c606f8 fix: image alt on HTML-only translations and on markdown that starts with a tag
Blank content means content_html is the body (no 422 for
content_html-only translations, a regression from the markdown alt fix);
any other content is handled as markdown, so a body opening with a tag
gets its inline image label and keeps its code samples.

quick-261104-xae, review WR-02.
2026-11-05 00:19:28 +01:00

345 lines
12 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"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/cabana"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// maxImageAltChanges caps one POST /images/alt batch.
const maxImageAltChanges = 500
// FixHeadings is POST /_journal/api/v1/fix-headings {dry_run}: the
// journal:fix-headings conversion of stored H1 headings (posts in every
// locale and category pillar text), for golem15.journal.manage_settings. It
// is a dry run unless dry_run is false, and answers {posts, categories,
// headings, dry_run}. While the H1 setting is off it refuses with 409 and
// the refusal phrase. MCP journal_fix_headings.
func FixHeadings(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok || !requireManageSettings(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
}
dry := true
if v, has := body["dry_run"]; has && v != nil {
dry = asBool(v)
}
if !classes.EffectiveH1ToH2(r.Context(), db, appConfig(app)) {
writeAPIError(w, http.StatusConflict, classes.H1ConversionOffText)
return
}
res, err := classes.FixStoredHeadings(r.Context(), app, db, !dry)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{
"posts": res.Posts,
"categories": res.Categories,
"headings": res.Headings,
"dry_run": dry,
})
}
}
// imageAltChange is one requested alt change.
type imageAltChange struct {
postID uint
locale string
src string
alt string
}
// imageAltText is the stored body of one post in one locale ("" is the
// host columns) while a batch is applied.
type imageAltText struct {
post *models.Post
locale string
content, contentHTML string
oldContent, oldHTML string
}
// errMarkdownImage refuses an alt for an image a markdown post's source
// does not hold as an inline image or a raw img.
const errMarkdownImage = "The image is not an inline image of the post's markdown; edit the post."
// errForbiddenPost stops a batch that names a post the caller may not edit.
var errForbiddenPost = errors.New("forbidden post")
// SetImageAlt is POST /_journal/api/v1/images/alt {dry_run, changes:
// [{post_id, locale, src, alt}]}: it sets the alt attribute of the img whose
// src equals src in the post's content_html, in the default locale when
// locale is empty and in that translation otherwise; alt "" marks a
// decorative image. The post's content gets it too, read as markdown (an
// HTML body included): its inline image ![alt](src) or a raw img outside
// code (classes.SetMarkdownImageAlt, classes.SetImageAltOutsideCode), so
// the next content save keeps it. An image the markdown does not hold that
// way (a reference-style image, for example) refuses the change with 422
// changes.N.src. A locale whose content is blank (a translation that
// carries only content_html) gets the alt in content_html alone. It needs
// golem15.journal.access_posts and may edit only the posts the caller may
// edit (403). The batch is all or nothing: a missing post or an src that is
// not in the post refuses it with 422 (errors keyed changes.N.field). It
// answers {changes: [{post_id, locale, src, old_alt, alt, changed}],
// dry_run}; a dry run (dry_run true) writes nothing. Applied changes write
// in one transaction and fire one RecordSaved per changed post after the
// commit. Alt text is post content, so it is a Journal tool (P33-05):
// MCP journal_set_image_alt.
func SetImageAlt(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok || !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
}
dry := false
if v, has := body["dry_run"]; has && v != nil {
dry = asBool(v)
}
changes, verrs := parseImageAltChanges(body["changes"])
if len(verrs) > 0 {
writeValidation(w, verrs)
return
}
var answer []map[string]any
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
texts := map[string]*imageAltText{}
posts := map[uint]*models.Post{}
answer = make([]map[string]any, 0, len(changes))
for i, c := range changes {
post, found := posts[c.postID]
if !found {
p, err := findPostByID(tx, strconv.FormatUint(uint64(c.postID), 10))
if err != nil || p == nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The selected post is invalid.")
continue
}
if !p.CanEdit(pr) {
return errForbiddenPost
}
posts[c.postID], post = p, p
}
key := strconv.FormatUint(uint64(c.postID), 10) + "|" + c.locale
text, found := texts[key]
if !found {
text, err = loadImageAltText(ctx, tx, post, c.locale)
if err != nil {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.locale", i), "The locale is invalid.")
continue
}
texts[key] = text
}
html, n, old := classes.SetImageAlt(text.contentHTML, c.src, c.alt)
if n == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The image is not in the post.")
continue
}
switch {
case strings.TrimSpace(text.content) == "":
// content_html is the body (a translation that holds
// only content_html, or a row without content): nothing
// else to keep in sync (33-REVIEW-GAPFIX WR-02).
default:
// content is read as markdown, as FormatHTML renders it
// on every API path, and the next save derives
// content_html from it again, so the alt must land in it
// (L1 WR-05): an inline image, or a raw img outside
// code. An HTML body (the rich editor's) is HTML blocks
// to goldmark, so its img is a raw img outside code and
// gets the alt here too. The first character no longer
// picks the branch, so markdown that opens with a tag
// keeps its code samples and is never answered changed
// and reverted later.
content, m, _ := classes.SetMarkdownImageAlt(text.content, c.src, c.alt)
content, r, _ := classes.SetImageAltOutsideCode(content, c.src, c.alt)
if m+r == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), errMarkdownImage)
continue
}
text.content = content
}
text.contentHTML = html
var oldAlt any
if old != nil {
oldAlt = *old
}
answer = append(answer, map[string]any{
"post_id": c.postID, "locale": c.locale, "src": c.src,
"old_alt": oldAlt, "alt": c.alt, "changed": old == nil || *old != c.alt,
})
}
if len(verrs) > 0 || dry {
return nil
}
return writeImageAltTexts(ctx, app, tx, texts)
})
switch {
case errors.Is(err, errForbiddenPost):
writeAPIError(w, http.StatusForbidden, errEditPermission)
return
case err != nil:
pluginLog.ErrorContext(r.Context(), "journal api: image alt failed", "error", err)
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
case len(verrs) > 0:
writeValidation(w, verrs)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"changes": answer, "dry_run": dry})
}
}
// parseImageAltChanges reads the changes array: post_id (a positive id),
// src (not blank) and alt (a string, "" allowed) are required, locale is
// optional.
func parseImageAltChanges(raw any) ([]imageAltChange, map[string][]string) {
items, ok := raw.([]any)
if !ok {
return nil, map[string][]string{"changes": {"The changes must be an array."}}
}
if len(items) > maxImageAltChanges {
return nil, map[string][]string{"changes": {fmt.Sprintf("The changes may not have more than %d items.", maxImageAltChanges)}}
}
var verrs map[string][]string
out := make([]imageAltChange, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]any)
if !ok {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d", i), "The change must be an object.")
continue
}
var c imageAltChange
id, ok := asUint(m["post_id"])
if !ok || id == 0 {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The post id field is required.")
}
c.postID = id
c.src = strings.TrimSpace(asString(m["src"]))
if c.src == "" {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The src field is required.")
}
alt, isString := m["alt"].(string)
if !isString {
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.alt", i), "The alt must be a string.")
}
c.alt = strings.TrimSpace(alt)
c.locale = strings.TrimSpace(asString(m["locale"]))
out = append(out, c)
}
return out, verrs
}
// loadImageAltText reads a post's content and content_html in locale: the
// host columns for "", the translation otherwise (the host columns for the
// default locale).
func loadImageAltText(ctx context.Context, tx *gorm.DB, post *models.Post, locale string) (*imageAltText, error) {
t := &imageAltText{post: post, locale: locale}
if locale == "" {
t.content, t.contentHTML = post.Content, post.ContentHTML
} else {
for _, field := range []string{"content", "content_html"} {
v, _, err := translate.TranslatedExact(ctx, tx, post, field, locale)
if err != nil {
return nil, err
}
text, _ := v.(string)
if field == "content" {
t.content = text
} else {
t.contentHTML = text
}
}
}
t.oldContent, t.oldHTML = t.content, t.contentHTML
return t, nil
}
// writeImageAltTexts stores the changed bodies and fires one RecordSaved
// per changed post, with a site cache purge.
func writeImageAltTexts(ctx context.Context, app *backpack.App, tx *gorm.DB, texts map[string]*imageAltText) error {
keys := make([]string, 0, len(texts))
for key := range texts {
keys = append(keys, key)
}
sort.Strings(keys)
byPost := map[uint][]pact.RecordChange{}
var order []*models.Post
for _, key := range keys {
t := texts[key]
var changes []pact.RecordChange
if t.content != t.oldContent {
changes = append(changes, pact.RecordChange{Attribute: "content", Locale: t.locale, Old: t.oldContent, New: t.content})
}
if t.contentHTML != t.oldHTML {
changes = append(changes, pact.RecordChange{Attribute: "content_html", Locale: t.locale, Old: t.oldHTML, New: t.contentHTML})
}
if len(changes) == 0 {
continue
}
if t.locale == "" {
if err := tx.Model(&models.Post{}).Where("id = ?", t.post.ID).
Updates(map[string]any{"content": t.content, "content_html": t.contentHTML}).Error; err != nil {
return err
}
} else {
for _, c := range changes {
if err := translate.SetTranslated(ctx, tx, t.post, c.Attribute, t.locale, c.New); err != nil {
return err
}
}
}
if _, seen := byPost[t.post.ID]; !seen {
order = append(order, t.post)
}
byPost[t.post.ID] = append(byPost[t.post.ID], changes...)
}
locale, _ := towel.Locale(ctx)
purger := cooler.SiteCacheFromApp(app)
for _, post := range order {
cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
Locale: locale,
Changes: byPost[post.ID],
})
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
}
return nil
}