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