Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s

This commit is contained in:
2026-09-09 01:33:39 -05:00
parent a3349b4a98
commit 4f1e901f04
43 changed files with 1251 additions and 1203 deletions
+71
View File
@@ -0,0 +1,71 @@
package store
import (
"crypto/subtle"
"database/sql"
"encoding/base64"
"time"
)
// genDeletionToken returns a 32-char url-safe random token
func genDeletionToken() string {
b := make([]byte, 24)
cryptoRead(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// TimeNow is overridable in tests to inject the clock.
var TimeNow = time.Now
// registerRead applies the burn-after-read budget for one view (#49).
// For pastes with reads_limit set: the viewer's paste_views row is checked;
// a view within the burn viewer window of the viewer's last view is deduped
// (count=false). Otherwise reads_used is incremented, and the paste is
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
// a cookie (plain API clients) count as their own viewer id "".
// For legacy plain burn_after_read pastes (no reads_limit), any read burns.
// Returns the number of reads remaining (0 when burned), or nil when no
// budget is set. view_count is tracked separately and unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, count bool) {
if !row.ReadsLimit.Valid {
if row.BurnAfterRead {
s.SoftDelete(row.ID)
r := 0
return &r, true
}
return nil, false
}
now := TimeNow().Unix()
var last sql.NullInt64
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
row.ID, viewerID).Scan(&last)
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
r := int(row.ReadsLimit.Int64) - row.ReadsUsed
if r < 0 {
r = 0
}
return &r, 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`,
row.ID, viewerID, now)
used := row.ReadsUsed + 1
s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID)
if int64(used) >= row.ReadsLimit.Int64 {
s.SoftDelete(row.ID)
}
r := int(row.ReadsLimit.Int64) - int(used)
if r < 0 {
r = 0
}
return &r, true
}
// Burned reports whether a read-limited paste has exhausted its budget.
func (row *PasteRow) Burned() bool {
return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64
}
func DeletionTokenEqual(stored, given string) bool {
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"errors"
"regexp"
"strings"
)
var slugRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]{0,63}$`)
// reserved words that would collide with routes
var reservedSlugs = map[string]bool{
"api": true, "raw": true, "can": true, "cans": true, "public": true,
"history": true, "static": true, "assets": true, "favicon.ico": true,
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
}
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
var ErrReservedSlug = errors.New("that slug is reserved")
var ErrSlugTaken = errors.New("that slug is already taken")
func ValidateCustomSlug(slug string) error {
if !slugRE.MatchString(slug) {
return ErrInvalidSlug
}
if reservedSlugs[strings.ToLower(slug)] {
return ErrReservedSlug
}
return nil
}
func (s *Store) SlugTaken(slug string) (bool, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE custom_slug = ? OR id = ?`, slug, slug).Scan(&n)
if err != nil {
return false, err
}
if n > 0 {
return true, nil
}
err = s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans WHERE id = ?`, slug).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
+52
View File
@@ -0,0 +1,52 @@
package store
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// argon2id with sane defaults
const (
argonTime = 1
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
argonSaltLen = 16
)
// Argon2IDHash hashes a password with argon2id.
func Argon2IDHash(pw string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
func CheckPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p)
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// cryptoRead is used by genSlug
func cryptoRead(b []byte) (int, error) { return rand.Read(b) }
+463
View File
@@ -0,0 +1,463 @@
// 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"`
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
}
// 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
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 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
}