197 lines
6.0 KiB
Go
197 lines
6.0 KiB
Go
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())
|
|
}
|