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

@@ -6,6 +6,8 @@
"scripts": { "scripts": {
"dev:server": "tsx watch --env-file=.env src/server/index.ts", "dev:server": "tsx watch --env-file=.env src/server/index.ts",
"token": "tsx src/server/gen-token.ts", "token": "tsx src/server/gen-token.ts",
"tokens": "tsx src/server/list-tokens.ts",
"revoke-token": "tsx src/server/revoke-token.ts",
"build": "tsc", "build": "tsc",
"start": "node --env-file=.env dist/server/index.js" "start": "node --env-file=.env dist/server/index.js"
}, },

View File

@@ -144,3 +144,29 @@ export function createToken(label: string): string {
); );
return token; 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; const PORT = Number(process.env.PORT) || 3070;
app.use(express.json({ limit: '5mb' })); // article HTML can be sizeable 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(cors()); // the extension calls this from arbitrary page origins
app.use('/api', routes); 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 { Router } from 'express';
import { isValidToken } from './db.js'; import { isValidToken } from './db.js';
import { listArticles, getArticle } from './db.js'; import { listArticles, getArticle, updateArticle, deleteArticle } from './db.js';
const router = Router(); const router = Router();
@@ -9,20 +9,87 @@ function escapeHtml(s: unknown): string {
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .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 { function page(title: string, body: string): string {
return `<!doctype html> return `<!doctype html>
<html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title> <html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <style>
body { max-width: 680px; margin: 40px auto; padding: 0 20px; font-family: system-ui, sans-serif; line-height: 1.6; color: #1a1a1a; } :root {
a { color: #0a5; } --bg: #080808;
.article-list { list-style: none; padding: 0; } --bg-alt: #0e0e0e;
.article-list li { padding: 12px 0; border-bottom: 1px solid #ddd; } --text: #c8c8c8;
.article-list .meta { color: #777; font-size: 13px; } --muted: #555;
article img { max-width: 100%; } --heading: #f0f0f0;
.unread { font-weight: 600; } --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> </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 // 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) { function requireTokenQuery(req: any, res: any, next: any) {
const token = typeof req.query.t === 'string' ? req.query.t : ''; const token = typeof req.query.t === 'string' ? req.query.t : '';
if (!token || !isValidToken(token)) { 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; return;
} }
req.token = token; req.token = token;
@@ -45,37 +112,78 @@ router.get('/', requireTokenQuery, (req: any, res) => {
const t = req.token; const t = req.token;
const items = articles.map(a => ` const items = articles.map(a => `
<li class="${a.read_at ? '' : 'unread'}"> <li class="article-card${a.read_at ? '' : ' unread'}">
<a href="/read/${a.id}?t=${encodeURIComponent(t)}">${escapeHtml(a.title)}</a> <div class="article-title"><a href="/read/${a.id}?t=${encodeURIComponent(t)}">${escapeHtml(a.title)}</a></div>
<div class="meta">${escapeHtml(new URL(a.url).hostname)} — captured ${escapeHtml(a.captured_at.slice(0, 10))}${a.read_at ? '' : ' · unread'}</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(''); </li>`).join('');
res.send(page('stash', ` res.send(page('stash', `
<h1>stash</h1> <div class="header-row"><h1>stash</h1></div>
<form method="get"> <div class="card">
<form method="get" class="search-row">
<input type="hidden" name="t" value="${escapeHtml(t)}"> <input type="hidden" name="t" value="${escapeHtml(t)}">
<input type="text" name="q" value="${escapeHtml(q)}" placeholder="search…"> <input type="text" class="text-input" name="q" value="${escapeHtml(q)}" placeholder="search…">
<label><input type="checkbox" name="unread" value="true" ${unread ? 'checked' : ''} onchange="this.form.submit()"> unread only</label> <label class="checkbox-label"><input type="checkbox" name="unread" value="true" ${unread ? 'checked' : ''} onchange="this.form.submit()"> unread only</label>
<button type="submit">Search</button> <button type="submit" class="btn">search</button>
</form> </form>
<ul class="article-list">${items || '<li>Nothing captured yet.</li>'}</ul> <ul class="article-list">${items || '<li class="empty">Nothing captured yet.</li>'}</ul>
</div>
`)); `));
}); });
router.get('/read/:id', requireTokenQuery, (req: any, res) => { router.get('/read/:id', requireTokenQuery, (req: any, res) => {
const article = getArticle(Number(req.params.id)); let article = getArticle(Number(req.params.id));
if (!article) { res.status(404).send(page('Not found', '<p>Not found.</p>')); return; } 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, ` res.send(page(article.title, `
<p><a href="/?t=${encodeURIComponent(req.token)}">&larr; back</a></p> <div class="header-row"><a class="back" href="/?t=${encodeURIComponent(t)}">&larr; back to stash</a></div>
<div class="card">
<article> <article>
<h1>${escapeHtml(article.title)}</h1> <h1>${escapeHtml(article.title)}</h1>
<p class="meta"> <div class="reader-meta">
<a href="${escapeHtml(article.url)}" target="_blank" rel="noopener">${escapeHtml(article.url)}</a> <a href="${escapeHtml(article.url)}" target="_blank" rel="noopener">${escapeHtml(new URL(article.url).hostname)}</a>
${article.author ? ` ${escapeHtml(article.author)}` : ''} ${article.author ? `<span>${escapeHtml(article.author)}</span>` : ''}
</p> <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.content_html}
</article> </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; export default router;