Admin endpoint: ENV/file admin key, --reset-admin-key, settings API wired into ratelimit/max-bytes/default-expiry/burn-window (#40)
CI / test (push) Successful in 22s
CI / docker (push) Skipped

This commit is contained in:
2026-09-09 00:44:02 -05:00
parent 03600b2ed5
commit a3349b4a98
10 changed files with 562 additions and 11 deletions
+15
View File
@@ -51,6 +51,21 @@ The SQLite database lives in the `/data` volume inside the container.
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
### Admin
`GET /admin` serves the admin page. Enter the admin key there — it is stored in
`sessionStorage` (never a cookie) and sent as the `X-Admin-Key` header on
`GET`/`POST /admin/api/settings`.
The admin API reads/sets: rate-limit burst, rate-limit refill per minute, max
content bytes, default expiry, custom URL reservation days, and the burn
viewer window (minutes). All admin access attempts are logged.
```bash
./palette --reset-admin-key # regenerate the admin key and print it
```
## API ## API
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// #40: admin endpoint with an install-time key. The key is read from
// PALETTE_ADMIN_KEY when set; otherwise a 32-char random hex key is generated
// and persisted to <db-dir>/admin-key (0600) so it survives restarts.
// Settings holds the runtime-tunable values the admin API exposes. The list
// is intentionally small and extensible: add a field + JSON tag, wire it into
// the consumer, and it round-trips through GET/POST /admin/api/settings.
type Settings struct {
RateLimitBurst float64 `json:"rate_limit_burst"`
RateLimitPerMinute float64 `json:"rate_limit_per_minute"`
MaxContentBytes int64 `json:"max_content_bytes"`
DefaultExpiry string `json:"default_expiry"`
CustomSlugReservationDays int `json:"custom_slug_reservation_days"`
BurnViewerWindowMinutes int `json:"burn_viewer_window_minutes"`
}
func defaultSettings(cfg Config) Settings {
return Settings{
RateLimitBurst: 5,
RateLimitPerMinute: 60, // 1 req/sec refill
MaxContentBytes: cfg.MaxTextBytes,
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
CustomSlugReservationDays: customSlugReservationDays,
BurnViewerWindowMinutes: readWindowMinutes,
}
}
// settingsStore keeps the current settings in memory (mutex-guarded) and
// persists them as JSON to <db-dir>/settings.json.
type settingsStore struct {
mu sync.RWMutex
cur Settings
path string
}
func loadSettingsStore(dbPath string, cfg Config) *settingsStore {
p := filepath.Join(filepath.Dir(dbPath), "settings.json")
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
if b, err := os.ReadFile(p); err == nil {
var s Settings
if json.Unmarshal(b, &s) == nil {
// merge over defaults so newly added fields keep sane values
def := defaultSettings(cfg)
if s.RateLimitBurst > 0 {
def.RateLimitBurst = s.RateLimitBurst
}
if s.RateLimitPerMinute > 0 {
def.RateLimitPerMinute = s.RateLimitPerMinute
}
if s.MaxContentBytes > 0 {
def.MaxContentBytes = s.MaxContentBytes
}
if s.DefaultExpiry != "" {
def.DefaultExpiry = s.DefaultExpiry
}
if s.CustomSlugReservationDays > 0 {
def.CustomSlugReservationDays = s.CustomSlugReservationDays
}
if s.BurnViewerWindowMinutes > 0 {
def.BurnViewerWindowMinutes = s.BurnViewerWindowMinutes
}
ss.cur = def
}
}
return ss
}
func (ss *settingsStore) get() Settings {
ss.mu.RLock()
defer ss.mu.RUnlock()
return ss.cur
}
func (ss *settingsStore) set(s Settings) error {
if s.RateLimitBurst <= 0 || s.RateLimitPerMinute <= 0 || s.MaxContentBytes <= 0 ||
s.CustomSlugReservationDays <= 0 || s.BurnViewerWindowMinutes <= 0 {
return fmt.Errorf("all numeric settings must be positive")
}
if s.DefaultExpiry != "" {
d, err := time.ParseDuration(s.DefaultExpiry)
if err != nil || !validExpiry(d) {
return fmt.Errorf("default_expiry must be a duration between 1 minute and 1 year (or empty)")
}
}
ss.mu.Lock()
defer ss.mu.Unlock()
b, _ := json.Marshal(s)
if err := os.WriteFile(ss.path, b, 0600); err != nil {
return err
}
ss.cur = s
return nil
}
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
// the path so callers can regenerate. Used by --reset-admin-key (#40).
func resetAdminKeyFile(dbPath string) string {
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
os.Remove(p)
return p
}
// resolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
// persisted key file is reused; else a new 32-char hex key is generated and
// persisted with 0600 perms.
func resolveAdminKey(dbPath string) (string, error) {
if v := os.Getenv("PALETTE_ADMIN_KEY"); v != "" {
return v, nil
}
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
if b, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(b))) >= 16 {
return strings.TrimSpace(string(b)), nil
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
key := hex.EncodeToString(b)
if err := os.WriteFile(p, []byte(key+"\n"), 0600); err != nil {
return "", err
}
log.Printf("generated admin key, persisted to %s", p)
return key, nil
}
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
// file, generate a fresh key, print it.
func handleResetAdminKey(dbPath string) {
p := resetAdminKeyFile(dbPath)
key, err := resolveAdminKey(dbPath)
if err != nil {
log.Fatalf("reset admin key: %v", err)
}
fmt.Printf("admin key reset; new key written to %s:\n%s\n", p, key)
}
// adminKeyOK reports whether the request carries the correct admin key via
// X-Admin-Key header or ?key=. Constant-time compare; failures and successes
// are both logged (#40).
func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
given := r.Header.Get("X-Admin-Key")
if given == "" {
given = r.URL.Query().Get("key")
}
return subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1
}
func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.adminKeyOK(r, key) {
log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeErr(w, 401, "unauthorized")
return
}
log.Printf("admin auth OK: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next(w, r)
}
}
func (a *apiServer) handleAdminPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "admin.html", map[string]any{"Page": "admin"})
}
func (a *apiServer) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, a.settings.get())
}
func (a *apiServer) handleAdminPostSettings(w http.ResponseWriter, r *http.Request) {
var s Settings
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
writeErr(w, 400, "invalid json body")
return
}
if err := a.settings.set(s); err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, a.settings.get())
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// newTestSettingsStore builds an in-memory settings store with a temp file.
func newTestSettingsStore(t *testing.T, cfg Config) *settingsStore {
t.Helper()
dir := t.TempDir()
ss := loadSettingsStore(filepath.Join(dir, "palette.db"), cfg)
// point persistence at a temp path (dir(dbPath) == dir)
return ss
}
func TestAdminAuth(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/admin/api/settings", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("no key: expected 401, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
req.Header.Set("X-Admin-Key", "wrong-key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("wrong key: expected 401, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings?key=test-admin-key", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("query key: expected 200, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
req.Header.Set("X-Admin-Key", "test-admin-key")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("header key: expected 200, got %d", rec.Code)
}
// HTML page itself is open (key entered via form)
req = httptest.NewRequest("GET", "/admin", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("admin page: expected 200, got %d", rec.Code)
}
}
func TestAdminEnvKeyPrecedence(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
key, err := resolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
if key != "envkey1234567890abcdef" {
t.Fatalf("env key not used: %q", key)
}
if _, err := os.Stat(filepath.Join(dir, "admin-key")); !os.IsNotExist(err) {
t.Fatal("env key should not create a key file")
}
// unset env: file takes over
os.Unsetenv("PALETTE_ADMIN_KEY")
key2, err := resolveAdminKey(dbPath)
if err != nil {
t.Fatal(err)
}
if len(key2) != 32 {
t.Fatalf("generated key should be 32 hex chars, got %d", len(key2))
}
if fi, err := os.Stat(filepath.Join(dir, "admin-key")); err != nil || fi.Mode().Perm() != 0600 {
t.Fatalf("admin-key file perms: %v err %v", fi, err)
}
// reuse on subsequent boots
key3, _ := resolveAdminKey(dbPath)
if key3 != key2 {
t.Fatal("persisted key not reused")
}
}
func TestAdminSettingsRoundTrip(t *testing.T) {
s := testServer(t)
h := s.routes()
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(body))
req.Header.Set("X-Admin-Key", "test-admin-key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := post(`{"rate_limit_burst": 9, "rate_limit_per_minute": 120, "max_content_bytes": 1024, "default_expiry": "1h", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 7}`)
if rec.Code != 200 {
t.Fatalf("post settings: %d %s", rec.Code, rec.Body.String())
}
got := s.settings.get()
if got.RateLimitBurst != 9 || got.RateLimitPerMinute != 120 || got.MaxContentBytes != 1024 ||
got.DefaultExpiry != "1h" || got.CustomSlugReservationDays != 10 || got.BurnViewerWindowMinutes != 7 {
t.Fatalf("settings not applied: %+v", got)
}
// persisted to disk
b, err := os.ReadFile(s.settings.path)
if err != nil {
t.Fatal(err)
}
var persisted Settings
if err := json.Unmarshal(b, &persisted); err != nil {
t.Fatal(err)
}
if persisted.BurnViewerWindowMinutes != 7 {
t.Fatalf("persisted settings wrong: %+v", persisted)
}
// invalid rejected
if rec := post(`{"rate_limit_burst": -1}`); rec.Code != 400 {
t.Fatalf("invalid settings: expected 400, got %d", rec.Code)
}
if rec := post(`{"rate_limit_burst": 5, "rate_limit_per_minute": 60, "max_content_bytes": 1024, "default_expiry": "bogus", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 5}`); rec.Code != 400 {
t.Fatalf("bad expiry: expected 400, got %d", rec.Code)
}
// settings actually consumed: default expiry applied on create
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ExpiresAt *int64 `json:"expires_at"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ExpiresAt == nil {
t.Fatal("default expiry not applied to new paste")
}
}
func TestAdminResetKey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "palette.db")
os.Unsetenv("PALETTE_ADMIN_KEY")
key1, _ := resolveAdminKey(dbPath)
// direct invocation of the reset behavior
resetAdminKeyFile(dbPath)
key2, _ := resolveAdminKey(dbPath)
if key1 == key2 {
t.Fatal("reset did not regenerate key")
}
}
+12 -1
View File
@@ -23,6 +23,17 @@ func genDeletionToken() string {
// as a new read. See the decision comment on issue #49. // as a new read. See the decision comment on issue #49.
const readWindowMinutes = 15 const readWindowMinutes = 15
// burnViewerWindowMinutes returns the admin-tunable per-viewer dedupe window
// (#40), falling back to the 15-minute default from #49.
func burnViewerWindowMinutes() int {
if globalSettingsFn != nil {
if m := globalSettings().BurnViewerWindowMinutes; m > 0 {
return m
}
}
return readWindowMinutes
}
// timeNow is overridable in tests to inject the clock. // timeNow is overridable in tests to inject the clock.
var timeNow = time.Now var timeNow = time.Now
@@ -48,7 +59,7 @@ func (s *Store) registerRead(row *PasteRow, viewerID string) (remaining *int, co
var last sql.NullInt64 var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`, s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
row.ID, viewerID).Scan(&last) row.ID, viewerID).Scan(&last)
if last.Valid && now-last.Int64 < readWindowMinutes*60 { if last.Valid && now-last.Int64 < int64(burnViewerWindowMinutes())*60 {
r := int(row.ReadsLimit.Int64) - row.ReadsUsed r := int(row.ReadsLimit.Int64) - row.ReadsUsed
if r < 0 { if r < 0 {
r = 0 r = 0
+29 -5
View File
@@ -419,8 +419,10 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
} }
type apiServer struct { type apiServer struct {
store *Store store *Store
cfg Config cfg Config
settings *settingsStore
adminKey string
} }
func (a *apiServer) routes() http.Handler { func (a *apiServer) routes() http.Handler {
@@ -429,6 +431,11 @@ func (a *apiServer) routes() http.Handler {
r.Use(middleware.Timeout(30 * time.Second)) r.Use(middleware.Timeout(30 * time.Second))
r.Use(viewerCookieMiddleware) r.Use(viewerCookieMiddleware)
// admin (#40): HTML page is open (key entry via form); API is key-guarded
r.Get("/admin", a.handleAdminPage)
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
// API // API
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Post("/pastes", a.handleCreatePaste) r.Post("/pastes", a.handleCreatePaste)
@@ -521,10 +528,15 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "content is required") writeErr(w, 400, "content is required")
return return
} }
if int64(len(p.Content)) > a.cfg.MaxTextBytes { if int64(len(p.Content)) > a.settings.get().MaxContentBytes { // #40: admin-tunable
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes)) writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.settings.get().MaxContentBytes))
return return
} }
// #40: admin-configurable default expiry
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && a.settings.get().DefaultExpiry != "" {
def := a.settings.get().DefaultExpiry
p.ExpiresIn = &def
}
p.ViewerID = currentViewerID(r) p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p) created, err := a.store.CreatePaste(&p)
if err != nil { if err != nil {
@@ -770,6 +782,11 @@ func templateEsc(s string) string {
} }
func main() { func main() {
// #40: --reset-admin-key regenerates the admin key and exits.
if len(os.Args) > 1 && (os.Args[1] == "--reset-admin-key") {
handleResetAdminKey(envOr("PALETTE_DB", "palette.db"))
return
}
cfg := Config{ cfg := Config{
Addr: envOr("PALETTE_ADDR", ":8080"), Addr: envOr("PALETTE_ADDR", ":8080"),
DBPath: envOr("PALETTE_DB", "palette.db"), DBPath: envOr("PALETTE_DB", "palette.db"),
@@ -782,12 +799,19 @@ func main() {
} }
store.StartSweeper(time.Minute) store.StartSweeper(time.Minute)
adminKey, err := resolveAdminKey(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
ss := loadSettingsStore(cfg.DBPath, cfg)
ui, err := NewWebUI() ui, err := NewWebUI()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
webUIInstance = ui webUIInstance = ui
srv := &apiServer{store: store, cfg: cfg} srv := &apiServer{store: store, cfg: cfg, settings: ss, adminKey: adminKey}
globalSettingsFn = func() Settings { return ss.get() }
log.Printf("palette listening on %s", cfg.Addr) log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes())) log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes()))
} }
+12 -1
View File
@@ -12,11 +12,22 @@ import (
func testServer(t *testing.T) *apiServer { func testServer(t *testing.T) *apiServer {
t.Helper() t.Helper()
globalLimiter = newLimiter() // fresh buckets per test globalLimiter = newLimiter() // fresh buckets per test
if webUIInstance == nil {
ui, err := NewWebUI()
if err != nil {
t.Fatal(err)
}
webUIInstance = ui
}
store, err := OpenStore(":memory:") store, err := OpenStore(":memory:")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}} cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
ss := newTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
return &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
} }
func TestCreateAndGetPaste(t *testing.T) { func TestCreateAndGetPaste(t *testing.T) {
+5 -1
View File
@@ -49,7 +49,11 @@ func TestMineCreateListDelete(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
a := &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024}} cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
ss := newTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
h := a.routes() h := a.routes()
alice := viewerCookieFor(t, h, "/history") alice := viewerCookieFor(t, h, "/history")
+13 -2
View File
@@ -59,9 +59,20 @@ func clientIP(r *http.Request) string {
var globalLimiter = newLimiter() var globalLimiter = newLimiter()
// rateLimitCreate: 1 req/sec refill, burst 5, per IP. // globalSettingsFn is set at startup; tests can point it at fixed settings.
var globalSettingsFn func() Settings
func globalSettings() Settings {
if globalSettingsFn != nil {
return globalSettingsFn()
}
return defaultSettings(Config{})
}
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
func rateLimitCreate(r *http.Request) bool { func rateLimitCreate(r *http.Request) bool {
return globalLimiter.allow("create:"+clientIP(r), 1, 5) s := globalSettings()
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
} }
// rateLimitGuess: 1 req/sec refill, burst 5, per IP. // rateLimitGuess: 1 req/sec refill, burst 5, per IP.
+5 -1
View File
@@ -23,7 +23,11 @@ func newTestServer(t *testing.T) *apiServer {
} }
webUIInstance = ui webUIInstance = ui
} }
return &apiServer{store: store, cfg: Config{MaxTextBytes: 1024 * 1024}} cfg := Config{MaxTextBytes: 1024 * 1024}
ss := newTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
return &apiServer{store: store, cfg: cfg, settings: ss, adminKey: "test-admin-key"}
} }
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder { func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
+107
View File
@@ -0,0 +1,107 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Admin</h1>
</div>
<div class="settings-body">
<p>Enter the admin key to manage server settings. The key is kept in
sessionStorage for this tab only and is sent as a request header — it is
never stored in a cookie, so it will not accompany normal paste requests.</p>
<form id="admin-key-form">
<label for="admin-key">Admin key</label><br>
<input type="password" id="admin-key" autocomplete="off" style="width:100%">
<button type="submit">Unlock</button>
<span id="admin-key-status"></span>
</form>
<div id="admin-panel" style="display:none">
<h2>Settings</h2>
<form id="admin-settings-form">
<table>
<tr><td>Rate-limit burst</td><td><input type="number" id="rl-burst" min="1" step="1"></td></tr>
<tr><td>Rate-limit refill per minute</td><td><input type="number" id="rl-refill" min="0.1" step="0.1"></td></tr>
<tr><td>Max content bytes</td><td><input type="number" id="max-content" min="1" step="1"></td></tr>
<tr><td>Default expiry</td><td><input type="text" id="default-expiry" placeholder="e.g. 168h, 30m, 0 = never"></td></tr>
<tr><td>Custom URL reservation days</td><td><input type="number" id="slug-days" min="1" step="1"></td></tr>
<tr><td>Burn viewer window (minutes)</td><td><input type="number" id="burn-window" min="1" step="1"></td></tr>
</table>
<button type="submit">Save</button>
<span id="admin-save-status"></span>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
if (r.status === 200) {
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
if (key()) loadSettings();
})();
</script>
{{template "foot" .}}