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:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PORT=3070
|
||||||
|
DATA_DIR=./data
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
137
PROPOSAL.md
Normal file
137
PROPOSAL.md
Normal 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
53
README.md
Normal 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
51
extension/background.js
Normal 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
31
extension/extract.js
Normal 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
2786
extension/lib/Readability.js
Normal file
File diff suppressed because it is too large
Load Diff
15
extension/manifest.json
Normal file
15
extension/manifest.json
Normal 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
27
extension/options.html
Normal 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
17
extension/options.js
Normal 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
1965
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
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;
|
||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user