Files
palette/internal/api/server.go
T
poslop 91568c0598
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
fix #63: require deletion token on DELETE /api/pastes/{id}
- DELETE now demands the create-time deletion token (Authorization
  header: Bearer/Token/bare, or ?token= query param), compared with
  the constant-time store.DeletionTokenEqual. 403 otherwise.
- Creator-browser deletes via the /mine button (matching vwr cookie,
  #37) remain allowed; other browsers and plain API clients get 403.
- Regression tests: no token, wrong token (header+query), correct
  token (header+query), creator-cookie path, token extraction.
- Adapted TestSoftDelete to pass the deletion token.
- docs/API.md delete section updated.
- Based on #58's SoftDelete (bool, error) signature.
2026-09-09 09:21:28 -05:00

463 lines
14 KiB
Go

// 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
}
}
// #58: only the reader that wins the atomic burn claim may see content.
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
writeErr(w, 404, "paste not found")
return
}
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
}
// #63: deletion requires authorization. Either the deletion token issued
// at create time (Authorization header or ?token= query param, matching
// the create response's "deletion_token" field), or the creator browser
// itself (client-sent vwr cookie matching the paste's viewer, #37).
if !a.deletionAuthorized(r, row) {
writeErr(w, 403, "deletion token required")
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"})
}
// deletionAuthorization extracts the deletion token from the request: the
// Authorization header ("Bearer <t>", "Token <t>", or a bare token) or the
// token query parameter. Returns "" when absent.
func deletionAuthorization(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
for _, prefix := range []string{"Bearer ", "Token "} {
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
return strings.TrimSpace(h[len(prefix):])
}
}
return strings.TrimSpace(h)
}
return r.URL.Query().Get("token")
}
// deletionAuthorized reports whether the request may soft-delete the paste:
// a valid constant-time-matched deletion token, or the creator browser's
// viewer cookie (#37). Plain API clients with no token get false.
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
if tok := deletionAuthorization(r); tok != "" {
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
store.DeletionTokenEqual(row.DeletionToken.String, tok)
}
// 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)
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
row.ViewerID.String != "" && row.ViewerID.String == vid
}
// 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. #58: a reader that loses
// the burn claim must not receive the content.
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
http.Error(w, "not found", 404)
return
}
// #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) }