diff --git a/README.md b/README.md index 25eb22c..a8d9126 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ The SQLite database lives in the `/data` volume inside the container. | `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 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 `/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 diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..9687e5f --- /dev/null +++ b/admin.go @@ -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 /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()) +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..dbbaed3 --- /dev/null +++ b/admin_test.go @@ -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") + } +} diff --git a/burn.go b/burn.go index 9d02ea1..fb8acac 100644 --- a/burn.go +++ b/burn.go @@ -23,6 +23,17 @@ func genDeletionToken() string { // as a new read. See the decision comment on issue #49. 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. var timeNow = time.Now @@ -48,7 +59,7 @@ func (s *Store) registerRead(row *PasteRow, viewerID string) (remaining *int, co var last sql.NullInt64 s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`, 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 if r < 0 { r = 0 diff --git a/main.go b/main.go index bceaffa..9fa5c21 100644 --- a/main.go +++ b/main.go @@ -419,8 +419,10 @@ func writeErr(w http.ResponseWriter, status int, msg string) { } type apiServer struct { - store *Store - cfg Config + store *Store + cfg Config + settings *settingsStore + adminKey string } 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(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 r.Route("/api", func(r chi.Router) { 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") return } - if int64(len(p.Content)) > a.cfg.MaxTextBytes { - writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", 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.settings.get().MaxContentBytes)) 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) created, err := a.store.CreatePaste(&p) if err != nil { @@ -770,6 +782,11 @@ func templateEsc(s string) string { } 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{ Addr: envOr("PALETTE_ADDR", ":8080"), DBPath: envOr("PALETTE_DB", "palette.db"), @@ -782,12 +799,19 @@ func main() { } store.StartSweeper(time.Minute) + adminKey, err := resolveAdminKey(cfg.DBPath) + if err != nil { + log.Fatal(err) + } + ss := loadSettingsStore(cfg.DBPath, cfg) + ui, err := NewWebUI() if err != nil { log.Fatal(err) } 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.Fatal(http.ListenAndServe(cfg.Addr, srv.routes())) } diff --git a/main_test.go b/main_test.go index c91e83c..cec8caa 100644 --- a/main_test.go +++ b/main_test.go @@ -12,11 +12,22 @@ import ( func testServer(t *testing.T) *apiServer { t.Helper() globalLimiter = newLimiter() // fresh buckets per test + if webUIInstance == nil { + ui, err := NewWebUI() + if err != nil { + t.Fatal(err) + } + webUIInstance = ui + } store, err := OpenStore(":memory:") if err != nil { 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) { diff --git a/mine_test.go b/mine_test.go index 538624f..026f0d9 100644 --- a/mine_test.go +++ b/mine_test.go @@ -49,7 +49,11 @@ func TestMineCreateListDelete(t *testing.T) { if err != nil { 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() alice := viewerCookieFor(t, h, "/history") diff --git a/ratelimit.go b/ratelimit.go index e96e603..eaac2e8 100644 --- a/ratelimit.go +++ b/ratelimit.go @@ -59,9 +59,20 @@ func clientIP(r *http.Request) string { 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 { - 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. diff --git a/ratelimit_test.go b/ratelimit_test.go index ca970c5..c3593a1 100644 --- a/ratelimit_test.go +++ b/ratelimit_test.go @@ -23,7 +23,11 @@ func newTestServer(t *testing.T) *apiServer { } 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 { diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..d2eb786 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,107 @@ +{{template "head" .}} +{{template "topbar" .}} +
+
+
+

Admin

+
+
+

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.

+
+
+ + + +
+ +
+
+
+ +{{template "foot" .}}