|
|
|
@@ -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
|
|
|
|
|
}
|