package main import ( "context" "database/sql" "embed" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" _ "modernc.org/sqlite" ) //go:embed web/templates/* web/static/* var webFS embed.FS const ( softDeleteGraceDays = 7 customSlugReservationDays = 30 ) type Config struct { Addr string DBPath string MaxTextBytes int64 MaxItemBytes int64 } type Paste struct { ID string `json:"id"` CustomSlug *string `json:"custom_slug,omitempty"` Content string `json:"content"` ContentType string `json:"content_type"` Language *string `json:"language,omitempty"` Title *string `json:"title,omitempty"` Password *string `json:"password,omitempty"` ExpiresIn *string `json:"expires_in,omitempty"` BurnAfterRead bool `json:"burn_after_read,omitempty"` BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1) Visibility string `json:"visibility"` CanID *string `json:"can_id,omitempty"` CreatedAt int64 `json:"created_at"` DeletedAt *int64 `json:"deleted_at,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` ViewerID string `json:"-"` // set from vwr cookie server-side (#37) readsLimit *int64 // #49: resolved read budget, not serialized ViewCount int `json:"view_count"` DeletionToken string `json:"-"` } type PasteRow struct { ID string CustomSlug sql.NullString Content string ContentType string Language sql.NullString Title sql.NullString PasswordHash sql.NullString ExpiresAt sql.NullInt64 BurnAfterRead bool ReadsLimit sql.NullInt64 ReadsUsed int Visibility string CanID sql.NullString CreatedAt int64 DeletedAt sql.NullInt64 ViewCount int Size int DeletionToken sql.NullString ViewerID sql.NullString } type CanRow struct { ID string Title sql.NullString Visibility string PasswordHash sql.NullString CreatedAt int64 DeletedAt sql.NullInt64 ExpiresAt sql.NullInt64 } type Store struct { db *sql.DB } func OpenStore(path string) (*Store, error) { db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, err } s := &Store{db: db} if err := s.migrate(); err != nil { return nil, err } return s, nil } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS pastes ( id TEXT PRIMARY KEY, custom_slug TEXT UNIQUE, content TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'text/plain', language TEXT, title TEXT, password_hash TEXT, expires_at INTEGER, burn_after_read INTEGER DEFAULT 0, visibility TEXT NOT NULL DEFAULT 'public', can_id TEXT, created_at INTEGER NOT NULL, deleted_at INTEGER, view_count INTEGER NOT NULL DEFAULT 0, deletion_token TEXT ); CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL; CREATE TABLE IF NOT EXISTS paste_cans ( id TEXT PRIMARY KEY, title TEXT, description TEXT, visibility TEXT NOT NULL DEFAULT 'public', password_hash TEXT, created_at INTEGER NOT NULL, deleted_at INTEGER, expires_at INTEGER ); `) s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37) s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49) s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49) s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views ( paste_id TEXT NOT NULL, viewer_id TEXT NOT NULL, last_viewed INTEGER NOT NULL, PRIMARY KEY (paste_id, viewer_id) )`) // #49: per-viewer read dedupe window return err } var slugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" var httpClient = &http.Client{} func genSlug(n int) string { b := make([]byte, n) _, _ = cryptorandRead(b) for i := range b { b[i] = slugAlphabet[int(b[i])%len(slugAlphabet)] } return string(b) } // cryptorandRead wraps crypto/rand func cryptorandRead(b []byte) (int, error) { return cryptoRead(b) } func (s *Store) CreatePaste(p *Paste) (*Paste, error) { id := genSlug(6) now := time.Now().Unix() var expiresAt *int64 if p.ExpiresIn != nil && *p.ExpiresIn != "" { d, err := time.ParseDuration(*p.ExpiresIn) if err != nil { return nil, fmt.Errorf("invalid expires_in: %w", err) } t := now + int64(d.Seconds()) expiresAt = &t } var pwHash *string if p.Password != nil && *p.Password != "" { h, err := hashPassword(*p.Password) if err != nil { return nil, err } pwHash = &h } if p.CustomSlug != nil && *p.CustomSlug != "" { slug := *p.CustomSlug if err := ValidateCustomSlug(slug); err != nil { return nil, err } taken, err := s.SlugTaken(slug) if err != nil { return nil, err } if taken { return nil, errSlugTaken } } // #49: burn-after-read pastes carry a read budget (default 1 read) if p.BurnAfterRead { limit := int64(1) if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 { limit = int64(*p.BurnAfterReads) } p.readsLimit = &limit } visibility := p.Visibility if visibility == "" { visibility = "public" } if visibility != "public" && visibility != "unlisted" { return nil, errors.New("visibility must be public or unlisted") } contentType := p.ContentType if contentType == "" { contentType = "text/plain" } var slugVal *string if p.CustomSlug != nil && *p.CustomSlug != "" { slugVal = p.CustomSlug } p.DeletionToken = genDeletionToken() _, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id, reads_limit) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID, p.readsLimit) if err != nil { return nil, err } p.ID = id p.CreatedAt = now p.ExpiresAt = expiresAt p.Visibility = visibility return p, nil } func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0) FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug) var r PasteRow err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed) if err == sql.ErrNoRows { return nil, nil } return &r, err } func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var out []PasteRow for rows.Next() { var r PasteRow var cs, lang, title sql.NullString if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { return nil, 0, err } r.CustomSlug = cs r.Language = lang r.Title = title out = append(out, r) } var total int s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total) return out, total, nil } // ListMine lists pastes created from the given viewer id (browser cookie), newest first. func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) { rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var out []PasteRow for rows.Next() { var r PasteRow var cs, lang, title sql.NullString if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { return nil, 0, err } r.CustomSlug, r.Language, r.Title = cs, lang, title out = append(out, r) } var total int s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, viewerID, time.Now().Unix()).Scan(&total) return out, total, nil } // MineOwner returns the stored viewer_id for a paste, or "" if none. func (s *Store) MineOwner(id string) (string, error) { var vid sql.NullString err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid) if err == sql.ErrNoRows { return "", nil } if err != nil { return "", err } if !vid.Valid { return "", nil } return vid.String, nil } func (s *Store) SoftDelete(id string) error { _, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id) return err } func (s *Store) IncrementViews(id string) { s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id) } // SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace. func (s *Store) SweepExpired() { now := time.Now().Unix() s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now) grace := now - softDeleteGraceDays*86400 s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace) } // ReleaseCustomSlugs frees custom URLs so they can be reused: // - pastes whose expires_at has passed (expired or soft-deleted/expired), // - pastes created more than 30 days ago (custom URLs are a reservation, not permanent). // // It returns the number of pastes whose custom_slug was released. func (s *Store) ReleaseCustomSlugs() (int64, error) { now := time.Now().Unix() res, err := s.db.Exec(`UPDATE pastes SET custom_slug = NULL WHERE custom_slug IS NOT NULL AND (expires_at IS NOT NULL AND expires_at > 0 AND expires_at < ? OR created_at < ?)`, now, now-customSlugReservationDays*86400) if err != nil { return 0, err } n, _ := res.RowsAffected() if n > 0 { log.Printf("released %d custom slug(s)", n) } return n, nil } func (s *Store) StartSweeper(every time.Duration) { go func() { t := time.NewTicker(every) for range t.C { s.SweepExpired() s.ReleaseCustomSlugs() } }() } func hashPassword(pw string) (string, error) { // argon2id return argon2idHash(pw) } func boolToInt(b bool) int { if b { return 1 } return 0 } func nullStrPtr(ns sql.NullString) *string { if ns.Valid { return &ns.String } return nil } 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}) } type apiServer struct { store *Store cfg Config } func (a *apiServer) routes() http.Handler { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) r.Use(viewerCookieMiddleware) // 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.handleNewPage) r.Get("/history", a.handleHistoryPage) r.Get("/settings", a.handleSettingsPage) r.Get("/mine", a.handleMinePage) r.Handle("/static/*", 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 := 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) { setRateLimitHeaders(w, 1, 5) if !rateLimitCreate(r) { writeRateLimited(w, 1) return } var p 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)) > a.cfg.MaxTextBytes { writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes)) return } 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 == "" || !checkPassword(row.PasswordHash.String, pw) { writeErr(w, 401, "password required") return } } nullPtr := func(ns sql.NullString) *string { if ns.Valid { return &ns.String } return nil } rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn) writeJSON(w, 200, map[string]any{ "id": row.ID, "content": row.Content, "content_type": row.ContentType, "language": nullPtr(row.Language), "title": nullPtr(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 := nullStrPtr(row.Language), 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": 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 := nullStrPtr(row.Language), 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": 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 15-minute dedupe window as page views. a.store.registerRead(row, currentViewerID(r)) // #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, "
%s", row.ID, templateEsc(row.Content)) } func templateEsc(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">") return r.Replace(s) } func main() { cfg := Config{ Addr: envOr("PALETTE_ADDR", ":8080"), DBPath: envOr("PALETTE_DB", "palette.db"), MaxTextBytes: int64(envIntOr("PALETTE_MAX_TEXT", 5*1024*1024)), MaxItemBytes: int64(envIntOr("PALETTE_MAX_ITEM", 25*1024*1024)), } store, err := OpenStore(cfg.DBPath) if err != nil { log.Fatal(err) } store.StartSweeper(time.Minute) ui, err := NewWebUI() if err != nil { log.Fatal(err) } webUIInstance = ui srv := &apiServer{store: store, cfg: cfg} log.Printf("palette listening on %s", cfg.Addr) log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes())) } 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 }