- 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
220 lines
6.7 KiB
Go
220 lines
6.7 KiB
Go
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
|
|
}
|