82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
|
|
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;
|