Admin endpoint: ENV/file admin key, --reset-admin-key, settings API wired into ratelimit/max-bytes/default-expiry/burn-window (#40)
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
+12
-1
@@ -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) {
|
||||
|
||||
+5
-1
@@ -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")
|
||||
|
||||
+13
-2
@@ -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.
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
@@ -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" .}}
|
||||
Reference in New Issue
Block a user