diff --git a/package.json b/package.json index 2aaf5ba..77a0b20 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "dev:server": "tsx watch --env-file=.env src/server/index.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", "start": "node --env-file=.env dist/server/index.js" }, diff --git a/src/server/db.ts b/src/server/db.ts index a89afc5..501d580 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -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; +} diff --git a/src/server/index.ts b/src/server/index.ts index a74c40b..98a0a4f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); diff --git a/src/server/list-tokens.ts b/src/server/list-tokens.ts new file mode 100644 index 0000000..d1f6980 --- /dev/null +++ b/src/server/list-tokens.ts @@ -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}`); + } +} diff --git a/src/server/revoke-token.ts b/src/server/revoke-token.ts new file mode 100644 index 0000000..0a84d53 --- /dev/null +++ b/src/server/revoke-token.ts @@ -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 -- '); + 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}".`); +} diff --git a/src/server/ui.ts b/src/server/ui.ts index 2fe4dee..911c150 100644 --- a/src/server/ui.ts +++ b/src/server/ui.ts @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); } +// 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 ` ${escapeHtml(title)} + + + -${body}`; +
${body}
`; } // 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', '

Missing or invalid ?t= token.

')); + res.status(401).send(page('Unauthorized', '

stash

Missing or invalid ?t= token.

')); return; } req.token = token; @@ -45,37 +112,78 @@ router.get('/', requireTokenQuery, (req: any, res) => { const t = req.token; const items = articles.map(a => ` -
  • - ${escapeHtml(a.title)} -
    ${escapeHtml(new URL(a.url).hostname)} — captured ${escapeHtml(a.captured_at.slice(0, 10))}${a.read_at ? '' : ' · unread'}
    +
  • +
    ${escapeHtml(a.title)}
    +
  • `).join(''); res.send(page('stash', ` -

    stash

    -
    - - - - -
    - +

    stash

    +
    +
    + + + + +
    + +
    `)); }); router.get('/read/:id', requireTokenQuery, (req: any, res) => { - const article = getArticle(Number(req.params.id)); - if (!article) { res.status(404).send(page('Not found', '

    Not found.

    ')); return; } + let article = getArticle(Number(req.params.id)); + if (!article) { res.status(404).send(page('Not found', '

    stash

    Not found.

    ')); return; } + if (!article.read_at) { + article = updateArticle(article.id, { read_at: new Date().toISOString() }) ?? article; + } + const t = req.token; res.send(page(article.title, ` -

    ← back

    -
    -

    ${escapeHtml(article.title)}

    -

    - ${escapeHtml(article.url)} - ${article.author ? ` — ${escapeHtml(article.author)}` : ''} -

    - ${article.content_html} -
    +
    ← back to stash
    +
    +
    +

    ${escapeHtml(article.title)}

    +
    + ${escapeHtml(new URL(article.url).hostname)} + ${article.author ? `— ${escapeHtml(article.author)}` : ''} +
    + + +
    +
    + + +
    +
    + ${article.content_html} +
    +
    `)); }); +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', '

    stash

    Not found.

    ')); 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;