package web import ( "context" "errors" "net/http" "strconv" "strings" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // The cluster's settings, edited in the browser (BACKLOG E4): the document // `llmgw ctl config edit` opens, for whoever has no terminal on a control // plane. The same rules hold here as there — a version is checked as a // control plane would load it before it is kept, it is made from the newest // version or refused, and every change is in force on every control plane // within seconds and in the audit log. // SettingsAdmin is where the cluster's settings are kept. type SettingsAdmin interface { ClusterSettings(ctx context.Context) (store.SettingsVersion, error) ClusterSettingsAt(ctx context.Context, version int64) (store.SettingsVersion, error) ClusterSettingsHistory(ctx context.Context, limit int) ([]store.SettingsVersion, error) PutClusterSettings(ctx context.Context, base int64, body, by, note string) (int64, error) } type settingsPageData struct { Nav Nav Version string CSRF string // Current is the version in force; Shown the one in the editor, which // is an earlier one when ?version= asked for it. Current, Shown store.SettingsVersion Earlier bool // Body is what the editor holds: the version shown, or what was sent // and refused, so a refusal does not lose the edit. Body string Note string History []settingsRow LocalKeys []string Notice string Error string } type settingsRow struct { Version int64 At, By string Note string InForce bool Selected bool } // RegisterAdminSettings mounts /admin/settings, for administrators only: // the settings hold webhook addresses and the like, so even reading them is // not every staff role's. func (p *AccountPages) RegisterAdminSettings(mux *http.ServeMux, s SettingsAdmin, check func([]byte) error, localKeys []string) { p.hasSettings = true page := func(w http.ResponseWriter, r *http.Request, admin store.User, d settingsPageData) { ctx, l := r.Context(), langOf(r) d.Nav, d.Version, d.CSRF, d.LocalKeys = p.navFor(r, admin, "settings"), p.version, csrfFor(r), localKeys cur, err := s.ClusterSettings(ctx) if err != nil { d.Error = l.Err(err) } d.Current = cur if d.Shown.Version == 0 { d.Shown = cur } d.Earlier = d.Shown.Version != cur.Version if d.Body == "" { d.Body = d.Shown.Body } if hist, err := s.ClusterSettingsHistory(ctx, 30); err == nil { for _, v := range hist { d.History = append(d.History, settingsRow{Version: v.Version, At: l.Date(v.At.UTC()) + " " + v.At.UTC().Format("15:04"), By: v.By, Note: v.Note, InForce: v.Version == cur.Version, Selected: v.Version == d.Shown.Version}) } } noStore(w) render(w, r, "admin_settings", d) } adminsOnly := func(next func(w http.ResponseWriter, r *http.Request, admin store.User)) http.HandlerFunc { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { if !admin.IsAdmin { http.NotFound(w, r) return } if r.Method == http.MethodPost { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { http.Error(w, "bad request", http.StatusBadRequest) return } } next(w, r, admin) }) } mux.HandleFunc("GET /admin/settings", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { var d settingsPageData if q := r.URL.Query().Get("version"); q != "" { n, err := strconv.ParseInt(q, 10, 64) if v, verr := s.ClusterSettingsAt(r.Context(), n); err == nil && verr == nil && v.Version != 0 { d.Shown = v } else { d.Error = langOf(r).T("settings.err.no_version") } } page(w, r, admin, d) })) mux.HandleFunc("POST /admin/settings", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { l := langOf(r) body := strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") note := strings.TrimSpace(r.FormValue("note")) base, _ := strconv.ParseInt(r.FormValue("base"), 10, 64) // The audit line records the form; the document can hold webhook // secrets, so it records the document's size, not the document. r.PostForm.Set("body", "("+strconv.Itoa(len(body))+" bytes)") refuse := func(msg string) { noteAudit(r, "cluster settings", "", msg) page(w, r, admin, settingsPageData{Body: body, Note: note, Error: msg}) } if cur, err := s.ClusterSettings(r.Context()); err == nil && cur.Body == body { refuse(l.T("settings.err.unchanged")) return } if err := check([]byte(body)); err != nil { refuse(l.T("settings.err.invalid") + " " + err.Error()) return } n, err := s.PutClusterSettings(r.Context(), base, body, admin.Email, note) switch { case errors.Is(err, store.ErrSettingsChanged): refuse(l.T("settings.err.changed")) return case err != nil: refuse(l.Err(err)) return } noteAudit(r, "cluster settings", "version "+strconv.FormatInt(n, 10), "") page(w, r, admin, settingsPageData{Notice: l.T("settings.saved", n)}) })) mux.HandleFunc("POST /admin/settings/rollback", adminsOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { l := langOf(r) n, _ := strconv.ParseInt(r.FormValue("version"), 10, 64) old, err := s.ClusterSettingsAt(r.Context(), n) if err != nil || old.Version == 0 { page(w, r, admin, settingsPageData{Error: l.T("settings.err.no_version")}) return } cur, err := s.ClusterSettings(r.Context()) if err != nil { page(w, r, admin, settingsPageData{Error: l.Err(err)}) return } if err := check([]byte(old.Body)); err != nil { // A version kept before a change to server.yaml may no longer // load beside it; refused here rather than breaking the cluster. noteAudit(r, "cluster settings", "", err.Error()) page(w, r, admin, settingsPageData{Error: l.T("settings.err.invalid") + " " + err.Error()}) return } v, err := s.PutClusterSettings(r.Context(), cur.Version, old.Body, admin.Email, "back to version "+strconv.FormatInt(n, 10)) if err != nil { page(w, r, admin, settingsPageData{Error: l.Err(err)}) return } noteAudit(r, "cluster settings", "version "+strconv.FormatInt(v, 10)+" from "+strconv.FormatInt(n, 10), "") page(w, r, admin, settingsPageData{Notice: l.T("settings.rolled_back", n, v)}) })) }