feat: journal:fix-headings and the settings API
- classes.FixStoredHeadings converts stored H1 in post content (every
locale) and category pillar text, in one transaction with a RecordSaved
and a site cache purge per changed record
- journal:fix-headings, a dry run unless --apply, refuses with exit code 1
while the H1 setting is off
- GET/PUT /_journal/api/v1/settings and POST /fix-headings {dry_run} for
golem15.journal.manage_settings; 409 with the refusal while the setting
is off
This commit is contained in:
219
controllers/api/settings.go
Normal file
219
controllers/api/settings.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"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/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// permManageSettings guards the settings and maintenance routes.
|
||||
const permManageSettings = "golem15.journal.manage_settings"
|
||||
|
||||
// requireManageSettings answers 403 {"error":"Insufficient permissions"}
|
||||
// without golem15.journal.manage_settings.
|
||||
func requireManageSettings(w http.ResponseWriter, pr *bouncer.Principal) bool {
|
||||
if !cabana.Allows(pr, []string{permManageSettings}) {
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serializeSettings is the settings answer: every fillable column by name.
|
||||
func serializeSettings(s models.Settings) map[string]any {
|
||||
return map[string]any{
|
||||
"show_all_posts": s.ShowAllPosts,
|
||||
"use_rich_editor": s.UseRichEditor,
|
||||
"search_use_typesense": s.SearchUseTypesense,
|
||||
"search_title_weight": s.SearchTitleWeight,
|
||||
"search_excerpt_weight": s.SearchExcerptWeight,
|
||||
"search_content_weight": s.SearchContentWeight,
|
||||
"rss_enabled": s.RSSEnabled,
|
||||
"rss_include_content": s.RSSIncludeContent,
|
||||
"rss_title": s.RSSTitle,
|
||||
"rss_description": s.RSSDescription,
|
||||
"rss_language": s.RSSLanguage,
|
||||
"rss_copyright": s.RSSCopyright,
|
||||
"rss_image_url": s.RSSImageURL,
|
||||
"rss_posts_per_feed": s.RSSPostsPerFeed,
|
||||
"content_h1_to_h2": s.ContentH1ToH2,
|
||||
"generic_anchors": s.GenericAnchors,
|
||||
}
|
||||
}
|
||||
|
||||
// loadSettings reads the settings row (ID 1); without one it is the
|
||||
// migration's defaults, not yet stored.
|
||||
func loadSettings(db *gorm.DB) (models.Settings, error) {
|
||||
var s models.Settings
|
||||
err := db.First(&s, 1).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.Settings{
|
||||
ID: 1, ShowAllPosts: true, SearchTitleWeight: 5, SearchExcerptWeight: 3, SearchContentWeight: 1,
|
||||
RSSEnabled: true, RSSLanguage: "en-us", RSSPostsPerFeed: 20, ContentH1ToH2: true,
|
||||
}, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// ShowSettings is GET /_journal/api/v1/settings: the Journal settings row,
|
||||
// for golem15.journal.manage_settings (MCP journal_get_settings).
|
||||
func ShowSettings(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
|
||||
}
|
||||
s, err := loadSettings(db.WithContext(r.Context()))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(s)})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSettings is PUT /_journal/api/v1/settings: it writes the submitted
|
||||
// fillable columns (models.Settings.Fillable) after checking them against
|
||||
// the model's Rules, and answers the row. Other keys are ignored. Changing
|
||||
// content_h1_to_h2 never rewrites stored content (D-50). MCP
|
||||
// journal_update_settings.
|
||||
func UpdateSettings(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
|
||||
}
|
||||
fillable := models.Settings{}.Fillable()
|
||||
values := map[string]any{}
|
||||
for key, v := range body {
|
||||
if slices.Contains(fillable, key) {
|
||||
values[key] = plainJSON(v)
|
||||
}
|
||||
}
|
||||
var tr *phrasebook.Translator
|
||||
if app != nil {
|
||||
tr, _ = app.Lookup[*phrasebook.Translator]()
|
||||
}
|
||||
var saved models.Settings
|
||||
var verrs map[string][]string
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
s, err := loadSettings(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := map[string]string{}
|
||||
for key, rule := range s.Rules() {
|
||||
if _, has := values[key]; has {
|
||||
rules[key], verrs = settingsRule(key, rule, values[key], verrs)
|
||||
}
|
||||
}
|
||||
if len(verrs) > 0 {
|
||||
return nil
|
||||
}
|
||||
if verrs, err = lagoon.Validate(ctx, tx, &s, rules, values, tr); err != nil || len(verrs) > 0 {
|
||||
return err
|
||||
}
|
||||
if err := lagoon.Fill(&s, fillable, values, true); err != nil {
|
||||
var typeErr *lagoon.FillTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
verrs = map[string][]string{typeErr.Key: {"The " + typeErr.Key + " field is invalid."}}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.ID = 1
|
||||
if err := tx.Save(&s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
saved = s
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
pluginLog.ErrorContext(r.Context(), "journal api: settings update failed", "error", err)
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if len(verrs) > 0 {
|
||||
writeValidation(w, verrs)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(saved)})
|
||||
}
|
||||
}
|
||||
|
||||
// plainJSON turns a json.Number (readJSONObject decodes with UseNumber)
|
||||
// into an int64, or a float64 when it is not an integer.
|
||||
func plainJSON(v any) any {
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return i
|
||||
}
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// settingsRule checks the string and url tokens of a Settings rule, which
|
||||
// lagoon.Validate does not know, and returns the rule without them for
|
||||
// lagoon.Validate. A failed check is added to verrs.
|
||||
func settingsRule(key, rule string, value any, verrs map[string][]string) (string, map[string][]string) {
|
||||
var kept []string
|
||||
for _, tok := range strings.Split(rule, "|") {
|
||||
switch tok {
|
||||
case "string":
|
||||
if _, ok := value.(string); !ok && value != nil {
|
||||
verrs = addSettingsError(verrs, key, "The "+key+" must be a string.")
|
||||
}
|
||||
case "url":
|
||||
if text, _ := value.(string); text != "" {
|
||||
u, err := url.Parse(text)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
verrs = addSettingsError(verrs, key, "The "+key+" format is invalid.")
|
||||
}
|
||||
}
|
||||
default:
|
||||
kept = append(kept, tok)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "|"), verrs
|
||||
}
|
||||
|
||||
func addSettingsError(verrs map[string][]string, key, msg string) map[string][]string {
|
||||
if verrs == nil {
|
||||
verrs = map[string][]string{}
|
||||
}
|
||||
verrs[key] = append(verrs[key], msg)
|
||||
return verrs
|
||||
}
|
||||
Reference in New Issue
Block a user