Restyle UI to match flit/wisp theming, add delete + token revoke/list

UI: ported flit/wisp's dark, JetBrains-Mono-accented theme wholesale
(same CSS vars, same card/button/input vocabulary) so stash reads as
part of the same family of tools instead of a bare unstyled list. List
view is now card-based with unread indicators; reader view got proper
article typography.

Delete: the list and reader views now have a delete button (with a
confirm prompt) wired to the existing DELETE /api/articles/:id via a
new POST /delete/:id UI route, since browser forms can't issue DELETE
directly. express.urlencoded() added to parse the form posts this and
the existing mark-read buttons need.

Token management: added listTokens/revokeToken to db.ts, plus
`npm run tokens` (lists label/created_at/last-4-chars only — never
re-prints a full secret) and `npm run revoke-token -- <label>`. This
was a real, previously-missing gap: there was no way to invalidate a
single compromised token short of wiping the entire database. Built
after a token got printed in plaintext during a debugging session and
there was no way to kill just that one credential.

Verified: full theme renders correctly (screenshotted list + reader
views), revoke-token correctly 401s the targeted token while leaving
others untouched (tested against a real leaked-then-revoked token),
list-tokens never displays a recoverable secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-13 19:00:36 +02:00
parent 264a9e1168
commit 4a6a373f1e
6 changed files with 201 additions and 32 deletions

View File

@@ -144,3 +144,29 @@ export function createToken(label: string): string {
);
return token;
}
export interface TokenSummary {
label: string;
created_at: string;
// Last 4 chars only — enough to disambiguate two tokens with the same
// label without ever re-printing a full, still-valid secret to a
// terminal (see the "printed test token in chat" incident this exists
// to prevent a repeat of).
suffix: string;
}
export function listTokens(): TokenSummary[] {
const rows = db.prepare('SELECT token, label, created_at FROM tokens ORDER BY created_at').all() as
{ token: string; label: string; created_at: string }[];
return rows.map(r => ({ label: r.label, created_at: r.created_at, suffix: r.token.slice(-4) }));
}
// Revoke by label (deletes ALL tokens with that label — labels aren't
// unique, so if you generated the same label twice, this clears every
// one of them) or by the exact token value if you still have it.
export function revokeToken(labelOrToken: string): number {
const byToken = db.prepare('DELETE FROM tokens WHERE token = ?').run(labelOrToken);
if (byToken.changes > 0) return byToken.changes;
const byLabel = db.prepare('DELETE FROM tokens WHERE label = ?').run(labelOrToken);
return byLabel.changes;
}

View File

@@ -7,6 +7,7 @@ const app = express();
const PORT = Number(process.env.PORT) || 3070;
app.use(express.json({ limit: '5mb' })); // article HTML can be sizeable
app.use(express.urlencoded({ extended: true })); // for the web UI's mark-read forms
app.use(cors()); // the extension calls this from arbitrary page origins
app.use('/api', routes);

14
src/server/list-tokens.ts Normal file
View File

@@ -0,0 +1,14 @@
// Lists tokens without ever re-printing the full secret — only the label,
// creation date, and last 4 characters, enough to identify which one to
// pass to revoke-token.ts.
import { listTokens } from './db.js';
const tokens = listTokens();
if (tokens.length === 0) {
console.log('No tokens registered.');
} else {
console.log('label created_at suffix');
for (const t of tokens) {
console.log(`${t.label.padEnd(16)} ${t.created_at.padEnd(25)}${t.suffix}`);
}
}

View File

@@ -0,0 +1,18 @@
// Revoke a token by label (revokes every token with that label) or by its
// exact value, if you still have it. This is the piece that was missing —
// previously a leaked/compromised token could only be invalidated by
// wiping the entire database.
import { revokeToken } from './db.js';
const target = process.argv[2];
if (!target) {
console.error('usage: npm run revoke-token -- <label-or-token>');
process.exit(1);
}
const count = revokeToken(target);
if (count === 0) {
console.log(`No token matched "${target}". Run "npm run tokens" to see what's registered.`);
} else {
console.log(`Revoked ${count} token(s) matching "${target}".`);
}

View File

@@ -1,6 +1,6 @@
import { Router } from 'express';
import { isValidToken } from './db.js';
import { listArticles, getArticle } from './db.js';
import { listArticles, getArticle, updateArticle, deleteArticle } from './db.js';
const router = Router();
@@ -9,20 +9,87 @@ function escapeHtml(s: unknown): string {
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Same dark, monospace-accented theme as flit/wisp — same CSS vars, same
// card/button/input vocabulary — so stash reads as part of the same family
// of tools rather than a bare database inspector.
function page(title: string, body: string): string {
return `<!doctype html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
body { max-width: 680px; margin: 40px auto; padding: 0 20px; font-family: system-ui, sans-serif; line-height: 1.6; color: #1a1a1a; }
a { color: #0a5; }
.article-list { list-style: none; padding: 0; }
.article-list li { padding: 12px 0; border-bottom: 1px solid #ddd; }
.article-list .meta { color: #777; font-size: 13px; }
article img { max-width: 100%; }
.unread { font-weight: 600; }
:root {
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app { width: 640px; max-width: 100%; margin: 0 auto; padding: 32px 16px 64px; }
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 28px; }
.header-row h1 { font-size: 24px; font-weight: 700; letter-spacing: -0.5px; margin: 0; }
.header-row h1::before { content: '> '; color: var(--muted); font-weight: 400; }
.header-row a.back { font-family: var(--mono); font-size: 13px; color: var(--muted); }
.header-row a.back:hover { color: var(--accent); }
.card { border: 1px solid var(--border); border-radius: 10px; padding: 20px; background: var(--bg-alt); box-shadow: var(--shadow); }
.stack { display: flex; flex-direction: column; gap: 12px; }
.search-row { display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap; }
.text-input { font-family: var(--mono); font-size: 13px; padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--heading); flex: 1; min-width: 160px; }
.text-input::placeholder { color: var(--muted); }
.text-input:focus { outline: none; border-color: var(--accent-border); }
.btn { font-family: var(--mono); font-size: 12px; font-weight: 600; padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; transition: all 0.15s; letter-spacing: 0.01em; white-space: nowrap; }
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
.btn:active { transform: scale(0.98); }
.btn-danger { color: #ff6b6b; }
.btn-danger:hover { border-color: rgba(255,107,107,0.4); color: #ff8f8f; }
.checkbox-label { font-family: var(--mono); font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.article-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.article-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; background: var(--bg); transition: border-color 0.15s; }
.article-card:hover { border-color: var(--accent-border); }
.article-card.unread { border-left: 2px solid var(--accent); }
.article-title { font-size: 15px; font-weight: 600; color: var(--heading); }
.article-title:hover { color: var(--accent); }
.article-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 6px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.unread-dot { color: var(--accent); }
.article-card form { display: inline; margin-left: auto; }
.empty { font-family: var(--mono); font-size: 13px; color: var(--muted); text-align: center; padding: 32px 0; }
article { font-size: 16px; line-height: 1.7; color: var(--text); }
article h1, article h2, article h3 { font-family: var(--sans); color: var(--heading); margin-top: 1.2em; }
article p { margin: 1em 0; }
article img { max-width: 100%; border-radius: 6px; }
article a { text-decoration: underline; }
.reader-meta { font-family: var(--mono); font-size: 12px; color: var(--muted); margin: 8px 0 24px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
</style>
</head><body>${body}</body></html>`;
</head><body><div id="app">${body}</div></body></html>`;
}
// Browser navigation can't set an Authorization header, so the web UI takes
@@ -31,7 +98,7 @@ function page(title: string, body: string): string {
function requireTokenQuery(req: any, res: any, next: any) {
const token = typeof req.query.t === 'string' ? req.query.t : '';
if (!token || !isValidToken(token)) {
res.status(401).send(page('Unauthorized', '<p>Missing or invalid <code>?t=</code> token.</p>'));
res.status(401).send(page('Unauthorized', '<h1>stash</h1><p class="empty">Missing or invalid <code>?t=</code> token.</p>'));
return;
}
req.token = token;
@@ -45,37 +112,78 @@ router.get('/', requireTokenQuery, (req: any, res) => {
const t = req.token;
const items = articles.map(a => `
<li class="${a.read_at ? '' : 'unread'}">
<a href="/read/${a.id}?t=${encodeURIComponent(t)}">${escapeHtml(a.title)}</a>
<div class="meta">${escapeHtml(new URL(a.url).hostname)} — captured ${escapeHtml(a.captured_at.slice(0, 10))}${a.read_at ? '' : ' · unread'}</div>
<li class="article-card${a.read_at ? '' : ' unread'}">
<div class="article-title"><a href="/read/${a.id}?t=${encodeURIComponent(t)}">${escapeHtml(a.title)}</a></div>
<div class="article-meta">
<span>${escapeHtml(new URL(a.url).hostname)}</span>
<span>captured ${escapeHtml(a.captured_at.slice(0, 10))}</span>
${a.read_at ? '' : '<span class="unread-dot">● unread</span>'}
<form method="post" action="/toggle-read/${a.id}?t=${encodeURIComponent(t)}">
<input type="hidden" name="redirect" value="${escapeHtml(req.originalUrl)}">
<button type="submit" class="btn">${a.read_at ? 'mark unread' : 'mark read'}</button>
</form>
<form method="post" action="/delete/${a.id}?t=${encodeURIComponent(t)}" onsubmit="return confirm('Delete this article?');">
<button type="submit" class="btn btn-danger">delete</button>
</form>
</div>
</li>`).join('');
res.send(page('stash', `
<h1>stash</h1>
<form method="get">
<input type="hidden" name="t" value="${escapeHtml(t)}">
<input type="text" name="q" value="${escapeHtml(q)}" placeholder="search…">
<label><input type="checkbox" name="unread" value="true" ${unread ? 'checked' : ''} onchange="this.form.submit()"> unread only</label>
<button type="submit">Search</button>
</form>
<ul class="article-list">${items || '<li>Nothing captured yet.</li>'}</ul>
<div class="header-row"><h1>stash</h1></div>
<div class="card">
<form method="get" class="search-row">
<input type="hidden" name="t" value="${escapeHtml(t)}">
<input type="text" class="text-input" name="q" value="${escapeHtml(q)}" placeholder="search…">
<label class="checkbox-label"><input type="checkbox" name="unread" value="true" ${unread ? 'checked' : ''} onchange="this.form.submit()"> unread only</label>
<button type="submit" class="btn">search</button>
</form>
<ul class="article-list">${items || '<li class="empty">Nothing captured yet.</li>'}</ul>
</div>
`));
});
router.get('/read/:id', requireTokenQuery, (req: any, res) => {
const article = getArticle(Number(req.params.id));
if (!article) { res.status(404).send(page('Not found', '<p>Not found.</p>')); return; }
let article = getArticle(Number(req.params.id));
if (!article) { res.status(404).send(page('Not found', '<h1>stash</h1><p class="empty">Not found.</p>')); return; }
if (!article.read_at) {
article = updateArticle(article.id, { read_at: new Date().toISOString() }) ?? article;
}
const t = req.token;
res.send(page(article.title, `
<p><a href="/?t=${encodeURIComponent(req.token)}">&larr; back</a></p>
<article>
<h1>${escapeHtml(article.title)}</h1>
<p class="meta">
<a href="${escapeHtml(article.url)}" target="_blank" rel="noopener">${escapeHtml(article.url)}</a>
${article.author ? `${escapeHtml(article.author)}` : ''}
</p>
${article.content_html}
</article>
<div class="header-row"><a class="back" href="/?t=${encodeURIComponent(t)}">&larr; back to stash</a></div>
<div class="card">
<article>
<h1>${escapeHtml(article.title)}</h1>
<div class="reader-meta">
<a href="${escapeHtml(article.url)}" target="_blank" rel="noopener">${escapeHtml(new URL(article.url).hostname)}</a>
${article.author ? `<span>— ${escapeHtml(article.author)}</span>` : ''}
<form method="post" action="/toggle-read/${article.id}?t=${encodeURIComponent(t)}">
<input type="hidden" name="redirect" value="${escapeHtml(req.originalUrl)}">
<button type="submit" class="btn">${article.read_at ? 'mark unread' : 'mark read'}</button>
</form>
<form method="post" action="/delete/${article.id}?t=${encodeURIComponent(t)}" onsubmit="return confirm('Delete this article?');">
<input type="hidden" name="redirect" value="/?t=${escapeHtml(t)}">
<button type="submit" class="btn btn-danger">delete</button>
</form>
</div>
${article.content_html}
</article>
</div>
`));
});
router.post('/toggle-read/:id', requireTokenQuery, (req: any, res) => {
const article = getArticle(Number(req.params.id));
if (!article) { res.status(404).send(page('Not found', '<h1>stash</h1><p class="empty">Not found.</p>')); return; }
updateArticle(article.id, { read_at: article.read_at ? null : new Date().toISOString() });
const redirect = typeof req.body?.redirect === 'string' ? req.body.redirect : `/?t=${encodeURIComponent(req.token)}`;
res.redirect(redirect);
});
router.post('/delete/:id', requireTokenQuery, (req: any, res) => {
deleteArticle(Number(req.params.id));
const redirect = typeof req.body?.redirect === 'string' ? req.body.redirect : `/?t=${encodeURIComponent(req.token)}`;
res.redirect(redirect);
});
export default router;