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 }