// 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" "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}) } // 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 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 } // #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 { 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 { // #81: every password verification (header, query param, or empty) // goes through the same per-IP+paste unlock limiter as the POST form // path, so brute-force via GET ?password= or X-Paste-Password gets 429. if !rateLimitUnlock(row.ID, r) { writeRateLimited(w, 60) return } // 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 ", "Token ", 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 := 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_ 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("&", "&", "<", "<", ">", ">") 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) }