Files
llmgw/internal/web/admin_settings.go
Ilker ManapandClaude Opus 5.5 659f20dc9a The cluster's settings edited on the portal, by administrators
/admin/settings edits the document llmgw ctl config edit opens: checked as a
control plane would load it, refused when made from a version since replaced
(the edit kept), its history viewed and rolled back as a new version, every
change audited by size and never by contents, since it can hold webhook
secrets. Administrators only; in English and Turkish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 17:23:13 +02:00

175 lines
6.2 KiB
Go

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)})
}))
}