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.
This commit is contained in:
1 parent
651bc0cb86
commit
e54fbc36ef
20 files changed
+176
-1356
No files matched your search
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cans feature removal (2026-10-16): opening a store on a pre-removal
|
||||
// database must drop the paste_cans table (only when empty) and the
|
||||
// pastes.can_id column. A non-empty paste_cans table is left in place.
|
||||
func TestMigrateDropsCansSchema(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
legacy := func(name string, canRows int) string {
|
||||
db, err := sql.Open("sqlite", filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open raw db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
stmts := []string{
|
||||
`CREATE TABLE 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 TABLE 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
|
||||
)`,
|
||||
`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`,
|
||||
`INSERT INTO pastes (id, content, content_type, visibility, can_id, created_at)
|
||||
VALUES ('p1', 'x', 'text/plain', 'public', NULL, 1)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatalf("seed %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < canRows; i++ {
|
||||
if _, err := db.Exec(`INSERT INTO paste_cans (id, created_at) VALUES (?, 1)`, string(rune('a'+i))); err != nil {
|
||||
t.Fatalf("seed can row: %v", err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
|
||||
intCol := func(s *Store, table, col string) int {
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, col).Scan(&n)
|
||||
return n
|
||||
}
|
||||
tableExists := func(s *Store, table string) bool {
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// Empty paste_cans: table and column both dropped.
|
||||
s1, err := OpenStore(legacy("empty.db", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s1.db.Close()
|
||||
if tableExists(s1, "paste_cans") {
|
||||
t.Error("paste_cans still exists after migrate (was empty; should be dropped)")
|
||||
}
|
||||
if intCol(s1, "pastes", "can_id") != 0 {
|
||||
t.Error("pastes.can_id still exists after migrate; should be dropped")
|
||||
}
|
||||
// The store still works end to end.
|
||||
if _, err := s1.CreatePaste(&Paste{Content: "hello", Visibility: "public"}); err != nil {
|
||||
t.Fatalf("create paste on migrated db: %v", err)
|
||||
}
|
||||
if got := s1.QueryInt(`SELECT COUNT(*) FROM pastes`); got != 2 {
|
||||
t.Errorf("pastes count = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Non-empty paste_cans: table kept, can_id still dropped.
|
||||
s2, err := OpenStore(legacy("full.db", 2))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s2.db.Close()
|
||||
if !tableExists(s2, "paste_cans") {
|
||||
t.Error("non-empty paste_cans was dropped; must be kept until cleaned")
|
||||
}
|
||||
if intCol(s2, "pastes", "can_id") != 0 {
|
||||
t.Error("pastes.can_id still exists after migrate; should be dropped")
|
||||
}
|
||||
|
||||
// Fresh database: neither the table nor the column is ever created.
|
||||
s3, err := OpenStore(filepath.Join(dir, "fresh.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open fresh: %v", err)
|
||||
}
|
||||
defer s3.db.Close()
|
||||
if tableExists(s3, "paste_cans") {
|
||||
t.Error("fresh db created paste_cans")
|
||||
}
|
||||
if intCol(s3, "pastes", "can_id") != 0 {
|
||||
t.Error("fresh db created pastes.can_id")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user