Files
palette/internal/api/server.go
T
fen 00aaafeb3c
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
#143: set tok_<id> cookie in create handlers so the created banner can show the token (QA)
2026-09-10 08:37:07 -05:00

650 lines
20 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"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
langpkg "palette/internal/lang"
"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})
}
// writeErrCode emits a JSON error with a machine-readable code (#105): the
// new-page JS maps known codes to plain-language messages, API consumers get
// the stable `code` field alongside the human `error` text.
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code})
}
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
// code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
return "slug_taken"
case errors.Is(err, store.ErrReservedSlug):
return "slug_reserved"
case errors.Is(err, store.ErrInvalidSlug):
return "slug_invalid"
case strings.Contains(err.Error(), "expires_in"):
return "expiry_invalid"
default:
return "invalid_request"
}
}
// 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(a.limitRequestBody) // #68: hard server-side body cap -> 413
r.Use(viewerCookieMiddleware)
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
// 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.Delete("/cans/{id}", a.handleDeleteCan)
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
})
// can page (#4): GET renders, POST unlocks (same flow as pastes)
r.Get("/can/{id}", a.handleCanPage)
r.Post("/can/{id}", a.handleCanPage)
// raw + #38 attachment serving
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
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
}
// #143: hand the deletion token to the creator's browser via a short-lived
// HttpOnly cookie instead of the URL. The paste view reads it once to show
// the one-time created banner; it expires after 60s.
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
http.SetCookie(w, &http.Cookie{
Name: "tok_" + pasteID, Value: token, Path: "/",
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
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
}
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
a.handleCreatePasteMultipart(w, r, s) // #38: file attachment create
return
}
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
return
}
writeErr(w, 400, "invalid json body")
return
}
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
if status == http.StatusRequestEntityTooLarge {
writeErrCode(w, status, "content_too_large", msg)
} else {
writeErrCode(w, status, "content_empty", msg)
}
return
}
// #86: bound free-form metadata at create time
if p.Title != nil {
t, err := checkTitle(*p.Title)
if err != nil {
writeErr(w, 400, err.Error())
return
}
p.Title = &t
}
if p.Language != nil {
l, err := checkLanguage(*p.Language)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if l == "" {
p.Language = nil
} else {
p.Language = &l
}
}
if p.BurnAfterReads != nil { // #68: reject negative read budgets
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
writeErr(w, 400, err.Error())
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 {
writeErrCode(w, 400, createErrCode(err), err.Error())
return
}
setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
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 {
// #81/#141: every password verification (header or empty) goes
// through the same per-IP+paste unlock limiter as the POST form
// path, so brute-force via X-Paste-Password gets 429. The
// ?password= query fallback was removed (#141): query strings
// leak into access logs, browser history, and Referer headers.
if !rateLimitUnlock(row.ID, r) {
writeRateLimited(w, 60)
return
}
// require password via header or query
pw := r.Header.Get("X-Paste-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; #143 removed the ?token= query
// path so the bearer secret never lands in access logs or history), 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).
// The ?token= query parameter is deliberately NOT accepted (#143): URL
// query strings end up in proxy access logs and browser history. 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 ""
}
// 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 := parseLimit(r, 50, 100)
offset := parseOffset(r)
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,
"is_can": row.IsCan,
})
}
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 := parseLimit(r, 25, 100)
offset := parseOffset(r)
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),
"is_can": row.IsCan,
})
}
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, "", 0) // raw views always count (#49/#95)
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
}
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
http.NotFound(w, r)
return
}
// #4: password-protected cans go through the same unlock flow as pastes:
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
// inherit the protection (handleCanItem checks the same cookie).
if can.PasswordHash.Valid {
h := a.webHandlers()
if r.Method == http.MethodPost {
if !rateLimitUnlock(can.ID, r) {
h.WriteRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
a.renderCan(w, can)
return
}
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": true,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
c, err := r.Cookie("pw_" + can.ID)
if err != nil || c.Value != web.UnlockToken(can.ID) {
h.RenderPage(w, "unlock.html", map[string]any{
"Page": "unlock", "ID": can.ID, "Wrong": false,
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
})
return
}
}
a.renderCan(w, can)
}
// renderCan renders the can view page: title/description and items as cards.
// Text items expand inline; files link to download.
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
h := a.webHandlers()
items, err := a.store.ListCanItems(can.ID)
if err != nil {
http.Error(w, "db error", 500)
return
}
type canItem struct {
ID string
Title string
ContentType string
Size string
IsFile bool
Content string
ContentHTML template.HTML
Language string
}
cards := make([]canItem, 0, len(items))
totalSize := 0
for _, it := range items {
totalSize += len(it.Content)
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
ci := canItem{
ID: it.ID,
Title: nullStrOr(it.Title, it.ID),
ContentType: it.ContentType,
Size: web.HumanSize(len(it.Content)),
IsFile: isFile,
Language: it.Language.String,
}
if !isFile {
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
}
cards = append(cards, ci)
}
h.RenderPage(w, "can.html", map[string]any{
"Page": "can",
"ID": can.ID,
"Title": nullStrOr(can.Title, "Untitled can"),
"Description": can.Description.String,
"HasDescription": can.Description.Valid && can.Description.String != "",
"HasPassword": can.PasswordHash.Valid,
"Items": cards,
"ItemCount": len(cards),
"SizeHuman": web.HumanSize(totalSize),
"CreatedAgo": web.AgoString(can.CreatedAt),
"CreatedAtUnix": can.CreatedAt,
"ExpiresAt": can.ExpiresAt.Valid,
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
})
}
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
func expiryStringIfValid(ns sql.NullInt64) string {
if !ns.Valid {
return ""
}
remaining := ns.Int64 - time.Now().Unix()
s := remaining
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
}
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) }