Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s

This commit is contained in:
2026-09-09 01:33:39 -05:00
parent a3349b4a98
commit 4f1e901f04
43 changed files with 1251 additions and 1203 deletions
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"palette/internal/store"
"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: 30,
BurnViewerWindowMinutes: 15,
}
}
// 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
}
// LoadSettingsStore loads (or initializes) the settings store.
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 || !store.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).
// ResetAdminKeyFile deletes the persisted admin key file (if any).
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.
// 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.
// 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) 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())
}
// Get returns the current settings (exported for cmd wiring).
func (ss *settingsStore) Get() Settings { return ss.get() }