sweeper: release custom URLs on expiry and after 30-day reservation (#29)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
}
|
||||
+58
-1
@@ -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; }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1>Public pastes</h1>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
<div class="search"><input id="filter" placeholder="Search…"></div>
|
||||
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
|
||||
<div class="float">
|
||||
<table>
|
||||
<colgroup><col style="width:220px"><col><col style="width:156px"><col style="width:138px"><col style="width:111px"><col style="width:165px"></colgroup>
|
||||
@@ -43,6 +43,9 @@ function matchesFilter(it) {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const spinner = document.getElementById('search-spinner');
|
||||
spinner.style.visibility = 'visible';
|
||||
try {
|
||||
const filtered = filter.length > 0;
|
||||
const off = (page - 1) * PER;
|
||||
const url = filtered
|
||||
@@ -95,6 +98,9 @@ async function load() {
|
||||
add('›', page+1, {dis: page===pages});
|
||||
$('pg').innerHTML = btns.join('');
|
||||
}
|
||||
} finally {
|
||||
spinner.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
$('pg').addEventListener('click', e => {
|
||||
|
||||
+35
-10
@@ -4,7 +4,7 @@
|
||||
<div class="pane-l-col">
|
||||
<div class="float pane-l-head">
|
||||
<div class="editor-head">
|
||||
<input id="title" placeholder="Title (optional)">
|
||||
<input id="title" placeholder="Title">
|
||||
<select id="language">
|
||||
<option value="">auto</option>
|
||||
<option>go</option><option>python</option><option>javascript</option>
|
||||
@@ -12,7 +12,7 @@
|
||||
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
|
||||
<option>markdown</option><option>text</option>
|
||||
</select>
|
||||
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button">⟳</button>
|
||||
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float editor-wrap">
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="actionbar">
|
||||
<span class="hint">Ctrl+Enter to create</span>
|
||||
<div class="spacer" style="flex:1"></div>
|
||||
<button class="btn" id="create">Create ⇧</button>
|
||||
<button class="btn" id="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,14 +40,17 @@
|
||||
</div>
|
||||
<div class="float side-section">
|
||||
<h3>Protection</h3>
|
||||
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
|
||||
<input type="password" id="password" class="pwinput" placeholder="Password" style="display:none; margin: 6px 8px 0; width: auto;">
|
||||
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
|
||||
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
|
||||
<div class="protect">
|
||||
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
|
||||
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button></div></div>
|
||||
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
|
||||
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float side-section">
|
||||
<h3>Custom URL</h3>
|
||||
<div class="row" style="justify-content:flex-start; gap:6px; align-items:center;"><span>/</span><input type="text" id="custom" placeholder="my-snippet" style="text-align:left; flex:1; min-width:0;"></div>
|
||||
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
|
||||
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
|
||||
</div>
|
||||
<div class="float side-section" id="result-card" style="display:none">
|
||||
<h3>Result</h3>
|
||||
@@ -68,7 +71,21 @@ function updateGutter() {
|
||||
content.addEventListener('input', updateGutter);
|
||||
updateGutter();
|
||||
|
||||
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; });
|
||||
function toast(msg) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(t._h);
|
||||
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||
}
|
||||
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
|
||||
$('pwreveal').addEventListener('click', () => {
|
||||
const pw = $('password');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
$('pwreveal').title = show ? 'Hide password' : 'Show password';
|
||||
});
|
||||
|
||||
let guessed = ''; // last auto-detected language, '' = user override
|
||||
|
||||
@@ -143,11 +160,19 @@ async function create() {
|
||||
const url = location.origin + '/' + (data.custom_slug || data.id);
|
||||
showResult('<a href="' + url + '">' + url + '</a>', false);
|
||||
$('result').dataset.token = data.deletion_token || '';
|
||||
try { navigator.clipboard.writeText(url); } catch(e) {}
|
||||
try { navigator.clipboard.writeText(url); toast('Copied'); } catch(e) {}
|
||||
// show the paste
|
||||
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
|
||||
}
|
||||
$('create').addEventListener('click', create);
|
||||
// reset stale result state when returning via Back (bfcache) (#28)
|
||||
window.addEventListener('pageshow', e => {
|
||||
if (!e.persisted) return;
|
||||
const rc = document.getElementById('result-card');
|
||||
if (rc) rc.style.display = 'none';
|
||||
const r = document.getElementById('result');
|
||||
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
||||
});
|
||||
|
||||
@@ -33,8 +33,17 @@
|
||||
</div>
|
||||
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
||||
<script>
|
||||
function toast(msg) {
|
||||
let t = document.querySelector('.toast');
|
||||
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(t._h);
|
||||
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||
}
|
||||
function copyContent() {
|
||||
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
||||
toast('Copied');
|
||||
}
|
||||
function redeem(token) {
|
||||
if (!confirm('Hard delete this paste immediately?')) return;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="center">
|
||||
<div class="float">
|
||||
<div class="float unlock-card">
|
||||
<div class="inner">
|
||||
<div class="lockring">🔒</div>
|
||||
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
|
||||
<h1>This paste is locked</h1>
|
||||
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
|
||||
<form method="post" action="/unlock/{{.ID}}">
|
||||
<input type="password" name="password" class="pwinput" placeholder="••••••••" autofocus>
|
||||
{{if .Wrong}}<p class="err" style="display:block">Wrong password. Try again.</p>{{end}}
|
||||
<form method="post" action="">
|
||||
<div class="pw-field">
|
||||
<input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
|
||||
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
|
||||
<button class="btn" type="submit">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="foot">Created {{.CreatedAgo}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('pwreveal').addEventListener('click', () => {
|
||||
const pw = document.getElementById('password');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
|
||||
});
|
||||
</script>
|
||||
{{template "foot" .}}
|
||||
|
||||
Reference in New Issue
Block a user