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:
81
src/server/ui.ts
Normal file
81
src/server/ui.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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)}">← 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;
|
||||
Reference in New Issue
Block a user