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() }
+168
View File
@@ -0,0 +1,168 @@
package api
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")
}
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"palette/internal/store"
)
// burnViewerWindow returns the admin-tunable per-viewer dedupe window
// (#40), falling back to the 15-minute default from #49.
func (a *apiServer) burnViewerWindow() int {
if a.settings != nil {
if m := a.settings.get().BurnViewerWindowMinutes; m > 0 {
return m
}
}
return 15
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
// DELETE /api/pastes/{id}/redeem?token=...
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
token := r.URL.Query().Get("token")
if token == "" {
writeErr(w, 400, "token required")
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.DeletionToken.String == "" || !store.DeletionTokenEqual(row.DeletionToken.String, token) {
writeErr(w, 403, "invalid token")
return
}
// hard delete: pastes table row goes away entirely
a.store.HardDelete(row.ID)
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestBurnAfterRead(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vanish","burn_after_read":true}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// first read ok
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("first read: %d", rec.Code)
}
// second read gone
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("second read expected 404, got %d", rec.Code)
}
}
func TestDeletionTokenRedeem(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.DeletionToken == "" {
t.Fatal("no deletion token in create response")
}
// wrong token
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token=wrong", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("wrong token: %d", rec.Code)
}
// right token: hard delete
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token="+created.DeletionToken, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("redeem: %d", rec.Code)
}
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
var n int
n = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
if n != 0 {
t.Fatal("row still exists after redeem")
}
}
func TestNonBurnPasteUnaffectedByRead(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"normal"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
for i := 0; i < 3; i++ {
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("read %d: %d", i, rec.Code)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"palette/internal/store"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type anyHandler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// createBurnReads creates a burn-after-N-reads paste and returns its id.
func createBurnReads(t *testing.T, h anyHandler, reads int) string {
t.Helper()
body, _ := json.Marshal(map[string]any{"content": "limited", "burn_after_read": true, "burn_after_reads": reads})
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create burn_after_reads=%d: %d %s", reads, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id in create response")
}
return created.ID
}
func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
if viewer != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: viewer})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// viewer A: ok (read 1)
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1 (viewer A): %d %s", rec.Code, rec.Body.String())
}
// viewer B: ok (read 2)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2 (viewer B): %d %s", rec.Code, rec.Body.String())
}
// viewer C: burned -> 404
if rec := getWithCookie(t, h, id, "ccc"); rec.Code != 404 {
t.Fatalf("read 3 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsSameViewerWithinWindowNoDecrement(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// same viewer reads twice within the window: second is deduped
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("deduped re-read expected 200, got %d", rec.Code)
}
// another viewer still gets read 2 (budget not consumed by re-reads)
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
t.Fatalf("read 2: %d", rec.Code)
}
}
func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
base := time.Now()
store.TimeNow = func() time.Time { return base }
t.Cleanup(func() { store.TimeNow = time.Now })
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
// 10 minutes later: still within window, deduped
store.TimeNow = func() time.Time { return base.Add(10 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read within window: %d", rec.Code)
}
// 20 minutes after first read: window expired, counts as read 2
store.TimeNow = func() time.Time { return base.Add(20 * time.Minute) }
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
}
// budget exhausted -> 404 even for the same viewer
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 404 {
t.Fatalf("after budget expected 404, got %d", rec.Code)
}
}
func TestBurnReadsDefaultOne(t *testing.T) {
s := testServer(t)
h := s.routes()
// burn_after_read without burn_after_reads defaults to 1 read
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"one","burn_after_read":true}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
t.Fatalf("read 1: %d", rec.Code)
}
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 404 {
t.Fatalf("read 2 expected 404, got %d", rec.Code)
}
}
func TestBurnReadsPageViewCounts(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 2)
// HTML page view counts as a read too (documented decision)
req := httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 1: %d", rec.Code)
}
// re-view within window: deduped
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page re-view: %d", rec.Code)
}
// distinct viewer: read 2, page renders with reads remaining
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "bbb"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("page view 2: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Reads left") {
t.Fatal("stats pill missing 'Reads left'")
}
// third viewer: burned
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: "ccc"})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("page view 3 expected 404, got %d", rec.Code)
}
}
+230
View File
@@ -0,0 +1,230 @@
package api
import (
"palette/internal/store"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// CreateCan makes a can with N items (multipart form).
// Fields: title, description, visibility, expires_in, password, files (one or more), or json_items for text items.
func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(a.cfg.MaxItemBytes); err != nil {
writeErr(w, 400, "multipart form required")
return
}
title := r.FormValue("title")
visibility := r.FormValue("visibility")
if visibility == "" {
visibility = "public"
}
if visibility != "public" && visibility != "unlisted" {
writeErr(w, 400, "visibility must be public or unlisted")
return
}
expiresIn := r.FormValue("expires_in")
password := r.FormValue("password")
var expiresAt *int64
now := time.Now().Unix()
if expiresIn != "" {
d, err := time.ParseDuration(expiresIn)
if err != nil {
writeErr(w, 400, "invalid expires_in")
return
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if password != "" {
h, err := store.Argon2IDHash(password)
if err != nil {
writeErr(w, 500, "hash error")
return
}
pwHash = &h
}
canID := store.GenSlug(8)
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
if err != nil {
writeErr(w, 500, "db error")
return
}
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
itemCount := 0
if itemsJSON := r.FormValue("json_items"); itemsJSON != "" {
var items []map[string]string
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
writeErr(w, 400, "invalid json_items")
return
}
for _, it := range items {
content := it["content"]
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("item %q exceeds max", it["title"]))
return
}
lang := it["language"]
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
// uploaded files
if r.MultipartForm != nil {
for _, headers := range r.MultipartForm.File {
for _, fh := range headers {
f, err := fh.Open()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
content, err := io.ReadAll(f)
f.Close()
if err != nil {
writeErr(w, 400, "cannot read uploaded file")
return
}
if int64(len(content)) > a.cfg.MaxItemBytes {
writeErr(w, 413, fmt.Sprintf("file %q exceeds max %d bytes", fh.Filename, a.cfg.MaxItemBytes))
return
}
contentStr := string(content)
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
writeErr(w, 500, "db error")
return
}
itemCount++
}
}
}
if itemCount == 0 {
a.store.DeleteCan(canID)
writeErr(w, 400, "can needs at least one item (files or json_items)")
return
}
writeJSON(w, 201, map[string]any{
"id": canID, "url": "/can/" + canID, "items": itemCount,
})
}
func detectContentType(name string, content []byte) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
}
if len(content) > 8 && content[0] == 0x89 && content[1] == 'P' {
return "image/png"
}
return "text/plain"
}
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if can == nil {
writeErr(w, 404, "can not found")
return
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "can expired")
return
}
if can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
pw = r.URL.Query().Get("password")
}
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
items, err := a.store.ListCanItems(can.ID)
if err != nil {
writeErr(w, 500, "db error")
return
}
type itemMeta struct {
ID string `json:"id"`
Title *string `json:"title"`
ContentType string `json:"content_type"`
Size int `json:"size"`
URL string `json:"url"`
}
metas := make([]itemMeta, 0, len(items))
for _, it := range items {
metas = append(metas, itemMeta{
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
})
}
writeJSON(w, 200, map[string]any{
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
"created_at": can.CreatedAt, "items": metas,
})
}
func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "item")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "item not found")
return
}
// must belong to a can
if !row.CanID.Valid {
writeErr(w, 404, "not a can item")
return
}
// inherit can password protection
can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
pw = r.URL.Query().Get("password")
}
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
// #34: same content-type guard as /raw — never serve active content types.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write([]byte(row.Content))
}
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
)
func multipartBody(t *testing.T, fields map[string]string, fileField, fileName, fileContent string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
w.WriteField(k, v)
}
if fileField != "" {
fw, _ := w.CreateFormFile(fileField, fileName)
fw.Write([]byte(fileContent))
}
w.Close()
return &buf, w.FormDataContentType()
}
func TestCreateAndGetCan(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "My can",
"json_items": `[{"title":"a.txt","content":"AAA"},{"title":"b.txt","content":"BBB"}]`,
"expires_in": "1h",
}, "files", "pic.txt", "file data")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create can: got %d: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Items int `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.Items != 3 {
t.Fatalf("expected 3 items, got %d", created.Items)
}
// get can
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get can: got %d", rec.Code)
}
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
if len(can.Items) != 3 {
t.Fatalf("expected 3 items in get, got %d", len(can.Items))
}
// fetch item
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+can.Items[0].ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item fetch: got %d", rec.Code)
}
}
func TestEmptyCanRejected(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{"title": "empty"}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400 for empty can, got %d", rec.Code)
}
}
func TestCanPasswordInheritedByItems(t *testing.T) {
s := testServer(t)
h := s.routes()
body, ct := multipartBody(t, map[string]string{
"title": "locked",
"password": "pw123",
"json_items": `[{"title":"s.txt","content":"sec"}]`,
}, "", "", "")
req := httptest.NewRequest("POST", "/api/pastes/can", body)
req.Header.Set("Content-Type", ct)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// can without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401, got %d", rec.Code)
}
// get item id with pw
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &can)
itemID := can.Items[0].ID
// item without pw -> 401
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("item expected 401, got %d", rec.Code)
}
// item with pw -> 200
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID+"?password=pw123", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("item expected 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sec") {
t.Fatalf("item content mismatch: %s", rec.Body.String())
}
}
+73
View File
@@ -0,0 +1,73 @@
package api
import (
"palette/internal/store"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestCustomSlugCreateAndFetch(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x","custom_slug":"release-notes"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
// fetch by custom slug
req = httptest.NewRequest("GET", "/api/pastes/release-notes", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("fetch by slug: %d", rec.Code)
}
var got map[string]any
json.Unmarshal(rec.Body.Bytes(), &got)
if got["content"] != "x" {
t.Fatal("content mismatch via custom slug")
}
}
func TestCustomSlugValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
slug, body string
wantCode int
}{
{"dup", `{"content":"first","custom_slug":"dup"}`, 201},
{"dup", `{"content":"second","custom_slug":"dup"}`, 400},
{"api", `{"content":"x","custom_slug":"api"}`, 400},
{"raw", `{"content":"x","custom_slug":"raw"}`, 400},
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
}
for i, c := range cases {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
req.RemoteAddr = "10.7.1." + string(rune('1'+i)) + ":1000" // avoid rate-limit bucket sharing
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
t.Fatalf("slug %q: got %d want %d: %s", c.slug, rec.Code, c.wantCode, rec.Body.String())
}
}
}
func TestSlugCollisionWithAutoID(t *testing.T) {
s := testServer(t)
// manually insert a paste, then try to claim its auto ID as a custom slug
p, err := s.store.CreatePaste(&store.Paste{Content: "auto"})
if err != nil {
t.Fatal(err)
}
if taken, _ := s.store.SlugTaken(p.ID); !taken {
t.Fatal("auto id should be claimed")
}
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"encoding/json"
"net/http"
"palette/internal/lang"
)
// handleGuessLang serves POST /api/guess-language.
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
setRateLimitHeaders(w, 1, 5)
if !rateLimitGuess(r) {
writeRateLimited(w, 1)
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
l := lang.GuessLang(req.Content)
writeJSON(w, http.StatusOK, map[string]any{"language": l})
}
+247
View File
@@ -0,0 +1,247 @@
package api
import (
"palette/internal/store"
"palette/internal/web"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func testServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
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: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
}
func TestCreateAndGetPaste(t *testing.T) {
s := testServer(t)
h := s.routes()
// create
body := `{"content":"hello world","language":"txt"}`
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
if len(created.ID) != 6 {
t.Fatalf("unexpected id: %q", created.ID)
}
// get
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get: got %d", rec.Code)
}
var got map[string]any
json.Unmarshal(rec.Body.Bytes(), &got)
if got["content"] != "hello world" {
t.Fatalf("content mismatch: %v", got["content"])
}
if got["title"] != nil {
t.Fatalf("title should be null, got %v", got["title"])
}
}
func TestPasswordProtection(t *testing.T) {
s := testServer(t)
h := s.routes()
body := `{"content":"secret","password":"hunter2"}`
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// without password -> 401
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401, got %d", rec.Code)
}
// with password -> 200
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=hunter2", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200 with password, got %d", rec.Code)
}
// wrong password -> 401
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=nope", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401 wrong pw, got %d", rec.Code)
}
}
func TestExpiryValidation(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x","expires_in":"notaduration"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestSizeLimit(t *testing.T) {
s := testServer(t)
h := s.routes()
big := strings.Repeat("a", int(s.cfg.MaxTextBytes)+1)
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"`+big+`"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 413 {
t.Fatalf("expected 413, got %d", rec.Code)
}
}
func TestSoftDelete(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("delete: got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("expected 404 after delete, got %d", rec.Code)
}
}
func TestListPublicExcludesUnlisted(t *testing.T) {
s := testServer(t)
h := s.routes()
for _, vis := range []string{"public", "unlisted"} {
body := `{"content":"x","visibility":"` + vis + `"}`
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
}
req := httptest.NewRequest("GET", "/api/public", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var resp struct {
Total int `json:"total"`
Items []map[string]any `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("expected 1 public paste, got %d", resp.Total)
}
}
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
s.store.SoftDelete(created.ID)
// simulate grace elapsed
past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400
s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
s.store.SweepExpired()
var count int
count = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
if count != 0 {
t.Fatal("expected hard delete after grace period")
}
}
func TestSlugCharset(t *testing.T) {
for i := 0; i < 100; i++ {
s := store.GenSlug(6)
for _, c := range s {
if !strings.ContainsRune(store.SlugAlphabet, c) {
t.Fatalf("bad char %q in slug %q", c, s)
}
}
}
}
func TestRawEndpoint(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("raw: got %d", rec.Code)
}
if rec.Body.String() != "raw content here" {
t.Fatalf("raw body mismatch: %q", rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
t.Fatalf("raw content-type: %q", ct)
}
}
func TestNotFound(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/api/pastes/zzzzzz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"palette/internal/store"
"palette/internal/web"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doReq performs a request against the router, carrying the given cookies,
// and returns the recorder (so Set-Cookie from the viewer middleware is visible).
func doReq(t *testing.T, h http.Handler, method, path, cookie string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if cookie != "" {
req.AddCookie(&http.Cookie{Name: "vwr", Value: cookie})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// viewerCookieFor performs a request without the vwr cookie and extracts the
// one the viewer middleware sets in the response.
func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
t.Helper()
rec := doReq(t, h, "GET", path, "", "")
for _, c := range rec.Result().Cookies() {
if c.Name == "vwr" {
return c.Value
}
}
t.Fatal("vwr cookie not set")
return ""
}
func TestMineCreateListDelete(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
st, err := store.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
h := a.routes()
alice := viewerCookieFor(t, h, "/history")
if alice == "" {
t.Fatal("no viewer cookie issued")
}
// create with alice's cookie -> stored viewer id
rec := doReq(t, h, "POST", "/api/pastes", alice, `{"content":"hello mine"}`)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct{ ID string }
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("no id returned")
}
// owner sees it in /api/mine
rec = doReq(t, h, "GET", "/api/mine", alice, "")
if rec.Code != 200 {
t.Fatalf("mine: %d", rec.Code)
}
var list struct {
Total int `json:"total"`
Items []struct{ ID string `json:"id"` } `json:"items"`
}
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != created.ID {
t.Fatalf("mine list: total=%d items=%v", list.Total, list.Items)
}
// a different browser's cookie does NOT see it
bob := viewerCookieFor(t, h, "/history")
rec = doReq(t, h, "GET", "/api/mine", bob, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("other browser sees %d pastes, want 0", list.Total)
}
// delete enforcement: bob cannot delete alice's paste
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, bob, "")
if rec.Code != 403 {
t.Fatalf("bob delete: %d, want 403", rec.Code)
}
// owner can delete
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, alice, "")
if rec.Code != 200 {
t.Fatalf("alice delete: %d", rec.Code)
}
rec = doReq(t, h, "GET", "/api/mine", alice, "")
json.Unmarshal(rec.Body.Bytes(), &list)
if list.Total != 0 {
t.Fatalf("after delete, mine total=%d, want 0", list.Total)
}
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// #34: the unlock cookie must be bound to the paste it unlocks, not a
// forgeable static value. A forged 'pw_<id>=1' cookie must not bypass the
// password check on the paste page.
func TestForgedUnlockCookieDoesNotBypassPassword(t *testing.T) {
globalLimiter = newLimiter()
s := testServer(t)
h := s.routes()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"SECRETPASTECONTENT","password":"hunter2"}`))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d", rec.Code)
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
id := created.ID
// request the page with a forged unlock cookie in the old format
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(&http.Cookie{Name: "pw_" + id, Value: "1"})
h.ServeHTTP(rec, req)
if rec.Code == 200 && strings.Contains(rec.Body.String(), "SECRETPASTECONTENT") {
t.Fatal("forged pw_<id>=1 cookie bypassed password protection")
}
if rec.Code != 200 {
t.Logf("forged-cookie request returned %d (page still locked) — good", rec.Code)
}
}
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"encoding/json"
"testing"
)
// test helpers for pentest tests (#34)
func jsonField(tb testing.TB, body, field string) string {
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
tb.Fatalf("bad json: %v", err)
}
v, _ := m[field].(string)
return v
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
)
// #34: attacker-controlled content_type must not let a paste be served as
// HTML/SVG/XML from /raw (stored XSS). Only a fixed safe set passes through.
func TestRawRejectsHTMLContentType(t *testing.T) {
globalLimiter = newLimiter() // fresh rate-limit buckets
s := testServer(t)
h := s.routes()
for _, ct := range []string{
"text/html", "TEXT/HTML", "text/html;charset=utf-8", "text/html;x=1",
"application/xhtml+xml", "image/svg+xml", "text/html,",
} {
globalLimiter = newLimiter() // burst 5, loop makes 7 creates
body := `{"content":"<script>alert(1)</script>","content_type":"` + ct + `"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("ct %q: create got %d: %s", ct, rec.Code, rec.Body.String())
}
id := jsonField(t, rec.Body.String(), "id")
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/raw/"+id, nil)
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Type"); got == ct {
t.Errorf("ct %q was served verbatim from /raw (stored XSS vector)", ct)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("ct %q: /raw missing X-Content-Type-Options: nosniff", ct)
}
}
}
func TestRawAllowsSafeContentType(t *testing.T) {
globalLimiter = newLimiter()
s := testServer(t)
h := s.routes()
for _, ct := range []string{"text/plain", "image/png", "application/pdf", "application/octet-stream"} {
globalLimiter = newLimiter()
body := `{"content":"hi","content_type":"` + ct + `"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("ct %q: create got %d", ct, rec.Code)
}
id := jsonField(t, rec.Body.String(), "id")
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/raw/"+id, nil)
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Type"); got != ct {
t.Errorf("ct %q: got %q", ct, got)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
type bucket struct {
tokens float64
last time.Time
rate float64 // tokens per second
burst float64
}
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
func newLimiter() *limiter {
return &limiter{buckets: make(map[string]*bucket)}
}
func (l *limiter) allow(key string, rate, burst float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
l.buckets[key] = b
}
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP extracts the request IP (no reverse proxy header by default).
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// 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, s Settings) bool {
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
}
// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
func rateLimitGuess(r *http.Request) bool {
return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
}
// rateLimitUnlock: 5 per minute per IP+paste.
func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
}
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
writeErr(w, 429, "rate limit exceeded")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
}
+235
View File
@@ -0,0 +1,235 @@
package api
import (
"palette/internal/lang"
"palette/internal/store"
"palette/internal/web"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTestServer(t *testing.T) *apiServer {
t.Helper()
globalLimiter = newLimiter() // fresh buckets per test
st, err := store.OpenStore(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
ui, err := web.New()
if err != nil {
t.Fatal(err)
}
cfg := Config{MaxTextBytes: 1024 * 1024}
ss := NewTestSettingsStore(t, cfg)
globalSettingsFn = ss.get
t.Cleanup(func() { globalSettingsFn = nil })
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
}
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestRateLimitCreateBurst: burst of 5 creates allowed, then 429.
func TestRateLimitCreateBurst(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// unique IP per test run so tests don't share buckets
reqIP := "10.9.9.1:1234"
for i := 0; i < 5; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("req %d: want 201, got %d: %s", i, rr.Code, rr.Body.String())
}
}
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("6th req: want 429, got %d", rr.Code)
}
if ra := rr.Header().Get("Retry-After"); ra == "" {
t.Fatal("missing Retry-After header")
}
if ra := rr.Header().Get("X-RateLimit-Limit"); ra == "" {
t.Fatal("missing X-RateLimit-Limit header")
}
}
// TestRateLimitRefill: after waiting >1s a token refills and a create succeeds.
func TestRateLimitRefill(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.2:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
}
time.Sleep(1100 * time.Millisecond)
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("after refill: want 201, got %d", rr.Code)
}
}
// TestRateLimitGuess: guess-language endpoint is limited too.
func TestRateLimitGuess(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.9.9.3:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"def f(): pass"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if i < 5 && rr.Code != 200 {
t.Fatalf("req %d: want 200, got %d", i, rr.Code)
}
}
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"x"}`)))
req.RemoteAddr = reqIP
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("want 429, got %d", rr.Code)
}
}
// TestRateLimitUnlock: 5 unlock attempts per IP+paste per minute, then 429.
func TestRateLimitUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
// create a password-protected paste
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret", "password": "pw1", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
reqIP := "10.9.9.4:1234"
for i := 0; i < 6; i++ {
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if i < 5 && rr2.Code == 429 {
t.Fatalf("req %d: unexpected 429", i)
}
}
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = reqIP
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if rr2.Code != 429 {
t.Fatalf("want 429, got %d", rr2.Code)
}
}
// TestHighlightCode basic expectations.
func TestHighlightCode(t *testing.T) {
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
out := lang.HighlightCode(in, "go")
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
t.Fatalf("no keyword span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`<span class="tok-com">// comment</span>`)) {
t.Fatalf("no comment span: %s", out)
}
if !bytes.Contains([]byte(out), []byte(`tok-str">&#34;hello&#34;</span>`)) {
t.Fatalf("no string span: %s", out)
}
// unsupported language returns escaped plain text
plain := lang.HighlightCode("<b>x</b>", "text")
if plain != "&lt;b&gt;x&lt;/b&gt;" {
t.Fatalf("plain escaping wrong: %q", plain)
}
// line count preserved (gutter alignment)
if got := len(splitLines(lang.HighlightCode("a\nb\nc", "go"))); got != 3 {
t.Fatalf("want 3 lines, got %d", got)
}
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// TestCreatorAutoUnlock: create with password, then POST the password to
// /{id}, then GET /{id} with the cookie shows the paste (#26).
func TestCreatorAutoUnlock(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret stuff", "password": "pw2", "visibility": "unlisted"})
if rr.Code != 201 {
t.Fatalf("create failed: %d", rr.Code)
}
var created map[string]any
json.Unmarshal(rr.Body.Bytes(), &created)
id := created["id"].(string)
// locked GET shows unlock page
req := httptest.NewRequest("GET", "/"+id, nil)
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if bytes.Contains(rr2.Body.Bytes(), []byte("secret stuff")) {
t.Fatal("locked paste leaked content")
}
// unlock POST with ?next= should set cookie and redirect
req = httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=pw2&next=/"+id+"?created=1")))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr3 := httptest.NewRecorder()
h.ServeHTTP(rr3, req)
if rr3.Code != http.StatusSeeOther {
t.Fatalf("unlock POST: want 303, got %d", rr3.Code)
}
var cookie *http.Cookie
for _, c := range rr3.Result().Cookies() {
if c.Name == "pw_"+id {
cookie = c
}
}
if cookie == nil {
t.Fatal("no pw_ cookie set")
}
// GET with cookie shows content
req = httptest.NewRequest("GET", "/"+id, nil)
req.AddCookie(cookie)
rr4 := httptest.NewRecorder()
h.ServeHTTP(rr4, req)
if !bytes.Contains(rr4.Body.Bytes(), []byte("secret stuff")) {
t.Fatalf("cookie unlock failed: %d %s", rr4.Code, rr4.Body.String())
}
}
+421
View File
@@ -0,0 +1,421 @@
// Package api implements palette's REST handlers and the HTTP router:
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"database/sql"
"palette/internal/store"
"palette/internal/web"
)
type Config struct {
Addr string
DBPath string
MaxTextBytes int64
MaxItemBytes int64
}
type apiServer struct {
store *store.Store
cfg Config
ui *web.UI
settings *settingsStore
adminKey string
}
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// Routes returns the HTTP handler for the server.
func (a *apiServer) Routes() http.Handler {
return a.routes()
}
func (a *apiServer) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
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.ui.Handlers().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)
r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Get("/mine", a.handleListMine)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
r.Post("/guess-language", a.handleGuessLang)
r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page
r.Get("/can/{id}", a.handleCanPage)
// raw
r.Get("/raw/{id}", a.handleRaw)
// web pages
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/new", a.ui.Handlers().HandleNewPage)
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
r.Get("/mine", a.ui.Handlers().HandleMinePage)
r.Handle("/static/*", a.ui.StaticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
r.Get("/{id}", a.handlePasteView)
r.Post("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found")
})
return r
}
// viewerCookieMiddleware ensures every request carries an anonymous browser id
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
func viewerCookieMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
id := store.GenSlug(16)
http.SetCookie(w, &http.Cookie{
Name: "vwr", Value: id, Path: "/",
MaxAge: 31536000, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
// remember that this cookie was minted here, not sent by the client
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
}
next.ServeHTTP(w, r)
})
}
type vwrMintedKeyType struct{}
var vwrMintedKey vwrMintedKeyType
func currentViewerID(r *http.Request) string {
if c, err := r.Cookie("vwr"); err == nil {
return c.Value
}
return ""
}
// viewerSentCookie reports whether the client itself sent a vwr cookie
// (as opposed to the middleware minting one for this request).
func viewerSentCookie(r *http.Request) bool {
if _, err := r.Cookie("vwr"); err != nil {
return false
}
_, minted := r.Context().Value(vwrMintedKey).(bool)
return !minted
}
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
s := a.settings.get()
setRateLimitHeaders(w, 1, 5)
if !rateLimitCreate(r, s) {
writeRateLimited(w, 1)
return
}
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeErr(w, 400, "invalid json body")
return
}
if strings.TrimSpace(p.Content) == "" {
writeErr(w, 400, "content is required")
return
}
if int64(len(p.Content)) > s.MaxContentBytes { // #40: admin-tunable
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", s.MaxContentBytes))
return
}
// #40: admin-configurable default expiry
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
def := s.DefaultExpiry
p.ExpiresIn = &def
}
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 201, map[string]any{
"id": created.ID,
"deletion_token": created.DeletionToken,
"url": "/" + created.ID,
"raw_url": "/raw/" + created.ID,
"api_url": "/api/pastes/" + created.ID,
"expires_at": created.ExpiresAt,
"created_at": created.CreatedAt,
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
})
}
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil {
writeErr(w, 500, "db error")
return
}
if row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
writeErr(w, 404, "paste expired")
return
}
if row.Burned() { // #49: read budget exhausted
writeErr(w, 404, "paste not found")
return
}
if row.PasswordHash.Valid {
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
pw = r.URL.Query().Get("password")
}
if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
}
}
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
"view_count": row.ViewCount, "visibility": row.Visibility,
"reads_remaining": rem,
})
}
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
// viewer-cookie delete enforcement (#37): only the browser that created
// the paste (matching vwr) may delete it via this endpoint. Requests with
// no client-sent vwr cookie (plain API clients) are unaffected.
vid := currentViewerID(r)
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid {
writeErr(w, 403, "not your paste")
return
}
if err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error")
return
}
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
}
// handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r)
if vid == "" {
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
rows, total, err := a.store.ListMine(vid, limit, offset)
if err != nil {
writeErr(w, 500, "db error")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 25
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
rows, total, err := a.store.ListPublic(limit, offset)
if err != nil {
writeErr(w, 500, "db error")
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
"custom_slug": store.NullStrPtr(row.CustomSlug),
})
}
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
}
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
http.Error(w, "not found", 404)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
http.Error(w, "password required", 401)
return
}
if row.Burned() { // #49: read budget exhausted
http.Error(w, "not found", 404)
return
}
// #49 decision: raw reads count against the read budget too, with the
// same per-viewer dedupe window as page views.
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
// #34: content_type is attacker-controlled via the create API. Serving it
// verbatim let a paste be stored with text/html (or image/svg+xml) and
// render as active content on this origin when fetched from /raw —
// stored XSS. Only pass through a fixed safe set; anything else is
// served as plain text with nosniff.
ct := row.ContentType
if !safeRawContentType(ct) {
ct = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("X-Content-Type-Options", "nosniff")
a.store.IncrementViews(row.ID)
w.Write([]byte(row.Content))
}
// safeRawContentType reports whether ct is in the fixed set of types that are
// safe to serve verbatim on /raw (no active-content execution contexts).
func safeRawContentType(ct string) bool {
base := ct
if i := strings.IndexByte(ct, ';'); i >= 0 {
base = ct[:i]
}
base = strings.ToLower(strings.TrimSpace(base))
switch base {
case "text/plain", "text/markdown", "text/x-markdown",
"application/json", "application/pdf",
"image/png", "image/jpeg", "image/gif", "image/webp",
"application/octet-stream":
return true
}
return false
}
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
can, err := a.store.GetCan(id)
if err != nil || can == nil {
http.NotFound(w, r)
return
}
items, _ := a.store.ListCanItems(can.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
for _, it := range items {
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
}
fmt.Fprintf(w, "</ul></body></html>")
}
func templateEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
h := a.webHandlers()
// unlock POST rate limiting is wired through h.RateLimitOK
h.HandlePasteView(w, r)
}
func (a *apiServer) webHandlers() *web.Handlers {
return &web.Handlers{
UI: a.ui,
Store: a.store,
ViewerID: currentViewerID,
BurnWindowMin: a.burnViewerWindow,
RateLimitOK: func(id string, r *http.Request) bool { return rateLimitUnlock(id, r) },
}
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func envIntOr(k string, d int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return d
}
func nullStrOr(ns sql.NullString, def string) string {
if ns.Valid {
return ns.String
}
return def
}
// EnvOr returns the env var value or default.
func EnvOr(k, d string) string { return envOr(k, d) }
// EnvIntOr returns the env int value or default.
func EnvIntOr(k string, d int) int { return envIntOr(k, d) }
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"palette/internal/store"
"testing"
"time"
)
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
// created_at/expires_at, bypassing the API's timestamp handling.
func insertPasteWithSlug(t *testing.T, s *store.Store, slug string, createdAt, expiresAt int64) string {
t.Helper()
id := store.GenSlug(6)
if _, err := s.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt); err != nil {
t.Fatal(err)
}
return id
}
func strPtr(s string) *string { return &s }
func TestReleaseSlugOnExpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("release-notes"); taken {
t.Fatal("slug should be released after expiry")
}
// slug must be reusable by a new paste
p, err := s.store.CreatePaste(&store.Paste{Content: "new", CustomSlug: strPtr("release-notes")})
if err != nil {
t.Fatalf("reuse slug: %v", err)
}
if p.CustomSlug == nil || *p.CustomSlug != "release-notes" {
t.Fatal("new paste did not claim released slug")
}
}
func TestReleaseSlugOnOldPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
// created 31 days ago, no expiry -> released by 30-day reservation rule
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
t.Fatalf("released %d err %v, want 1", n, err)
}
if taken, _ := s.store.SlugTaken("old-url"); taken {
t.Fatal("slug should be released after 30-day reservation")
}
}
func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 0 {
t.Fatalf("released %d err %v, want 0", n, err)
}
for _, slug := range []string{"fresh-url", "fresh-url2"} {
if taken, _ := s.store.SlugTaken(slug); !taken {
t.Fatalf("slug %q should still be held", slug)
}
}
}
func TestSweeperTickerReleasesSlugs(t *testing.T) {
s := testServer(t)
now := time.Now().Unix()
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
s.store.StartSweeper(10*time.Millisecond, store.SlugReservationDays)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("ticker did not release slug in time")
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
)
// #33 sweep: the create API must enforce the same expiry window as the UI
// (1 minute .. 1 year). Previously -1h, 0s, 1ns and 30000h were all accepted,
// producing pastes that were born expired or effectively permanent.
func TestCreatePasteExpiryBounds(t *testing.T) {
s := testServer(t)
h := s.routes()
cases := []struct {
expiresIn string
wantCode int
}{
{"-1h", 400},
{"-0s", 400},
{"0s", 400},
{"1ns", 400},
{"59s", 400},
{"1m", 201},
{"90s", 201},
{"8760h", 201}, // exactly 1 year
{"8785h", 400}, // 1 year + 1 day: over the max
{"30000h", 400}, // ~3.4 years, over the max
}
globalLimiter = newLimiter() // one fresh bucket for the whole table
for _, c := range cases {
globalLimiter = newLimiter() // avoid create rate limit between cases
req := httptest.NewRequest("POST", "/api/pastes",
strings.NewReader(`{"content":"x","expires_in":"`+c.expiresIn+`"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
t.Errorf("expires_in %q: got %d want %d (%s)",
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
}
}
}
// #33 sweep: HTML paste views must increment view_count. The increment was
// missing from handlePasteView, so the counter only moved on /raw.
func TestPasteViewIncrementsViewCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
// first render counts (no ?created=1 here: that's the just-created banner case)
req = httptest.NewRequest("GET", "/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("view: got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("api get: got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `"view_count":2`) {
t.Fatalf("expected view_count 2 after two HTML views, got: %s", body)
}
}
// #33 sweep: the just-created banner render (?created=1) must NOT count as a
// view for the creator.
func TestJustCreatedViewDoesNotCount(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
id := jsonField(t, rec.Body.String(), "id")
req = httptest.NewRequest("GET", "/"+id+"?created=1&token=t", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), `"view_count":1`) {
t.Fatalf("just-created render counted as a view: %s", rec.Body.String())
}
}