@isomoes/iread 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/server/cli.js +84 -0
- package/dist/server/cli.js.map +1 -0
- package/dist/server/db.js +158 -0
- package/dist/server/db.js.map +1 -0
- package/dist/server/feed-service.js +523 -0
- package/dist/server/feed-service.js.map +1 -0
- package/dist/server/fetch-feed.js +83 -0
- package/dist/server/fetch-feed.js.map +1 -0
- package/dist/server/index.js +62 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/opml.js +137 -0
- package/dist/server/opml.js.map +1 -0
- package/dist/server/routes/feeds.js +68 -0
- package/dist/server/routes/feeds.js.map +1 -0
- package/dist/server/routes/helpers.js +44 -0
- package/dist/server/routes/helpers.js.map +1 -0
- package/dist/server/routes/items.js +95 -0
- package/dist/server/routes/items.js.map +1 -0
- package/dist/server/routes/opml.js +50 -0
- package/dist/server/routes/opml.js.map +1 -0
- package/dist/server/sanitize.js +75 -0
- package/dist/server/sanitize.js.map +1 -0
- package/dist/server/ssrf.js +275 -0
- package/dist/server/ssrf.js.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/dist/web/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/dist/web/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/web/assets/index-BI1j2sXf.css +2 -0
- package/dist/web/assets/index-HhCr0pHx.js +17 -0
- package/dist/web/assets/index-HhCr0pHx.js.map +1 -0
- package/dist/web/index.html +25 -0
- package/package.json +75 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
// Hono entry point. Registers all /api/* routers FIRST; unknown /api/* paths
|
|
3
|
+
// return a JSON 404 in the uniform error shape (never HTML). In production serves
|
|
4
|
+
// the static web bundle from ../web (relative to the emitted dist/server/index.js)
|
|
5
|
+
// with SPA fallback to index.html for non-/api GET requests. (PLAN 7, 9, 10)
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { Hono } from 'hono';
|
|
10
|
+
import { serve } from '@hono/node-server';
|
|
11
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
12
|
+
import { feeds } from './routes/feeds.js';
|
|
13
|
+
import { items } from './routes/items.js';
|
|
14
|
+
import { opml } from './routes/opml.js';
|
|
15
|
+
const PORT = Number(process.env.PORT ?? 8787);
|
|
16
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
17
|
+
// Importing ./db has the side effect of opening the DB and running migrations.
|
|
18
|
+
// Do it explicitly so startup failures surface immediately.
|
|
19
|
+
import './db.js';
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
// --- API routes (registered FIRST) -----------------------------------------
|
|
22
|
+
const api = new Hono();
|
|
23
|
+
api.route('/feeds', feeds);
|
|
24
|
+
api.route('/items', items);
|
|
25
|
+
api.route('/opml', opml);
|
|
26
|
+
app.route('/api', api);
|
|
27
|
+
// Unknown /api/* -> JSON 404 in the uniform error shape, never the SPA HTML.
|
|
28
|
+
app.all('/api/*', (c) => {
|
|
29
|
+
const body = {
|
|
30
|
+
error: { message: `Not found: ${c.req.method} ${c.req.path}`, code: 'NOT_FOUND' },
|
|
31
|
+
};
|
|
32
|
+
return c.json(body, 404);
|
|
33
|
+
});
|
|
34
|
+
// --- Static + SPA fallback (production only) --------------------------------
|
|
35
|
+
if (IS_PROD) {
|
|
36
|
+
// Emitted file is dist/server/index.js; the web bundle is dist/web. The static
|
|
37
|
+
// root is therefore ../web relative to this module. serveStatic resolves only
|
|
38
|
+
// within that root (no ../ traversal escapes it).
|
|
39
|
+
const serverDir = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const webRoot = resolve(serverDir, '../web');
|
|
41
|
+
const indexHtmlPath = resolve(webRoot, 'index.html');
|
|
42
|
+
// Serve real static assets first.
|
|
43
|
+
app.use('/*', serveStatic({ root: webRoot }));
|
|
44
|
+
// SPA fallback: any non-/api GET that did not match a static file returns
|
|
45
|
+
// index.html. (The /api/* handlers above already returned for API paths.)
|
|
46
|
+
app.get('/*', async (c) => {
|
|
47
|
+
try {
|
|
48
|
+
const html = await readFile(indexHtmlPath, 'utf-8');
|
|
49
|
+
return c.html(html);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return c.text('Web bundle not found. Run `pnpm build` first.', 500);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// --- Start ------------------------------------------------------------------
|
|
57
|
+
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
|
58
|
+
const url = `http://localhost:${info.port}`;
|
|
59
|
+
console.log(`[iread] listening on ${url}`);
|
|
60
|
+
});
|
|
61
|
+
export { app };
|
|
62
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/server/opml.ts
|
|
2
|
+
// OPML import (XXE-safe parse -> feed URLs) and export (feeds -> OPML 2.0 XML).
|
|
3
|
+
// (PLAN 7 OPML, 10 XXE)
|
|
4
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
5
|
+
const MAX_OPML_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
6
|
+
export class OpmlError extends Error {
|
|
7
|
+
name = 'OpmlError';
|
|
8
|
+
}
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Import
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const parser = new XMLParser({
|
|
13
|
+
ignoreAttributes: false,
|
|
14
|
+
attributeNamePrefix: '@_',
|
|
15
|
+
// XXE / billion-laughs defense: do NOT process DOCTYPE or expand any entities.
|
|
16
|
+
processEntities: false,
|
|
17
|
+
allowBooleanAttributes: true,
|
|
18
|
+
// Treat every <outline> as a potential array element so nested structures and
|
|
19
|
+
// single-child documents are handled uniformly.
|
|
20
|
+
isArray: (name) => name === 'outline',
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* Recursively collect every `xmlUrl` from the parsed OPML object tree. OPML nests
|
|
24
|
+
* <outline> elements under <body> and allows folder hierarchies; we walk every
|
|
25
|
+
* object/array value depth-first and flatten, since iread has no folder concept.
|
|
26
|
+
*/
|
|
27
|
+
function collectXmlUrls(node, out) {
|
|
28
|
+
if (node === null || typeof node !== 'object')
|
|
29
|
+
return;
|
|
30
|
+
if (Array.isArray(node)) {
|
|
31
|
+
for (const child of node)
|
|
32
|
+
collectXmlUrls(child, out);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const obj = node;
|
|
36
|
+
// Pick up an xmlUrl attribute on this node (attributeNamePrefix '@_'). Some
|
|
37
|
+
// exporters vary the casing of the attribute name.
|
|
38
|
+
const xmlUrl = obj['@_xmlUrl'] ?? obj['@_xmlurl'] ?? obj['@_xmlURL'];
|
|
39
|
+
if (typeof xmlUrl === 'string') {
|
|
40
|
+
const trimmed = xmlUrl.trim();
|
|
41
|
+
if (trimmed)
|
|
42
|
+
out.push(trimmed);
|
|
43
|
+
}
|
|
44
|
+
// Recurse into every child value so nested <body>/<outline> trees are reached
|
|
45
|
+
// regardless of depth. Attribute values (string/number) are skipped by the
|
|
46
|
+
// typeof guard at the top.
|
|
47
|
+
for (const key of Object.keys(obj)) {
|
|
48
|
+
if (key.startsWith('@_'))
|
|
49
|
+
continue; // attributes, not child elements
|
|
50
|
+
collectXmlUrls(obj[key], out);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse an OPML document and return the de-duplicated list of feed `xmlUrl`s in
|
|
55
|
+
* first-seen order. Rejects oversized input and documents that declare a DOCTYPE.
|
|
56
|
+
* Throws OpmlError on parse failure / size violation.
|
|
57
|
+
*/
|
|
58
|
+
export function importOpml(xml) {
|
|
59
|
+
if (typeof xml !== 'string' || xml.trim() === '') {
|
|
60
|
+
throw new OpmlError('OPML body is empty.');
|
|
61
|
+
}
|
|
62
|
+
// Byte length, not character count, to match the on-wire size.
|
|
63
|
+
if (Buffer.byteLength(xml, 'utf-8') > MAX_OPML_BYTES) {
|
|
64
|
+
throw new OpmlError('OPML document exceeds the 5 MB size limit.');
|
|
65
|
+
}
|
|
66
|
+
// Defense in depth: reject any DOCTYPE outright (processEntities:false already
|
|
67
|
+
// refuses to expand entities, but we never want to even see a DTD).
|
|
68
|
+
if (/<!DOCTYPE/i.test(xml)) {
|
|
69
|
+
throw new OpmlError('OPML documents with a DOCTYPE are not allowed.');
|
|
70
|
+
}
|
|
71
|
+
let doc;
|
|
72
|
+
try {
|
|
73
|
+
doc = parser.parse(xml);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
throw new OpmlError(`Could not parse OPML: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
if (doc === null || typeof doc !== 'object') {
|
|
80
|
+
throw new OpmlError('OPML document is not well-formed.');
|
|
81
|
+
}
|
|
82
|
+
const root = doc;
|
|
83
|
+
const opml = root['opml'];
|
|
84
|
+
// Body lives at opml.body.outline, but some exporters omit the <opml> wrapper.
|
|
85
|
+
// Collect from the whole tree to be liberal in what we accept.
|
|
86
|
+
const urls = [];
|
|
87
|
+
collectXmlUrls(opml ?? root, urls);
|
|
88
|
+
// De-dup within the file, preserving first-seen order, so two identical
|
|
89
|
+
// xmlUrls do not collide on the UNIQUE index mid-transaction.
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const deduped = [];
|
|
92
|
+
for (const u of urls) {
|
|
93
|
+
if (!seen.has(u)) {
|
|
94
|
+
seen.add(u);
|
|
95
|
+
deduped.push(u);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return deduped;
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Export
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function escapeXmlAttr(value) {
|
|
104
|
+
return value
|
|
105
|
+
.replace(/&/g, '&')
|
|
106
|
+
.replace(/</g, '<')
|
|
107
|
+
.replace(/>/g, '>')
|
|
108
|
+
.replace(/"/g, '"')
|
|
109
|
+
.replace(/'/g, ''');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Produce an OPML 2.0 document, one <outline type="rss"> per feed with
|
|
113
|
+
* text/title/xmlUrl (and htmlUrl when a site URL is known). Attributes are
|
|
114
|
+
* XML-escaped.
|
|
115
|
+
*/
|
|
116
|
+
export function exportOpml(feeds) {
|
|
117
|
+
const now = new Date().toUTCString();
|
|
118
|
+
const lines = [];
|
|
119
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
120
|
+
lines.push('<opml version="2.0">');
|
|
121
|
+
lines.push(' <head>');
|
|
122
|
+
lines.push(' <title>iread subscriptions</title>');
|
|
123
|
+
lines.push(` <dateCreated>${escapeXmlAttr(now)}</dateCreated>`);
|
|
124
|
+
lines.push(' </head>');
|
|
125
|
+
lines.push(' <body>');
|
|
126
|
+
for (const feed of feeds) {
|
|
127
|
+
const text = escapeXmlAttr(feed.title || feed.feedUrl);
|
|
128
|
+
const xmlUrl = escapeXmlAttr(feed.feedUrl);
|
|
129
|
+
const htmlUrlAttr = feed.siteUrl ? ` htmlUrl="${escapeXmlAttr(feed.siteUrl)}"` : '';
|
|
130
|
+
lines.push(` <outline type="rss" text="${text}" title="${text}" xmlUrl="${xmlUrl}"${htmlUrlAttr} />`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(' </body>');
|
|
133
|
+
lines.push('</opml>');
|
|
134
|
+
lines.push('');
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=opml.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opml.js","sourceRoot":"","sources":["../../src/server/opml.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/server/routes/feeds.ts
|
|
2
|
+
// /api/feeds router: list, add, delete, refresh-one, refresh-all. (PLAN 7 Feeds)
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { addFeed, deleteFeed, listFeeds, refreshAll, refreshFeed, } from '../feed-service.js';
|
|
5
|
+
import { errorResponse, parseId, serviceErrorToResponse } from './helpers.js';
|
|
6
|
+
export const feeds = new Hono();
|
|
7
|
+
// IMPORTANT: register the static "/refresh" route before the parameterized
|
|
8
|
+
// ":id/refresh" route so "refresh" is never captured as an :id.
|
|
9
|
+
// GET /api/feeds — list feeds with counts + global totals.
|
|
10
|
+
feeds.get('/', (c) => {
|
|
11
|
+
const body = listFeeds();
|
|
12
|
+
return c.json(body, 200);
|
|
13
|
+
});
|
|
14
|
+
// POST /api/feeds — add a feed by URL (fetches immediately).
|
|
15
|
+
feeds.post('/', async (c) => {
|
|
16
|
+
let payload;
|
|
17
|
+
try {
|
|
18
|
+
payload = await c.req.json();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return errorResponse(c, 400, 'Request body must be JSON.');
|
|
22
|
+
}
|
|
23
|
+
if (!payload || typeof payload.url !== 'string') {
|
|
24
|
+
return errorResponse(c, 400, 'Body must include a "url" string.', 'BAD_INPUT');
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const feed = await addFeed(payload.url);
|
|
28
|
+
const body = { feed };
|
|
29
|
+
return c.json(body, 201);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return serviceErrorToResponse(c, err);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// POST /api/feeds/refresh — refresh all feeds (always 200).
|
|
36
|
+
feeds.post('/refresh', async (c) => {
|
|
37
|
+
const result = await refreshAll();
|
|
38
|
+
const body = result;
|
|
39
|
+
return c.json(body, 200);
|
|
40
|
+
});
|
|
41
|
+
// POST /api/feeds/:id/refresh — refresh one feed.
|
|
42
|
+
feeds.post('/:id/refresh', async (c) => {
|
|
43
|
+
const id = parseId(c.req.param('id'));
|
|
44
|
+
if (id === null)
|
|
45
|
+
return errorResponse(c, 400, 'Invalid feed id.', 'BAD_INPUT');
|
|
46
|
+
try {
|
|
47
|
+
const result = await refreshFeed(id);
|
|
48
|
+
const body = result;
|
|
49
|
+
return c.json(body, 200);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return serviceErrorToResponse(c, err);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// DELETE /api/feeds/:id — delete a feed (cascades to items).
|
|
56
|
+
feeds.delete('/:id', (c) => {
|
|
57
|
+
const id = parseId(c.req.param('id'));
|
|
58
|
+
if (id === null)
|
|
59
|
+
return errorResponse(c, 400, 'Invalid feed id.', 'BAD_INPUT');
|
|
60
|
+
try {
|
|
61
|
+
deleteFeed(id);
|
|
62
|
+
return c.body(null, 204);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return serviceErrorToResponse(c, err);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
//# sourceMappingURL=feeds.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feeds.js","sourceRoot":"","sources":["../../../src/server/routes/feeds.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/server/routes/helpers.ts
|
|
2
|
+
// Shared route utilities: uniform error responses, id/number parsing, and a
|
|
3
|
+
// mapping from ServiceError codes to HTTP statuses.
|
|
4
|
+
import { ServiceError } from '../feed-service.js';
|
|
5
|
+
/** Emit the uniform error shape `{ error: { message, code? } }`. */
|
|
6
|
+
export function errorResponse(c, status, message, code) {
|
|
7
|
+
const body = { error: code ? { message, code } : { message } };
|
|
8
|
+
return c.json(body, status);
|
|
9
|
+
}
|
|
10
|
+
const CODE_TO_STATUS = {
|
|
11
|
+
BAD_INPUT: 400,
|
|
12
|
+
UNSAFE_URL: 400,
|
|
13
|
+
NOT_FOUND: 404,
|
|
14
|
+
DUPLICATE: 409,
|
|
15
|
+
UNPARSEABLE: 422,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Map any thrown error to a response. ServiceError carries an explicit code;
|
|
19
|
+
* anything else becomes a 500 with a generic message (details are logged).
|
|
20
|
+
*/
|
|
21
|
+
export function serviceErrorToResponse(c, err) {
|
|
22
|
+
if (err instanceof ServiceError) {
|
|
23
|
+
return errorResponse(c, CODE_TO_STATUS[err.code], err.message, err.code);
|
|
24
|
+
}
|
|
25
|
+
console.error('[iread] unexpected error:', err);
|
|
26
|
+
return errorResponse(c, 500, 'An unexpected error occurred.', 'INTERNAL');
|
|
27
|
+
}
|
|
28
|
+
/** Parse a positive integer id from a path param; null if invalid. */
|
|
29
|
+
export function parseId(raw) {
|
|
30
|
+
if (raw === undefined)
|
|
31
|
+
return null;
|
|
32
|
+
if (!/^\d+$/.test(raw))
|
|
33
|
+
return null;
|
|
34
|
+
const n = Number.parseInt(raw, 10);
|
|
35
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
36
|
+
}
|
|
37
|
+
/** Parse an optional integer query param; returns undefined when absent/blank. */
|
|
38
|
+
export function parseOptionalInt(raw) {
|
|
39
|
+
if (raw === undefined || raw.trim() === '')
|
|
40
|
+
return undefined;
|
|
41
|
+
const n = Number(raw);
|
|
42
|
+
return Number.isFinite(n) ? Math.trunc(n) : undefined;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helpers.js","sourceRoot":"","sources":["../../../src/server/routes/helpers.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,4EAA4E;AAC5E,oDAAoD;AAKpD,OAAO,EAAE,YAAY,EAAyB,MAAM,oBAAoB,CAAC;AAEzE,oEAAoE;AACpE,MAAM,UAAU,aAAa,CAC3B,CAAU,EACV,MAA4B,EAC5B,OAAe,EACf,IAAa;IAEb,MAAM,IAAI,GAAa,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,CAAC;IACzE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC9B,CAAC;AAED,MAAM,cAAc,GAAmD;IACrE,SAAS,EAAE,GAAG;IACd,UAAU,EAAE,GAAG;IACf,SAAS,EAAE,GAAG;IACd,SAAS,EAAE,GAAG;IACd,WAAW,EAAE,GAAG;CACjB,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,CAAU,EAAE,GAAY;IAC7D,IAAI,GAAG,YAAY,YAAY,EAAE,CAAC;QAChC,OAAO,aAAa,CAAC,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAAC;IAChD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,+BAA+B,EAAE,UAAU,CAAC,CAAC;AAC5E,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,OAAO,CAAC,GAAuB;IAC7C,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACrD,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,gBAAgB,CAAC,GAAuB;IACtD,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC7D,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACtB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACxD,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/server/routes/items.ts
|
|
2
|
+
// /api/items router: list, get, patch (read/star), mark-all-read. (PLAN 7 Items)
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { getItem, listItems, markAllRead, patchItem, } from '../feed-service.js';
|
|
5
|
+
import { errorResponse, parseId, parseOptionalInt, serviceErrorToResponse } from './helpers.js';
|
|
6
|
+
export const items = new Hono();
|
|
7
|
+
function parseView(raw) {
|
|
8
|
+
if (raw === 'all' || raw === 'unread' || raw === 'starred')
|
|
9
|
+
return raw;
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
// GET /api/items — list item summaries.
|
|
13
|
+
items.get('/', (c) => {
|
|
14
|
+
const view = parseView(c.req.query('view')) ?? 'all';
|
|
15
|
+
const q = c.req.query('q') ?? '';
|
|
16
|
+
const feedId = parseOptionalInt(c.req.query('feedId'));
|
|
17
|
+
const limit = parseOptionalInt(c.req.query('limit'));
|
|
18
|
+
const offset = parseOptionalInt(c.req.query('offset'));
|
|
19
|
+
const query = { view, q };
|
|
20
|
+
if (feedId !== undefined)
|
|
21
|
+
query.feedId = feedId;
|
|
22
|
+
if (limit !== undefined)
|
|
23
|
+
query.limit = limit;
|
|
24
|
+
if (offset !== undefined)
|
|
25
|
+
query.offset = offset;
|
|
26
|
+
const body = listItems(query);
|
|
27
|
+
return c.json(body, 200);
|
|
28
|
+
});
|
|
29
|
+
// POST /api/items/mark-all-read — mark a scope read. Registered before /:id so
|
|
30
|
+
// "mark-all-read" is never captured as an item id.
|
|
31
|
+
items.post('/mark-all-read', async (c) => {
|
|
32
|
+
let payload = {};
|
|
33
|
+
try {
|
|
34
|
+
const text = await c.req.text();
|
|
35
|
+
if (text.trim() !== '')
|
|
36
|
+
payload = JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return errorResponse(c, 400, 'Request body must be JSON.', 'BAD_INPUT');
|
|
40
|
+
}
|
|
41
|
+
const scope = {};
|
|
42
|
+
if (typeof payload.feedId === 'number' && Number.isFinite(payload.feedId)) {
|
|
43
|
+
scope.feedId = Math.trunc(payload.feedId);
|
|
44
|
+
}
|
|
45
|
+
const view = parseView(payload.view);
|
|
46
|
+
if (view !== undefined)
|
|
47
|
+
scope.view = view;
|
|
48
|
+
const updated = markAllRead(scope);
|
|
49
|
+
const body = { updated };
|
|
50
|
+
return c.json(body, 200);
|
|
51
|
+
});
|
|
52
|
+
// GET /api/items/:id — full item for the reader pane.
|
|
53
|
+
items.get('/:id', (c) => {
|
|
54
|
+
const id = parseId(c.req.param('id'));
|
|
55
|
+
if (id === null)
|
|
56
|
+
return errorResponse(c, 400, 'Invalid item id.', 'BAD_INPUT');
|
|
57
|
+
try {
|
|
58
|
+
const item = getItem(id);
|
|
59
|
+
const body = { item };
|
|
60
|
+
return c.json(body, 200);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return serviceErrorToResponse(c, err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// PATCH /api/items/:id — update read/starred state.
|
|
67
|
+
items.patch('/:id', async (c) => {
|
|
68
|
+
const id = parseId(c.req.param('id'));
|
|
69
|
+
if (id === null)
|
|
70
|
+
return errorResponse(c, 400, 'Invalid item id.', 'BAD_INPUT');
|
|
71
|
+
let payload;
|
|
72
|
+
try {
|
|
73
|
+
payload = await c.req.json();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return errorResponse(c, 400, 'Request body must be JSON.', 'BAD_INPUT');
|
|
77
|
+
}
|
|
78
|
+
const patch = {};
|
|
79
|
+
if (typeof payload.isRead === 'boolean')
|
|
80
|
+
patch.isRead = payload.isRead;
|
|
81
|
+
if (typeof payload.isStarred === 'boolean')
|
|
82
|
+
patch.isStarred = payload.isStarred;
|
|
83
|
+
if (patch.isRead === undefined && patch.isStarred === undefined) {
|
|
84
|
+
return errorResponse(c, 400, 'Provide at least one of isRead or isStarred (boolean).', 'BAD_INPUT');
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const item = patchItem(id, patch);
|
|
88
|
+
const body = { item };
|
|
89
|
+
return c.json(body, 200);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return serviceErrorToResponse(c, err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
//# sourceMappingURL=items.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"items.js","sourceRoot":"","sources":["../../../src/server/routes/items.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/server/routes/opml.ts
|
|
2
|
+
// /api/opml router: export (GET) and import (POST). (PLAN 7 OPML)
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { importFeeds, listFeedsForExport } from '../feed-service.js';
|
|
5
|
+
import { exportOpml, importOpml, OpmlError } from '../opml.js';
|
|
6
|
+
import { errorResponse } from './helpers.js';
|
|
7
|
+
export const opml = new Hono();
|
|
8
|
+
// GET /api/opml — export subscriptions as OPML 2.0.
|
|
9
|
+
opml.get('/', (c) => {
|
|
10
|
+
const xml = exportOpml(listFeedsForExport());
|
|
11
|
+
c.header('Content-Type', 'text/x-opml; charset=utf-8');
|
|
12
|
+
c.header('Content-Disposition', 'attachment; filename="iread.opml"');
|
|
13
|
+
return c.body(xml, 200);
|
|
14
|
+
});
|
|
15
|
+
// POST /api/opml — import. Accepts a raw OPML body (application/xml,
|
|
16
|
+
// text/x-opml) or JSON { opml: string }.
|
|
17
|
+
opml.post('/', async (c) => {
|
|
18
|
+
const contentType = (c.req.header('content-type') ?? '').toLowerCase();
|
|
19
|
+
let xml;
|
|
20
|
+
try {
|
|
21
|
+
if (contentType.includes('application/json')) {
|
|
22
|
+
const payload = (await c.req.json());
|
|
23
|
+
if (!payload || typeof payload.opml !== 'string') {
|
|
24
|
+
return errorResponse(c, 400, 'JSON body must include an "opml" string.', 'BAD_INPUT');
|
|
25
|
+
}
|
|
26
|
+
xml = payload.opml;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Raw OPML body (application/xml, text/x-opml, or unspecified).
|
|
30
|
+
xml = await c.req.text();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return errorResponse(c, 400, 'Could not read the request body.', 'BAD_INPUT');
|
|
35
|
+
}
|
|
36
|
+
let urls;
|
|
37
|
+
try {
|
|
38
|
+
urls = importOpml(xml);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof OpmlError) {
|
|
42
|
+
return errorResponse(c, 400, err.message, 'BAD_OPML');
|
|
43
|
+
}
|
|
44
|
+
return errorResponse(c, 400, 'Could not parse the OPML document.', 'BAD_OPML');
|
|
45
|
+
}
|
|
46
|
+
const result = await importFeeds(urls);
|
|
47
|
+
const body = result;
|
|
48
|
+
return c.json(body, 200);
|
|
49
|
+
});
|
|
50
|
+
//# sourceMappingURL=opml.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opml.js","sourceRoot":"","sources":["../../../src/server/routes/opml.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAC5B,kEAAkE;AAElE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;AAE/B,oDAAoD;AACpD,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE;IAClB,MAAM,GAAG,GAAG,UAAU,CAAC,kBAAkB,EAAE,CAAC,CAAC;IAC7C,CAAC,CAAC,MAAM,CAAC,cAAc,EAAE,4BAA4B,CAAC,CAAC;IACvD,CAAC,CAAC,MAAM,CAAC,qBAAqB,EAAE,mCAAmC,CAAC,CAAC;IACrE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAC1B,CAAC,CAAC,CAAC;AAEH,qEAAqE;AACrE,yCAAyC;AACzC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACzB,MAAM,WAAW,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAEvE,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,IAAI,WAAW,CAAC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,CAAC;YAC7C,MAAM,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;YACnE,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACjD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,0CAA0C,EAAE,WAAW,CAAC,CAAC;YACxF,CAAC;YACD,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;QACrB,CAAC;aAAM,CAAC;YACN,gEAAgE;YAChE,GAAG,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,kCAAkC,EAAE,WAAW,CAAC,CAAC;IAChF,CAAC;IAED,IAAI,IAAc,CAAC;IACnB,IAAI,CAAC;QACH,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,YAAY,SAAS,EAAE,CAAC;YAC7B,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;QACxD,CAAC;QACD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,oCAAoC,EAAE,UAAU,CAAC,CAAC;IACjF,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,IAAI,GAAuB,MAAM,CAAC;IACxC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AAC3B,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/server/sanitize.ts
|
|
2
|
+
// Server-side HTML sanitization. Bodies are sanitized before they touch the DB,
|
|
3
|
+
// so stored content_html is already safe and the client renders it via
|
|
4
|
+
// dangerouslySetInnerHTML without re-sanitizing. (PLAN Section 5.4)
|
|
5
|
+
import sanitizeHtml from 'sanitize-html';
|
|
6
|
+
export const SANITIZE_OPTS = {
|
|
7
|
+
allowedTags: [
|
|
8
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
9
|
+
'p', 'blockquote', 'pre', 'code',
|
|
10
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
11
|
+
'a', 'b', 'i', 'strong', 'em', 'mark', 'small', 'sub', 'sup', 'u', 's', 'span', 'br', 'hr',
|
|
12
|
+
'img', 'figure', 'figcaption',
|
|
13
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
|
14
|
+
'video', 'audio', 'source',
|
|
15
|
+
],
|
|
16
|
+
allowedAttributes: {
|
|
17
|
+
a: ['href', 'name', 'target', 'rel'],
|
|
18
|
+
img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
|
|
19
|
+
video: ['src', 'controls', 'poster', 'width', 'height'],
|
|
20
|
+
audio: ['src', 'controls'],
|
|
21
|
+
source: ['src', 'srcset', 'type', 'media'],
|
|
22
|
+
td: ['colspan', 'rowspan'],
|
|
23
|
+
th: ['colspan', 'rowspan', 'scope'],
|
|
24
|
+
'*': ['class'],
|
|
25
|
+
},
|
|
26
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
27
|
+
// Raster data: images only. NO data: svg (can carry script in some renderers);
|
|
28
|
+
// the second pass below strips any non-raster data: URL that slips through here.
|
|
29
|
+
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
|
|
30
|
+
allowedSchemesAppliedToAttributes: ['href', 'src', 'srcset'],
|
|
31
|
+
allowProtocolRelative: true,
|
|
32
|
+
// nonTextTags drops the TEXT CONTENT of these, not just the tags.
|
|
33
|
+
nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe'],
|
|
34
|
+
transformTags: {
|
|
35
|
+
a: sanitizeHtml.simpleTransform('a', { target: '_blank', rel: 'noopener noreferrer nofollow' }),
|
|
36
|
+
img: sanitizeHtml.simpleTransform('img', { loading: 'lazy' }),
|
|
37
|
+
},
|
|
38
|
+
disallowedTagsMode: 'discard',
|
|
39
|
+
};
|
|
40
|
+
// Allowed raster data: image MIME types. data:image/svg+xml (and anything else)
|
|
41
|
+
// is stripped in the second pass below.
|
|
42
|
+
const RASTER_DATA_RE = /^data:image\/(?:png|jpeg|jpg|gif|webp)(?:;|,)/i;
|
|
43
|
+
// Matches a single `attr="data:..."` or `attr='data:...'` occurrence. We only
|
|
44
|
+
// need to scrub src / srcset / href because those are the only attributes
|
|
45
|
+
// allowedSchemesAppliedToAttributes lets data: through on, and img is the only
|
|
46
|
+
// tag with `data` in its scheme allowlist.
|
|
47
|
+
const DATA_URL_ATTR_RE = /\b(src|srcset|href)\s*=\s*("data:[^"]*"|'data:[^']*')/gi;
|
|
48
|
+
function isSafeDataUrl(rawUrl) {
|
|
49
|
+
// srcset can hold multiple comma-separated candidates and a data: URL itself
|
|
50
|
+
// contains a comma (data:<mime>,<payload>), so we cannot split on commas.
|
|
51
|
+
// Instead find every "data:" token (the MIME-type prefix up to the comma or
|
|
52
|
+
// semicolon) and require each one to be a raster image.
|
|
53
|
+
const matches = rawUrl.match(/data:[^\s]*/gi);
|
|
54
|
+
if (!matches)
|
|
55
|
+
return true; // no data: URL present, nothing to reject
|
|
56
|
+
return matches.every((m) => RASTER_DATA_RE.test(m));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Sanitize feed-provided article HTML. Runs sanitize-html with SANITIZE_OPTS,
|
|
60
|
+
* then a second regex pass strips any data: image URL that is not a raster
|
|
61
|
+
* format (notably data:image/svg+xml, which can carry script in some renderers).
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeArticleHtml(raw) {
|
|
64
|
+
const clean = sanitizeHtml(raw, SANITIZE_OPTS);
|
|
65
|
+
// Second pass: remove src/srcset/href attributes whose data: URL is not raster.
|
|
66
|
+
return clean.replace(DATA_URL_ATTR_RE, (full, attr, quoted) => {
|
|
67
|
+
const value = quoted.slice(1, -1); // strip surrounding quotes
|
|
68
|
+
return isSafeDataUrl(value) ? full : `${attr}=""`;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Strip all markup, returning plain text. Used for list-view summaries. */
|
|
72
|
+
export function toPlainText(raw) {
|
|
73
|
+
return sanitizeHtml(raw, { allowedTags: [], allowedAttributes: {} });
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/server/sanitize.ts"],"names":[],"mappings":"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"}
|