diff --git a/main.go b/main.go index 3769b34..266e8dd 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ var webFS embed.FS const ( softDeleteGraceDays = 7 + customSlugReservationDays = 30 ) type Config struct { @@ -272,11 +273,34 @@ func (s *Store) SweepExpired() { 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 30 days ago (custom URLs are a reservation, not permanent). +// +// It returns the number of pastes whose custom_slug was released. +func (s *Store) ReleaseCustomSlugs() (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-customSlugReservationDays*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) { go func() { t := time.NewTicker(every) for range t.C { s.SweepExpired() + s.ReleaseCustomSlugs() } }() } diff --git a/palette b/palette index 84137f2..b0cbc51 100755 Binary files a/palette and b/palette differ diff --git a/palette.db-shm b/palette.db-shm index 4e832c3..afd8292 100644 Binary files a/palette.db-shm and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal index 6538049..2fbd44b 100644 Binary files a/palette.db-wal and b/palette.db-wal differ diff --git a/slugrelease_test.go b/slugrelease_test.go new file mode 100644 index 0000000..1172d78 --- /dev/null +++ b/slugrelease_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "testing" + "time" +) + +// insertPasteWithSlug creates a paste directly with a custom slug and controlled +// created_at/expires_at, bypassing the API's timestamp handling. +func insertPasteWithSlug(t *testing.T, s *Store, slug string, createdAt, expiresAt int64) string { + t.Helper() + id := genSlug(6) + _, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt) + if err != nil { + t.Fatal(err) + } + return id +} + +func strPtr(s string) *string { return &s } + +func TestReleaseSlugOnExpiredPaste(t *testing.T) { + s := testServer(t) + now := time.Now().Unix() + insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60) + if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 { + t.Fatalf("released %d err %v, want 1", n, err) + } + if taken, _ := s.store.SlugTaken("release-notes"); taken { + t.Fatal("slug should be released after expiry") + } + // slug must be reusable by a new paste + p, err := s.store.CreatePaste(&Paste{Content: "new", CustomSlug: strPtr("release-notes")}) + if err != nil { + t.Fatalf("reuse slug: %v", err) + } + if p.CustomSlug == nil || *p.CustomSlug != "release-notes" { + t.Fatal("new paste did not claim released slug") + } +} + +func TestReleaseSlugOnOldPaste(t *testing.T) { + s := testServer(t) + now := time.Now().Unix() + // created 31 days ago, no expiry -> released by 30-day reservation rule + insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0) + if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 { + t.Fatalf("released %d err %v, want 1", n, err) + } + if taken, _ := s.store.SlugTaken("old-url"); taken { + t.Fatal("slug should be released after 30-day reservation") + } +} + +func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) { + s := testServer(t) + now := time.Now().Unix() + insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400) + insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0) + if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 0 { + t.Fatalf("released %d err %v, want 0", n, err) + } + for _, slug := range []string{"fresh-url", "fresh-url2"} { + if taken, _ := s.store.SlugTaken(slug); !taken { + t.Fatalf("slug %q should still be held", slug) + } + } +} + +func TestSweeperTickerReleasesSlugs(t *testing.T) { + s := testServer(t) + now := time.Now().Unix() + insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600) + s.store.StartSweeper(10 * time.Millisecond) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if taken, _ := s.store.SlugTaken("ticker-url"); !taken { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("ticker did not release slug in time") +} diff --git a/web/static/app.css b/web/static/app.css index 0a3b0c7..1478e23 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -184,7 +184,7 @@ td a.slug:hover { color: var(--accent); } .center .foot { font-size: 20.7px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); } .btn-icon { - padding: 4px 8px; font-size: 24.2px; line-height: 1; + padding: 4px 8px; font-size: 24.2px; line-height: 1; overflow: visible; display: inline-flex; align-items: center; justify-content: center; min-width: 40px; height: 36px; } @@ -211,3 +211,60 @@ td a.slug:hover { color: var(--accent); } outline-offset: 1px; } .seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; } + +/* toast (#19) */ +.toast { + position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(8px); + background: var(--surface-2); color: var(--fg); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 6px 18px; font-size: 20.7px; + opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200; + box-shadow: 0 4px 16px rgba(0,0,0,.25); +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* protection section rhythm (#20) */ +.protect { display: flex; flex-direction: column; gap: 2px; } +.protect .pw-row { padding: 2px 8px 4px; } +.pw-field { + display: flex; align-items: center; gap: 2px; width: 100%; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); +} +.pw-field:focus-within { border-color: var(--accent); } +.pw-field input { + flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--fg); + font: inherit; font-size: 21.6px; padding: 7px 12px; letter-spacing: .08em; +} +.pw-field input::placeholder { color: var(--muted); letter-spacing: normal; } +.pw-field .reveal { + background: none; border: none; color: var(--muted-fg); cursor: pointer; + display: flex; align-items: center; justify-content: center; padding: 0 10px; height: 100%; + flex-shrink: 0; +} +.pw-field .reveal:hover { color: var(--fg); } +.pw-field svg { width: 20px; height: 20px; display: block; } + +/* custom URL input (#22) */ +.deck .row input[type="text"] { width: 100%; } +.custom-input { + display: block; width: 100%; + border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 12px; + background: var(--bg); color: var(--fg); font: inherit; font-size: 21.6px; outline: none; +} +.custom-input:focus { border-color: var(--accent); } +.custom-input::placeholder { color: var(--muted); } + +/* btn-icon svg (#24) */ +.btn-icon svg { width: 20px; height: 20px; display: block; } + +/* search spinner (#32) */ +.search-spinner { + width: 16px; height: 16px; flex-shrink: 0; + border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; + animation: spin .8s linear infinite; visibility: hidden; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* unlock redesign (#27) */ +.unlock-card .pw-field { margin: 18px 0 4px; text-align: left; } +.unlock-card .pw-field input { text-align: left; } +.unlock-err { margin-top: 10px; font-size: 20.7px; color: #ff8fa3; } diff --git a/web/templates/history.html b/web/templates/history.html index c0aaf33..2061d77 100644 --- a/web/templates/history.html +++ b/web/templates/history.html @@ -5,7 +5,7 @@