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:
12
src/server/auth.ts
Normal file
12
src/server/auth.ts
Normal 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
146
src/server/db.ts
Normal 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
8
src/server/gen-token.ts
Normal 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
19
src/server/index.ts
Normal 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
58
src/server/routes.ts
Normal 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
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