Files
at-container-registry/pkg/hold/admin/handlers_settings.go
T

222 lines
6.3 KiB
Go

package admin
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/spf13/viper"
)
// settingsData holds settings display data
type settingsData struct {
Public bool
AllowAllCrew bool
EnableBlueskyPosts bool
Successor string
OwnerDID string
OwnerHandle string
HoldDID string
QuotasEnabled bool
TierCount int
DefaultTier string
}
// getSettingsData returns settings display data
func (ui *AdminUI) getSettingsData(ctx context.Context) (*settingsData, error) {
_, captain, err := ui.pds.GetCaptainRecord(ctx)
if err != nil {
return nil, err
}
ownerHandle := resolveHandle(ctx, captain.Owner)
quotasEnabled := ui.quotaMgr != nil && ui.quotaMgr.IsEnabled()
tierCount := 0
defaultTier := ""
if quotasEnabled {
tierCount = ui.quotaMgr.TierCount()
defaultTier = ui.quotaMgr.GetDefaultTier()
}
return &settingsData{
Public: captain.Public,
AllowAllCrew: captain.AllowAllCrew,
EnableBlueskyPosts: captain.EnableBlueskyPosts,
Successor: captain.Successor,
OwnerDID: captain.Owner,
OwnerHandle: ownerHandle,
HoldDID: ui.pds.DID(),
QuotasEnabled: quotasEnabled,
TierCount: tierCount,
DefaultTier: defaultTier,
}, nil
}
// handleSettingsTab returns the settings tab content (HTMX partial)
func (ui *AdminUI) handleSettingsTab(w http.ResponseWriter, r *http.Request) {
defer clearFlash(w)
settings, err := ui.getSettingsData(r.Context())
if err != nil {
renderHTMXError(w, r, http.StatusInternalServerError, "Couldn't load settings", err)
return
}
data := struct {
Settings settingsData
}{
Settings: *settings,
}
ui.renderTemplate(w, "partials/tab_settings.html", data)
}
// handleSettingsUpdate processes settings updates. Supports both plain-form
// (flash-then-redirect) and htmx (HX-Trigger toast) paths so the settings
// panel can stay in the SPA shell.
func (ui *AdminUI) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
isHTMX := r.Header.Get("HX-Request") == "true"
respond := func(category, msg string, status int) {
if isHTMX {
trigger := `{"toast":{"message":` + jsonString(msg) + `,"type":"` + category + `"}}`
w.Header().Set("HX-Trigger", trigger)
w.Header().Set("HX-Reswap", "none")
if status == 0 {
status = http.StatusNoContent
}
w.WriteHeader(status)
return
}
setFlash(w, r, category, msg)
http.Redirect(w, r, "/admin#settings", http.StatusFound)
}
if err := r.ParseForm(); err != nil {
respond("error", "Invalid form data", http.StatusBadRequest)
return
}
public := r.FormValue("public") == "on"
allowAllCrew := r.FormValue("allow_all_crew") == "on"
enablePosts := r.FormValue("enable_bluesky_posts") == "on"
successor := strings.TrimSpace(r.FormValue("successor"))
// Validate successor DID format if provided
if successor != "" {
if _, err := syntax.ParseDID(successor); err != nil || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) {
respond("error", "Successor must be a valid did:web: or did:plc: DID", http.StatusBadRequest)
return
}
}
// Get existing captain record, modify fields, write back
_, captain, getErr := ui.pds.GetCaptainRecord(ctx)
if getErr != nil {
slog.Error("Failed to get captain record", "error", getErr)
respond("error", "Couldn't read settings", http.StatusInternalServerError)
return
}
captain.Public = public
captain.AllowAllCrew = allowAllCrew
captain.EnableBlueskyPosts = enablePosts
captain.Successor = successor
_, err := ui.pds.UpdateCaptainRecord(ctx, captain)
if err != nil {
slog.Error("Failed to update captain record", "error", err)
respond("error", "Couldn't update settings", http.StatusInternalServerError)
return
}
session := getSessionFromContext(ctx)
slog.Info("Settings updated via admin panel",
"public", public,
"allowAllCrew", allowAllCrew,
"enableBlueskyPosts", enablePosts,
"successor", successor,
"by", session.DID)
// Write settings back to YAML config file (if one exists)
if ui.config.ConfigPath != "" {
if err := ui.writeConfigSettings(public, allowAllCrew, enablePosts, successor); err != nil {
slog.Warn("Failed to write settings to config file",
"path", ui.config.ConfigPath, "error", err)
// Show warning toast once, then suppress for 2 minutes
if _, err := r.Cookie("config_write_warned"); err != nil {
setConfigWriteWarnedCookie(w, r)
respond("warning", "Saved, but config file isn't writable — changes won't persist across restarts", 0)
return
}
}
}
respond("success", "Settings updated", 0)
}
// jsonString wraps a string in double quotes with JSON-safe escaping.
// Used to build HX-Trigger header values without pulling in encoding/json.
func jsonString(s string) string {
b := make([]byte, 0, len(s)+2)
b = append(b, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '"', '\\':
b = append(b, '\\', c)
case '\n':
b = append(b, '\\', 'n')
case '\r':
b = append(b, '\\', 'r')
case '\t':
b = append(b, '\\', 't')
default:
if c < 0x20 {
b = append(b, '\\', 'u', '0', '0',
"0123456789abcdef"[c>>4],
"0123456789abcdef"[c&0xf])
} else {
b = append(b, c)
}
}
}
b = append(b, '"')
return string(b)
}
// writeConfigSettings updates the toggleable settings in the YAML config file.
// Uses a fresh Viper instance to avoid baking env var overrides into the file.
func (ui *AdminUI) writeConfigSettings(public, allowAllCrew, enablePosts bool, successor string) error {
v := viper.New()
v.SetConfigFile(ui.config.ConfigPath)
if err := v.ReadInConfig(); err != nil {
return err
}
v.Set("server.public", public)
v.Set("server.successor", successor)
v.Set("registration.allow_all_crew", allowAllCrew)
v.Set("registration.enable_bluesky_posts", enablePosts)
return v.WriteConfig()
}
// setConfigWriteWarnedCookie sets a short-lived cookie to suppress repeated warnings.
func setConfigWriteWarnedCookie(w http.ResponseWriter, r *http.Request) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "config_write_warned",
Value: "1",
Path: "/admin",
MaxAge: 120, // 2 minutes
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}