// 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(a.limitRequestBody) // #68: hard server-side body cap -> 413 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 { if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader writeBodyTooLarge(w) return } writeErr(w, 400, "invalid json body") return } if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 { writeErr(w, status, msg) return } 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 { 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 := 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, }) } 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), }) } 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, "can/%s — palette

can/%s

") } 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) }