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:
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;
|
||||
Reference in New Issue
Block a user