Cans UI + parity: unlock-cookie flow, /can page, listings badge, custom slug, delete, sweeper; #32 perf notes (#4, #32)
This commit is contained in:
+92
-16
@@ -60,6 +60,7 @@ type PasteRow struct {
|
||||
Size int
|
||||
DeletionToken sql.NullString
|
||||
ViewerID sql.NullString
|
||||
IsCan bool // set on list rows that are cans (#4)
|
||||
}
|
||||
|
||||
type CanRow struct {
|
||||
@@ -70,6 +71,8 @@ type CanRow struct {
|
||||
CreatedAt int64
|
||||
DeletedAt sql.NullInt64
|
||||
ExpiresAt sql.NullInt64
|
||||
Description sql.NullString
|
||||
ViewerID sql.NullString
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -130,6 +133,7 @@ deletion_token TEXT
|
||||
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)
|
||||
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
|
||||
paste_id TEXT NOT NULL,
|
||||
viewer_id TEXT NOT NULL,
|
||||
@@ -258,9 +262,14 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||
}
|
||||
|
||||
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
|
||||
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content), 0
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -269,25 +278,33 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||
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 {
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); 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 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)
|
||||
s.db.QueryRow(`SELECT (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 > ?))
|
||||
+ (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)
|
||||
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)
|
||||
rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content), 0
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -296,16 +313,18 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
|
||||
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 {
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); 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 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)
|
||||
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)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
@@ -338,14 +357,38 @@ func (s *Store) SoftDelete(id string) (bool, error) {
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) IncrementViews(id string) {
|
||||
// IncrementViews counts one view. With a viewerID (#95): views are deduped
|
||||
// per-viewer within burnViewerWindow minutes using the paste_views table,
|
||||
// namespaced with a "views/" viewer prefix so these rows never collide with
|
||||
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
|
||||
// Raw views always count (#49 decision) — call with viewerID="" for those.
|
||||
// Returns true when the view was counted.
|
||||
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
|
||||
if viewerID == "" {
|
||||
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
||||
return true
|
||||
}
|
||||
now := TimeNow().Unix()
|
||||
vkey := "views/" + viewerID
|
||||
var last sql.NullInt64
|
||||
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
|
||||
id, vkey).Scan(&last)
|
||||
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
|
||||
return 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`,
|
||||
id, vkey, now)
|
||||
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
||||
return true
|
||||
}
|
||||
|
||||
// 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)
|
||||
// #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)
|
||||
}
|
||||
@@ -402,13 +445,46 @@ 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 {
|
||||
// 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)
|
||||
@@ -427,10 +503,10 @@ func (s *Store) InsertCanItem(canID, title, content, contentType string, languag
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user