Initial build: stash server + browser extension

Server (Express + better-sqlite3 + FTS5, mirroring keep's stack):
/api/capture (upsert-on-url, per PROPOSAL.md's dedup decision),
/api/articles (list/search/mark-read/delete), plus a minimal
server-rendered reading list and reader view at / and /read/:id
(token passed as ?t= there since browser navigation can't set an
Authorization header). Bearer-token auth generated via `npm run
token` — no login flow, matching keep's one-time identity
registration pattern.

Extension (Manifest V3, sideloaded, no store distribution): toolbar
click or context menu "Send to stash" runs @mozilla/readability
(vendored into extension/lib/) against the current page, falling back
to raw rendered HTML when extraction comes back too thin. Options
page for one-time server URL + token entry.

Verified end-to-end with the real extension loaded in a live Chromium
instance (not just unit-tested extraction logic): capture against a
real page, dedup-on-recapture, mark-read, FTS5 search, 401 on missing
auth, and both server-rendered views all confirmed working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-12 23:23:01 +02:00
commit f2ebfe35d4
19 changed files with 5449 additions and 0 deletions

81
src/server/ui.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Router } from 'express';
import { isValidToken } from './db.js';
import { listArticles, getArticle } from './db.js';
const router = Router();
function escapeHtml(s: unknown): string {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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">
<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; }
</style>
</head><body>${body}</body></html>`;
}
// Browser navigation can't set an Authorization header, so the web UI takes
// the token as a query param instead. The API (used by the extension) keeps
// using the Bearer header — see auth.ts.
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>'));
return;
}
req.token = token;
next();
}
router.get('/', requireTokenQuery, (req: any, res) => {
const unread = req.query.unread === 'true';
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const articles = listArticles({ unread, q: q || undefined, limit: 100 });
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>`).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>
`));
});
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; }
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>
`));
});
export default router;