Remove cans feature; harden pw_/tok_ cookie flags (pre-release fixes) (#307)
This commit was merged in pull request #307.
This commit is contained in:
1 parent
651bc0cb86
commit
c02550050f
20 files changed
+176
-1367
No files matched your search
+38
-146
@@ -32,7 +32,6 @@ type Paste struct {
|
||||
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"`
|
||||
@@ -55,29 +54,15 @@ type PasteRow struct {
|
||||
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
|
||||
IsCan bool // set on list rows that are cans (#4)
|
||||
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
|
||||
}
|
||||
|
||||
type CanRow struct {
|
||||
ID string
|
||||
Title sql.NullString
|
||||
Visibility string
|
||||
PasswordHash sql.NullString
|
||||
CreatedAt int64
|
||||
DeletedAt sql.NullInt64
|
||||
ExpiresAt sql.NullInt64
|
||||
Description sql.NullString
|
||||
ViewerID sql.NullString
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
|
||||
@@ -119,7 +104,6 @@ func (s *Store) migrate() error {
|
||||
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,
|
||||
@@ -128,22 +112,32 @@ 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(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
|
||||
// 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,
|
||||
@@ -290,10 +284,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
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.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
|
||||
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
|
||||
}
|
||||
@@ -301,19 +295,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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), 0, COALESCE(a.filename, '')
|
||||
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.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
UNION ALL
|
||||
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||
FROM paste_cans
|
||||
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
|
||||
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
|
||||
}
|
||||
@@ -322,34 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||
for rows.Next() {
|
||||
var r PasteRow
|
||||
var cs, lang, title sql.NullString
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||
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
|
||||
r.IsCan = isCan == 1
|
||||
out = append(out, r)
|
||||
}
|
||||
var total int
|
||||
s.db.QueryRow(`SELECT (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 > ?))
|
||||
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||
time.Now().Unix(), time.Now().Unix()).Scan(&total)
|
||||
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), 0, COALESCE(a.filename, '')
|
||||
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.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
UNION ALL
|
||||
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||
FROM paste_cans
|
||||
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
|
||||
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
|
||||
}
|
||||
@@ -358,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
|
||||
for rows.Next() {
|
||||
var r PasteRow
|
||||
var cs, lang, title sql.NullString
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||
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
|
||||
r.IsCan = isCan == 1
|
||||
out = append(out, r)
|
||||
}
|
||||
var total int
|
||||
s.db.QueryRow(`SELECT (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 > ?))
|
||||
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -432,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
|
||||
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)
|
||||
// #4: cans expire too — mirror paste behavior
|
||||
s.db.Exec(`UPDATE paste_cans 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)
|
||||
}
|
||||
@@ -490,92 +468,6 @@ func (s *Store) HardDelete(id string) {
|
||||
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
// CreateCan inserts a paste_can row with optional custom slug (parity with
|
||||
// pastes: validated by the same rules, checked against both tables).
|
||||
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
|
||||
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
|
||||
if customSlug != nil && *customSlug != "" {
|
||||
slug := *customSlug
|
||||
if err := ValidateCustomSlug(slug); err != nil {
|
||||
return err
|
||||
}
|
||||
taken, err := s.SlugTaken(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return ErrSlugTaken
|
||||
}
|
||||
canID = slug
|
||||
}
|
||||
if visibility == "" {
|
||||
visibility = "public"
|
||||
}
|
||||
if visibility != "public" && visibility != "unlisted" {
|
||||
return errors.New("visibility must be public or unlisted")
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
|
||||
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
|
||||
// hidden from listings by can_id and disappear with the can's page).
|
||||
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, 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, description, viewer_id
|
||||
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, &c.Description, &c.ViewerID)
|
||||
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) }
|
||||
|
||||
|
||||
Reference in new issue
Block a user