Files
palette/internal/store/store.go
T
fen e54fbc36ef
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
Remove cans feature; add Secure flag to pw_/tok_ cookies
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was
half-integrated and has zero prod usage; removing it eliminates four audit
findings by deletion.

Removed:
- internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*,
  /can/{id}) plus handleCanPage/renderCan
- is_can from /api/mine and /api/public responses
- store layer: CanRow, can store funcs, paste_cans UNION in listings,
  cans sweep; reserved slugs 'can'/'cans' released
- templates/can.html, can CSS, is_can badges in history.js/mine.js
- cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go,
  TestErrorCodeCanValidation)
- docs/api.md cans section, README/LICENSE/docker-compose mentions

Store migration: drops paste_cans only when empty (non-empty table is
kept and logged; prod verified clean 2026-10-16) and drops the dormant
pastes.can_id column. Covered by new TestMigrateDropsCansSchema.

Security hardening (audit findings): pw_<id> (unlock) and tok_<id>
(deletion-token) cookies now carry Secure: true in addition to
HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test
now asserts Secure.

go vet ./... and go test ./... pass.
2026-10-16 16:50:40 -05:00

490 lines
17 KiB
Go

// Package store provides the SQLite persistence layer for palette: schema
// migrations, the Store type and all queries, and the background sweeper.
package store
import (
"database/sql"
"errors"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
// SlugReservationDays is the default custom-URL reservation window (admin-tunable via settings, #40).
const SlugReservationDays = 30
// SoftDeleteGraceDays is how long soft-deleted pastes linger before hard delete.
const SoftDeleteGraceDays = 7
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"`
// #83: accept "public": true/false as an alias for visibility.
Public *bool `json:"public,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
CreatedAt int64
DeletedAt sql.NullInt64
ViewCount int
Size int
DeletionToken sql.NullString
ViewerID sql.NullString
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
}
type Store struct {
db *sql.DB
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
}
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
}
// #58: a single write connection. SQLite allows only one writer at a
// time; with multiple pooled connections concurrent writes surface as
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
// the burn-after-read race tests saw spurious 500s under parallel reads.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, err
}
// #38: attachment blobs live beside the database under <db>.files
blobs, err := NewFsBlobStore(path + ".files")
if err != nil {
return nil, err
}
s.blob = blobs
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',
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;
`)
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)
// Cans feature removal (owner decision 2026-10-16): drop the leftover
// schema. paste_cans is only dropped when empty so no data is silently
// destroyed; a non-empty table is left in place and logged. The dormant
// pastes.can_id column is dropped too (prod verified clean 2026-10-16:
// zero rows referenced a can).
var tbl int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='paste_cans'`).Scan(&tbl)
if tbl > 0 {
var rows int
s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans`).Scan(&rows)
if rows == 0 {
s.db.Exec(`DROP TABLE paste_cans`)
} else {
log.Printf("store: paste_cans still holds %d row(s); leaving the table in place (cans feature removed)", rows)
}
}
var col int
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pastes') WHERE name='can_id'`).Scan(&col)
if col > 0 {
s.db.Exec(`ALTER TABLE pastes DROP COLUMN can_id`)
}
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
// #38: file attachments, one per paste in iteration 1. mime is sniffed
// server-side before insert; bytes live in the BlobStore keyed
// <paste_id>/<sha256>.
s.db.Exec(`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
paste_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
created_at INTEGER NOT NULL
)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_attachments_paste ON attachments(paste_id)`)
return err
}
// Blobs returns the attachment blob store (nil when unavailable, e.g. some
// unit-test stores).
func (s *Store) Blobs() BlobStore { return s.blob }
// SlugAlphabet is the paste-id charset (no ambiguous chars).
var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
// genSlug generates a random slug of length n.
func genSlug(n int) string {
b := make([]byte, n)
_, _ = cryptoRead(b)
for i := range b {
b[i] = SlugAlphabet[int(b[i])%len(SlugAlphabet)]
}
return string(b)
}
// validExpiry reports whether an expires_in duration is in the accepted
// window. The UI restricts presets to 1 minute - 1 year (#48); the API must
// enforce the same bounds, otherwise negative/zero/absurd durations create
// pastes that are born expired (or effectively permanent).
const (
minExpiry = time.Minute
maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom)
)
func ValidExpiry(d time.Duration) bool {
return d >= minExpiry && d <= maxExpiry
}
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)
}
if !ValidExpiry(d) {
return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year")
}
t := now + int64(d.Seconds())
expiresAt = &t
}
var pwHash *string
if p.Password != nil && *p.Password != "" {
h, err := Argon2IDHash(*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/#82: burn-after-read pastes carry a read budget (default 1 read).
// burn_after_reads > 0 alone implies burn mode even without burn_after_read.
if p.BurnAfterRead || (p.BurnAfterReads != nil && *p.BurnAfterReads > 0) {
limit := int64(1)
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
limit = int64(*p.BurnAfterReads)
}
p.readsLimit = &limit
if !p.BurnAfterRead {
p.BurnAfterRead = true
}
}
visibility := p.Visibility
// #83: "public": false -> unlisted, true -> public; overrides string field
if p.Public != nil {
if *p.Public {
visibility = "public"
} else {
visibility = "unlisted"
}
}
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, 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.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
if err == sql.ErrNoRows {
return nil, nil
}
return &r, err
}
// ListPublic backs /api/public and the public listing page. Visibility rules
// mirror the history page: only non-deleted, non-expired pastes are listed,
// and password-protected pastes are excluded at the query level (#65) so
// their metadata (title, slug, existence) never leaks.
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.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, &r.AttFilename); 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 password_hash 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 p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
FROM pastes p
LEFT JOIN attachments a ON a.paste_id = p.id
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
ORDER BY p.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, &r.AttFilename); 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 (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
}
// SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
// IS NULL guard means only the first caller flips the row. Returns true when
// this call performed the delete (RowsAffected > 0), false when the paste was
// already deleted - callers use this to decide read admission atomically.
func (s *Store) SoftDelete(id string) (bool, error) {
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// IncrementViews counts one view. With a viewerID (#95): views are deduped
// per-viewer within burnViewerWindow minutes using the paste_views table,
// namespaced with a "views/" viewer prefix so these rows never collide with
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
// Raw views always count (#49 decision) — call with viewerID="" for those.
// Returns true when the view was counted.
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
if viewerID == "" {
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
now := TimeNow().Unix()
vkey := "views/" + viewerID
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
id, vkey).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
return false
}
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
id, vkey, now)
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
return true
}
// 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 reservationDays days ago (custom URLs are a
// reservation, not permanent).
//
// It returns the number of pastes whose custom_slug was released.
func (s *Store) ReleaseCustomSlugs(reservationDays int) (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-int64(reservationDays)*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, reservationDays int) {
go func() {
t := time.NewTicker(every)
for range t.C {
s.SweepExpired()
s.ReleaseCustomSlugs(reservationDays)
}
}()
}
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
}
// HardDelete removes a paste row entirely (deletion-token redeem).
func (s *Store) HardDelete(id string) {
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
}
// GenSlug is the exported slug generator.
func GenSlug(n int) string { return genSlug(n) }
// Exec runs a raw statement (test helper).
func (s *Store) Exec(query string, args ...any) (int64, error) {
res, err := s.db.Exec(query, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// QueryInt runs a query returning a single integer (test helper).
func (s *Store) QueryInt(query string, args ...any) int {
var n int
s.db.QueryRow(query, args...).Scan(&n)
return n
}