My pastes page /mine with anonymous viewer cookie (#37)
CI / test (push) Successful in 21s
CI / docker (push) Skipped

- vwr cookie middleware: random browser id set on first visit (reused by #49)
- pastes table gains viewer_id column, set server-side at creation from the cookie
- GET /api/mine lists pastes for the requesting browser (title/lang/size/created)
- DELETE enforcement: 403 when client-sent vwr doesn't match the paste's viewer_id
- /mine page reuses history table styling, delete buttons, empty state
- nav: 'Saved' item between Public and Git; Git gets external-link arrow (#56)
- tests: create-with-cookie appears in /mine, other cookie doesn't, delete enforcement

Closes #37
This commit is contained in:
2026-09-08 22:10:05 -05:00
parent e83303a428
commit 129934b645
6 changed files with 339 additions and 6 deletions
+3
View File
@@ -379,6 +379,9 @@ th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
.settings-head { padding: 12px 18px; }
.settings-body { padding: 16px 18px; }
/* topbar: Git external-link arrow (#56) */
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
@media (max-width: 640px) {
body { font-size: 16px; }
+2 -1
View File
@@ -17,7 +17,8 @@
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
</nav>
<div class="spacer"></div>
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
+89
View File
@@ -0,0 +1,89 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th>Paste</th>
<th>Language</th>
<th>Size</th>
<th>Created</th>
<th>URL</th>
<th>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function fmtSize(n) { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }
function ago(ts) {
const s = Math.floor(Date.now()/1000) - ts;
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
}
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
async function load() {
const res = await fetch('/api/mine');
const data = await res.json();
$('count').textContent = data.total.toLocaleString() + ' total';
const rows = $('rows');
if (!data.items.length) {
rows.innerHTML = '';
$('empty').style.display = 'block';
return;
}
$('empty').style.display = 'none';
rows.innerHTML = data.items.map(it =>
`<tr class="row" data-href="/${esc(it.id)}"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a>` +
(it.title ? `<div class="paste-sub">${esc(it.title)}</div>` : `<div class="paste-sub dim">none</div>`) + `</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${esc(it.custom_slug)}">/${esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`
).join('');
}
$('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (del) {
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
return;
}
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a')) return;
window.location.href = tr.dataset.href;
});
load();
</script>
{{template "foot" .}}