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

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=3070
DATA_DIR=./data

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
data/
.env

137
PROPOSAL.md Normal file
View File

@@ -0,0 +1,137 @@
# Proposal: `stash` — self-hosted read-later with one-click capture
**Status:** proposed, not built. Self-contained — written so a fresh agent
with no prior conversation context can pick this up and implement it
without anything explained first. No repo exists yet; this file is the
seed of one.
## Motivation
Read-later tools (Pocket, Instapaper, and their many clones) all share the
same shape as every other cloud convenience this household of projects has
been replacing: a third party ends up holding a full copy of everything
you've ever wanted to read, indefinitely, for a feature that's really just
"save this page, let me read it later, let me search it eventually."
The actual want: click a button in the browser, the current page's article
content gets pulled out and stored on infrastructure already run
(alongside `wisp`, `flit`, `keep`), with a plain reading-list view and full
text search over what's been captured.
A read-later tool with no one-click capture is just a bookmarks file with
extra steps — so this is explicitly a two-piece project: a browser
extension that does the sending, and a small server that does the storing.
Neither piece is useful alone.
## Architecture
Single-process server + SQLite, the same pattern already validated by
`galr` (a photo gallery proving SQLite can carry an entire app's storage
and query needs without a separate database service). No queue, no
multi-service split — one process, one file on disk.
### Data model
One table, `articles`:
| column | type | notes |
|---|---|---|
| `id` | integer PK | |
| `url` | text, unique | dedup key — see "Open questions" |
| `title` | text | |
| `content_html` | text | cleaned article body, for the reader view |
| `content_text` | text | plain-text extract, indexed via FTS5 for search |
| `author` | text, nullable | best-effort, from extraction |
| `published_at` | text, nullable | best-effort, from extraction |
| `captured_at` | text | when the extension sent it |
| `read_at` | text, nullable | null = unread |
| `tags` | text | comma-separated or JSON array, keep simple |
Full-text search via SQLite's FTS5 virtual table over `content_text` +
`title` — no external search service, matches the "one process" bet.
### Server endpoints
- `POST /api/capture` — body: `{ url, title, content_html, content_text,
author?, published_at? }`. Auth via a bearer token (see auth below).
Upserts on `url` (see dedup, below).
- `GET /api/articles?unread=true&q=<search>` — list/search, most recent
capture first.
- `GET /api/articles/:id` — full record, for the reader view.
- `PATCH /api/articles/:id` — `{ read_at }` (mark read/unread), `{ tags }`.
- `DELETE /api/articles/:id`
- A minimal server-rendered reader view (e.g. `/read/:id`) that serves
`content_html` in a plain readable template — the point of capturing
cleanly is a nicer reading experience than the original page, not just
an archive.
### Browser extension (Manifest V3)
- Toolbar icon + context-menu entry: "Send to stash"
- On click: injects a content script that runs Readability.js-style
extraction (Mozilla's `@mozilla/readability` is the standard library for
this — strips nav, ads, comments, keeps article body/title/author/date)
against the current DOM
- If extraction confidence is low (Readability returns null or a very
short result — common on heavily JS-rendered pages), fall back to
capturing the page's raw rendered HTML rather than failing outright.
Worse capture beats no capture for a personal tool.
- POSTs the extracted `{ url, title, content_html, content_text, author,
published_at }` to `POST /api/capture`
- Toolbar badge or toast confirms success/failure so a silent failure
doesn't look like a successful capture
### Auth
No login UI in the extension — that's overkill for a single-user tool.
Instead: a settings page (extension's options page) with one field, a
token pasted in once. The server generates that token via a CLI command
or a one-time setup endpoint, the same "generate once, paste once" shape
`keep`'s identity registration already uses. Stored in
`chrome.storage.local`, sent as `Authorization: Bearer <token>` on every
capture.
## What this deliberately isn't
- **Not multi-user, no accounts, no sync protocol** — one server, one
reader (you), one writer (the extension on your own browser profiles).
If two browsers both have the extension, they both hit the same server;
there's no per-device state to reconcile.
- **Not a visual/screenshot archive** — text and article structure only
(à la archive.org's Wayback Machine, which this is not trying to be).
Keeps storage small and keeps full-text search meaningful — searching
rendered screenshots isn't a thing.
- **Not published to the Chrome Web Store or Firefox Add-ons** —
sideloaded/unpacked only (`chrome://extensions` → "Load unpacked", or
Firefox's `about:debugging` temporary add-on). Store review, a
developer account, and update-approval friction are all overhead for a
tool with exactly one user. Tradeoff: Firefox's temporary add-ons don't
survive a browser restart, so Chrome/Chromium (persistent unpacked
extensions) is the more convenient target if only picking one browser
to support first.
## Open questions
- **Dedup on re-capture** — a `url` unique constraint plus upsert is the
simplest behavior (re-sending the same URL just refreshes the stored
copy). The alternative — versioning every capture — adds real
complexity for a benefit ("see how this article changed over time")
that's unlikely to matter for a personal reading list. Recommend
upsert-on-url as the default; revisit only if it turns out to matter in
practice.
- **JS-heavy / paywalled sites** — capture happens against whatever the
DOM looks like at click-time, in the browser, as the logged-in user
already sees it. This sidesteps needing server-side headless rendering
entirely (no Puppeteer on the server, no cookie-jar management) — the
extension captures exactly what's already rendered in front of the
user. The limitation: if the page is still loading content
asynchronously when "Send to stash" is clicked, that content won't be
in the DOM yet. Not worth engineering around for a first cut; a
"capture is incomplete, try again once the page fully loads" is an
acceptable rough edge.
- **Where does this live relative to `keep`** — once `keep` is rolled out
further (see `goonk/FUTURE.md`'s "Roll out `keep` to the rest of the
stack"), `stash`'s server should probably pull its own bearer-token
secret from `keep` rather than a hand-copied `.env`, the same way any
new project should from here on. Not a blocker for a first version,
since `keep`'s rollout is itself still in progress.

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# stash
A self-hosted read-later tool. Click "Send to stash" in the browser, the
current page's article content is extracted and stored on your own
server — full-text searchable, with a plain reader view. No third party
ever sees your reading list.
Two pieces, neither useful alone:
- **Server** (`src/server`) — Express + SQLite (via `better-sqlite3`),
FTS5 full-text search, a minimal server-rendered reading list + reader
view, and the `/api/*` endpoints the extension talks to.
- **Extension** (`extension/`) — Manifest V3, sideloaded (not published to
any store). Runs [`@mozilla/readability`](https://github.com/mozilla/readability)
against the current page to strip nav/ads/chrome and keep just the
article, falling back to the raw rendered page if extraction comes back
too thin to be useful.
Full design and the reasoning behind the scope boundaries: [PROPOSAL.md](./PROPOSAL.md).
## Server setup
```bash
cp .env.example .env
npm install
npm run token -- my-laptop # prints a bearer token — paste it into the extension's options
npm run dev:server
```
## Extension setup
1. `chrome://extensions` → enable Developer mode → "Load unpacked" → select `extension/`
2. Click the extension icon once, or use "Options" from its context menu, to open the options page
3. Enter your server's URL and the token from `npm run token`
Then either click the toolbar icon or right-click a page → "Send to stash".
## Reading
Visit `http://your-server:3070/?t=<token>` — a plain list of what's been
captured, with search and an unread filter. Click through to `/read/:id`
for the cleaned reader view.
## Status
Built and verified end-to-end: capture → server storage → full-text
search → mark-read → reader view, driven through the actual extension
(loaded in a real Chromium instance, not just unit-tested logic) against
a live server. Not yet deployed anywhere.
## License
MIT.

51
extension/background.js Normal file
View File

@@ -0,0 +1,51 @@
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'send-to-stash',
title: 'Send to stash',
contexts: ['page'],
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'send-to-stash' && tab?.id) capture(tab.id);
});
chrome.action.onClicked.addListener((tab) => {
if (tab?.id) capture(tab.id);
});
async function capture(tabId) {
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
files: ['lib/Readability.js', 'extract.js'],
});
if (!result) throw new Error('extraction produced nothing');
const { serverUrl, token } = await chrome.storage.local.get(['serverUrl', 'token']);
if (!serverUrl || !token) {
notify('stash: not configured', 'Set the server URL and token in the extension options.');
chrome.runtime.openOptionsPage();
return;
}
const res = await fetch(`${serverUrl.replace(/\/$/, '')}/api/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(result),
});
if (!res.ok) throw new Error(`server responded ${res.status}`);
notify('Sent to stash', result.title);
} catch (e) {
notify('stash capture failed', String(e.message ?? e));
}
}
function notify(title, message) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'data:image/svg+xml;base64,' + btoa('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect width="48" height="48" rx="8" fill="#0a5"/></svg>'),
title,
message,
});
}

31
extension/extract.js Normal file
View File

@@ -0,0 +1,31 @@
// Runs in the page as a content script, after lib/Readability.js has already
// been injected (see background.js's executeScript files array — order matters).
// Its return value becomes the result of chrome.scripting.executeScript.
(() => {
try {
const docClone = document.cloneNode(true);
const article = new Readability(docClone).parse();
if (article && article.textContent && article.textContent.trim().length > 200) {
return {
url: location.href,
title: article.title || document.title,
content_html: article.content,
content_text: article.textContent,
author: article.byline || undefined,
published_at: article.publishedTime || undefined,
};
}
} catch (e) {
// fall through to raw capture below
}
// Fallback: Readability either failed or returned too little to be useful
// (common on heavily JS-rendered pages) — capture what's actually rendered
// rather than failing outright. Worse capture beats no capture.
return {
url: location.href,
title: document.title,
content_html: document.body ? document.body.innerHTML : '',
content_text: document.body ? document.body.innerText : '',
};
})();

2786
extension/lib/Readability.js Normal file

File diff suppressed because it is too large Load Diff

15
extension/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"manifest_version": 3,
"name": "stash",
"version": "0.1.0",
"description": "Send the current page to your self-hosted stash read-later.",
"permissions": ["activeTab", "scripting", "storage", "contextMenus", "notifications"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Send to stash"
},
"options_page": "options.html"
}

27
extension/options.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>stash settings</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 30px; }
label { display: block; margin-top: 14px; font-size: 13px; color: #555; }
input { width: 100%; padding: 6px; margin-top: 4px; box-sizing: border-box; }
button { margin-top: 16px; padding: 8px 16px; }
#status { margin-top: 10px; font-size: 13px; }
</style>
</head>
<body>
<h2>stash settings</h2>
<p>Generate a token on the server with <code>npm run token</code>, then paste it below.</p>
<label>Server URL
<input id="serverUrl" placeholder="https://stash.example.com">
</label>
<label>Token
<input id="token" placeholder="paste the token here">
</label>
<button id="save">Save</button>
<div id="status"></div>
<script src="options.js"></script>
</body>
</html>

17
extension/options.js Normal file
View File

@@ -0,0 +1,17 @@
const serverUrlEl = document.getElementById('serverUrl');
const tokenEl = document.getElementById('token');
const statusEl = document.getElementById('status');
chrome.storage.local.get(['serverUrl', 'token']).then(({ serverUrl, token }) => {
if (serverUrl) serverUrlEl.value = serverUrl;
if (token) tokenEl.value = token;
});
document.getElementById('save').addEventListener('click', async () => {
await chrome.storage.local.set({
serverUrl: serverUrlEl.value.trim(),
token: tokenEl.value.trim(),
});
statusEl.textContent = 'Saved.';
setTimeout(() => { statusEl.textContent = ''; }, 2000);
});

1965
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "stash",
"version": "0.1.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev:server": "tsx watch --env-file=.env src/server/index.ts",
"token": "tsx src/server/gen-token.ts",
"build": "tsc",
"start": "node --env-file=.env dist/server/index.js"
},
"dependencies": {
"express": "^4.19.2",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}
}

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;

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}