Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
This commit is contained in:
@@ -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() }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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">"hello"</span>`)) {
|
||||
t.Fatalf("no string span: %s", out)
|
||||
}
|
||||
// unsupported language returns escaped plain text
|
||||
plain := lang.HighlightCode("<b>x</b>", "text")
|
||||
if plain != "<b>x</b>" {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">")
|
||||
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) }
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user