/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>
175 lines
6.2 KiB
Go
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)})
|
|
}))
|
|
}
|