// 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"` 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 } // #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 } 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 } // 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: 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 // #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, 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 } // ListPublic backs /api/public and the public listing page. Visibility rules // mirror the history page: only non-deleted, non-expired, non-can 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 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 password_hash 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 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 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 } // 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 } 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 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) } // InsertCan creates a paste_can row. func (s *Store) InsertCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64) error { _, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at) VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt) return err } // DeleteCan removes an (empty/aborted) can row. func (s *Store) DeleteCan(canID string) { s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID) } // InsertCanItem adds an item paste belonging to a can. func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error { // language/expiresAt unused here for now; content stored as text (binary-safe in sqlite) _, err := s.db.Exec(`INSERT INTO pastes (id, content, content_type, language, title, visibility, can_id, created_at) VALUES (?,?,?,?,?,?,?,?)`, genSlug(6), content, contentType, language, &title, "unlisted", canID, now) _ = expiresAt _ = binary return err } func (s *Store) GetCan(id string) (*CanRow, error) { row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id) var c CanRow err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt) if err == sql.ErrNoRows { return nil, nil } return &c, err } func (s *Store) ListCanItems(canID string) ([]PasteRow, error) { rows, err := s.db.Query(`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 FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID) if err != nil { return nil, err } defer rows.Close() var out []PasteRow for rows.Next() { var r PasteRow if err := rows.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); err != nil { return nil, err } out = append(out, r) } return out, nil } // 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 }