mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
222 lines
6.3 KiB
Go
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,
|
|
})
|
|
}
|