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

12
src/server/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Request, Response, NextFunction } from 'express';
import { isValidToken } from './db.js';
export function requireToken(req: Request, res: Response, next: NextFunction) {
const header = req.header('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token || !isValidToken(token)) {
res.status(401).json({ error: 'missing or invalid token' });
return;
}
next();
}

146
src/server/db.ts Normal file
View File

@@ -0,0 +1,146 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
const DATA_DIR = process.env.DATA_DIR ?? './data';
mkdirSync(DATA_DIR, { recursive: true });
export const db = new Database(`${DATA_DIR}/stash.db`);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content_html TEXT NOT NULL,
content_text TEXT NOT NULL,
author TEXT,
published_at TEXT,
captured_at TEXT NOT NULL,
read_at TEXT,
tags TEXT NOT NULL DEFAULT ''
);
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, content_text, content='articles', content_rowid='id'
);
-- Keep the FTS index in sync with articles — triggers instead of doing it
-- in application code so no write path can accidentally skip it.
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, content_text) VALUES (new.id, new.title, new.content_text);
END;
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content_text) VALUES ('delete', old.id, old.title, old.content_text);
END;
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content_text) VALUES ('delete', old.id, old.title, old.content_text);
INSERT INTO articles_fts(rowid, title, content_text) VALUES (new.id, new.title, new.content_text);
END;
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
export interface Article {
id: number;
url: string;
title: string;
content_html: string;
content_text: string;
author: string | null;
published_at: string | null;
captured_at: string;
read_at: string | null;
tags: string;
}
export interface CaptureInput {
url: string;
title: string;
content_html: string;
content_text: string;
author?: string;
published_at?: string;
}
// Upsert on url — re-capturing the same page refreshes the stored copy
// rather than versioning it. See PROPOSAL.md "Open questions" for why.
export function upsertArticle(input: CaptureInput): Article {
const now = new Date().toISOString();
db.prepare(`
INSERT INTO articles (url, title, content_html, content_text, author, published_at, captured_at)
VALUES (@url, @title, @content_html, @content_text, @author, @published_at, @captured_at)
ON CONFLICT(url) DO UPDATE SET
title = excluded.title,
content_html = excluded.content_html,
content_text = excluded.content_text,
author = excluded.author,
published_at = excluded.published_at,
captured_at = excluded.captured_at
`).run({
url: input.url,
title: input.title,
content_html: input.content_html,
content_text: input.content_text,
author: input.author ?? null,
published_at: input.published_at ?? null,
captured_at: now,
});
return db.prepare('SELECT * FROM articles WHERE url = ?').get(input.url) as Article;
}
export function listArticles(opts: { unread?: boolean; q?: string; limit: number }): Article[] {
if (opts.q) {
return db.prepare(`
SELECT articles.* FROM articles_fts
JOIN articles ON articles.id = articles_fts.rowid
WHERE articles_fts MATCH ?
${opts.unread ? 'AND articles.read_at IS NULL' : ''}
ORDER BY rank
LIMIT ?
`).all(opts.q, opts.limit) as Article[];
}
return db.prepare(`
SELECT * FROM articles
${opts.unread ? 'WHERE read_at IS NULL' : ''}
ORDER BY captured_at DESC
LIMIT ?
`).all(opts.limit) as Article[];
}
export function getArticle(id: number): Article | undefined {
return db.prepare('SELECT * FROM articles WHERE id = ?').get(id) as Article | undefined;
}
export function updateArticle(id: number, patch: { read_at?: string | null; tags?: string }): Article | undefined {
if (patch.read_at !== undefined) {
db.prepare('UPDATE articles SET read_at = ? WHERE id = ?').run(patch.read_at, id);
}
if (patch.tags !== undefined) {
db.prepare('UPDATE articles SET tags = ? WHERE id = ?').run(patch.tags, id);
}
return getArticle(id);
}
export function deleteArticle(id: number): boolean {
const result = db.prepare('DELETE FROM articles WHERE id = ?').run(id);
return result.changes > 0;
}
export function isValidToken(token: string): boolean {
const row = db.prepare('SELECT 1 FROM tokens WHERE token = ?').get(token);
return !!row;
}
export function createToken(label: string): string {
const token = crypto.randomUUID().replace(/-/g, '');
db.prepare('INSERT INTO tokens (token, label, created_at) VALUES (?, ?, ?)').run(
token, label, new Date().toISOString()
);
return token;
}

8
src/server/gen-token.ts Normal file
View File

@@ -0,0 +1,8 @@
// One-time setup: generates a bearer token and prints it once. Paste it into
// the extension's options page. There's no login flow — this is the entire
// auth story for a single-user tool.
import { createToken } from './db.js';
const label = process.argv[2] ?? 'default';
const token = createToken(label);
console.log(`Token for "${label}":\n${token}\n\nPaste this into the stash extension's options page.`);

19
src/server/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import express from 'express';
import cors from 'cors';
import routes from './routes.js';
import ui from './ui.js';
const app = express();
const PORT = Number(process.env.PORT) || 3070;
app.use(express.json({ limit: '5mb' })); // article HTML can be sizeable
app.use(cors()); // the extension calls this from arbitrary page origins
app.use('/api', routes);
app.use('/', ui);
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`stash server listening on :${PORT}`);
});

58
src/server/routes.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Router } from 'express';
import { requireToken } from './auth.js';
import { upsertArticle, listArticles, getArticle, updateArticle, deleteArticle } from './db.js';
const router = Router();
// POST /api/capture — what the extension hits on "Send to stash".
router.post('/capture', requireToken, (req, res) => {
const { url, title, content_html, content_text, author, published_at } = req.body ?? {};
if (!url || !title || !content_html || !content_text) {
res.status(400).json({ error: 'url, title, content_html, content_text are required' });
return;
}
try {
const article = upsertArticle({ url, title, content_html, content_text, author, published_at });
res.json(article);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// GET /api/articles?unread=true&q=<search>&limit=50
router.get('/articles', requireToken, (req, res) => {
const unread = req.query.unread === 'true';
const q = typeof req.query.q === 'string' && req.query.q.trim() ? req.query.q.trim() : undefined;
const limit = Math.min(Number(req.query.limit) || 50, 500);
try {
res.json(listArticles({ unread, q, limit }));
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
router.get('/articles/:id', requireToken, (req, res) => {
const article = getArticle(Number(req.params.id));
if (!article) { res.status(404).json({ error: 'not found' }); return; }
res.json(article);
});
router.patch('/articles/:id', requireToken, (req, res) => {
const id = Number(req.params.id);
const { read, tags } = req.body ?? {};
const patch: { read_at?: string | null; tags?: string } = {};
if (read === true) patch.read_at = new Date().toISOString();
if (read === false) patch.read_at = null;
if (typeof tags === 'string') patch.tags = tags;
const article = updateArticle(id, patch);
if (!article) { res.status(404).json({ error: 'not found' }); return; }
res.json(article);
});
router.delete('/articles/:id', requireToken, (req, res) => {
const ok = deleteArticle(Number(req.params.id));
if (!ok) { res.status(404).json({ error: 'not found' }); return; }
res.json({ ok: true });
});
export default router;

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;