@zhin.js/plugin-rss 1.1.19 → 1.1.20
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 +15 -4
- package/commands/rss-add/[url]/index.js +1 -1
- package/commands/rss-add/[url]/index.ts +1 -1
- package/commands/rss-preview/[url]/index.js +1 -1
- package/commands/rss-preview/[url]/index.ts +1 -1
- package/lib/admin-i18n.d.ts +6 -2
- package/lib/admin-i18n.d.ts.map +1 -1
- package/lib/admin-i18n.js +4 -0
- package/lib/admin-i18n.js.map +1 -1
- package/lib/admin-tasks.d.ts +5 -1
- package/lib/admin-tasks.d.ts.map +1 -1
- package/lib/admin-tasks.js +30 -12
- package/lib/admin-tasks.js.map +1 -1
- package/lib/console.d.ts.map +1 -1
- package/lib/console.js +2 -0
- package/lib/console.js.map +1 -1
- package/lib/feed.d.ts +9 -1
- package/lib/feed.d.ts.map +1 -1
- package/lib/feed.js +27 -7
- package/lib/feed.js.map +1 -1
- package/lib/poll.d.ts +7 -2
- package/lib/poll.d.ts.map +1 -1
- package/lib/poll.js +56 -23
- package/lib/poll.js.map +1 -1
- package/lib/public-fetch.d.ts +34 -0
- package/lib/public-fetch.d.ts.map +1 -0
- package/lib/public-fetch.js +144 -0
- package/lib/public-fetch.js.map +1 -0
- package/package.json +14 -19
- package/plugin.js +4 -1
- package/schema.json +46 -3
- package/skills/rss/tools/rss_preview_feed/index.js +1 -1
- package/skills/rss/tools/rss_preview_feed/index.ts +1 -1
- package/src/admin-i18n.ts +4 -0
- package/src/admin-tasks.ts +13 -3
- package/src/console.ts +2 -0
- package/src/feed.ts +30 -7
- package/src/poll.ts +58 -23
- package/src/public-fetch.ts +160 -0
package/src/admin-tasks.ts
CHANGED
|
@@ -70,15 +70,22 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
70
70
|
readonly #outbound: OutboundHost;
|
|
71
71
|
readonly #config: Readonly<RssConfig>;
|
|
72
72
|
readonly #load: FeedLoader;
|
|
73
|
+
readonly #enabledFor: (target: DeliveryTarget) => boolean;
|
|
73
74
|
readonly projectLocale: Locale;
|
|
74
75
|
readonly #admission = createGenerationAdmissionGate();
|
|
75
76
|
#stopped = false;
|
|
76
77
|
#working = false;
|
|
77
78
|
get [generationAdmissionSource]() { return [this.#admission]; }
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
/**
|
|
81
|
+
* `enabledFor` is the ADR 0050 / 0055 feature switch of the target conversation (pushes are not
|
|
82
|
+
* messages, so dispatch gating never sees them); a switched-off target is skipped, not sent.
|
|
83
|
+
*/
|
|
84
|
+
constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN',
|
|
85
|
+
enabledFor: (target: DeliveryTarget) => boolean = () => true) {
|
|
80
86
|
if (!outbound.sendTarget) throw unavailable('exactTarget');
|
|
81
87
|
this.projectLocale = projectLocale; this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
|
|
88
|
+
this.#enabledFor = enabledFor;
|
|
82
89
|
}
|
|
83
90
|
stop(): void { this.#stopped = true; }
|
|
84
91
|
|
|
@@ -169,7 +176,7 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
169
176
|
if (this.#stopped) break;
|
|
170
177
|
if (task.state !== 'scheduled' || flushed.has(task.id)) continue;
|
|
171
178
|
let feed: Awaited<ReturnType<FeedLoader>>;
|
|
172
|
-
try { feed = await this.#load(task.url, this.#config
|
|
179
|
+
try { feed = await this.#load(task.url, this.#config); }
|
|
173
180
|
catch { await this.#recordCheck(task, 'fetchFailed'); continue; }
|
|
174
181
|
if (this.#stopped) break;
|
|
175
182
|
// Keep only bounded identifiers, never arbitrary feed bodies, in durable state.
|
|
@@ -211,7 +218,10 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
211
218
|
async #deliver(claimed: Task, content: string): Promise<void> {
|
|
212
219
|
if (this.#stopped) return;
|
|
213
220
|
let state: Task['state'] = 'unknown', detail = taskDetail('unknown');
|
|
214
|
-
|
|
221
|
+
let enabled: boolean;
|
|
222
|
+
try { enabled = this.#enabledFor(claimed.target); } catch { enabled = true; }
|
|
223
|
+
if (!enabled) { state = 'scheduled'; detail = taskDetail('featureDisabled'); }
|
|
224
|
+
else try {
|
|
215
225
|
const id = await this.#outbound.sendTarget!(claimed.target, content, { locale: resolveLocale(claimed.locale) });
|
|
216
226
|
if (id) { state = 'scheduled'; detail = taskDetail('delivered'); }
|
|
217
227
|
} catch (error) {
|
package/src/console.ts
CHANGED
|
@@ -39,6 +39,8 @@ function resolveRssRuntime(call: PluginConsoleRpcCall, fallback?: RssRuntime): R
|
|
|
39
39
|
|
|
40
40
|
export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleRpc {
|
|
41
41
|
const rpc = definePluginConsoleRpc({
|
|
42
|
+
// Day-to-day edits belong to operators; `:write` would otherwise infer `manage` (ADR 0049).
|
|
43
|
+
permissions: [{ id: 'rss:write', level: 'operate', description: '编辑订阅' }],
|
|
42
44
|
status: {
|
|
43
45
|
page: 'index',
|
|
44
46
|
requiredPermissions: ['rss:read'],
|
package/src/feed.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { rssMessages as t } from './i18n.js';
|
|
|
4
4
|
* RSS feed fetch/preview helpers (no Plugin host).
|
|
5
5
|
*/
|
|
6
6
|
import Parser from 'rss-parser';
|
|
7
|
+
import { fetchPublic, readCappedBody, type HostLookup } from './public-fetch.js';
|
|
8
|
+
|
|
9
|
+
/** Feeds larger than this are refused instead of buffered. */
|
|
10
|
+
const MAX_FEED_BYTES = 5 * 1024 * 1024;
|
|
7
11
|
|
|
8
12
|
export interface FeedItem {
|
|
9
13
|
title: string;
|
|
@@ -20,6 +24,8 @@ export interface RssConfig {
|
|
|
20
24
|
readonly timeout: number;
|
|
21
25
|
readonly maxItemsPerPoll?: number;
|
|
22
26
|
readonly fetchTimeoutMs?: number;
|
|
27
|
+
/** Let feeds live on loopback/LAN addresses (self-hosted RSSHub and the like). Default false. */
|
|
28
|
+
readonly allowPrivateNetwork?: boolean;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const DEFAULT_RSS_CONFIG: RssConfig = {
|
|
@@ -79,18 +85,35 @@ export function stripHtml(html: string): string {
|
|
|
79
85
|
return out;
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Fetch and parse a feed. The URL comes from chat users, so it is requested through the
|
|
90
|
+
* public-address policy (every redirect hop re-checked), with one timeout for the whole
|
|
91
|
+
* exchange and a body cap.
|
|
92
|
+
*/
|
|
82
93
|
export async function fetchFeed(
|
|
83
94
|
url: string,
|
|
84
|
-
timeout = DEFAULT_RSS_CONFIG
|
|
95
|
+
options: number | Pick<RssConfig, 'timeout' | 'allowPrivateNetwork'> = DEFAULT_RSS_CONFIG,
|
|
96
|
+
lookup?: HostLookup,
|
|
85
97
|
): Promise<{ title: string; items: FeedItem[] }> {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
const { timeout, allowPrivateNetwork = false } = typeof options === 'number' ? { timeout: options } : options;
|
|
99
|
+
const { response } = await fetchPublic(url, {
|
|
100
|
+
timeoutMs: timeout,
|
|
101
|
+
lookup,
|
|
102
|
+
allowPrivateNetwork,
|
|
103
|
+
init: {
|
|
104
|
+
headers: {
|
|
105
|
+
'User-Agent': 'ZhinBot-RSS/1.0',
|
|
106
|
+
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
|
|
107
|
+
},
|
|
91
108
|
},
|
|
92
109
|
});
|
|
93
|
-
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
await response.body?.cancel();
|
|
112
|
+
throw new Error(`Status code ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
const body = await readCappedBody(response, MAX_FEED_BYTES);
|
|
115
|
+
if (!body) throw new Error(`Feed is larger than ${MAX_FEED_BYTES} bytes`);
|
|
116
|
+
const feed = await new Parser().parseString(body.toString('utf8'));
|
|
94
117
|
const items: FeedItem[] = (feed.items || []).slice(0, 20).map((item) => ({
|
|
95
118
|
title: (item.title || '').trim(),
|
|
96
119
|
link: (item.link || '').trim(),
|
package/src/poll.ts
CHANGED
|
@@ -8,10 +8,22 @@ import { fetchFeed, type FeedItem } from './feed.js';
|
|
|
8
8
|
import { getRssSeen, getRssSubs } from './db-store.js';
|
|
9
9
|
import type { RssRuntime } from './runtime.js';
|
|
10
10
|
|
|
11
|
+
/** Seen rows are kept this long after the item was last present in its feed. */
|
|
12
|
+
export const SEEN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Seen rows of items still in the feed are re-stamped at most this often. */
|
|
14
|
+
const SEEN_REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
11
16
|
function ts(): string {
|
|
12
17
|
return new Date().toISOString();
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
/** True for an item whose own publish date is past the retention window. Undated items are never stale. */
|
|
21
|
+
function isStale(item: FeedItem, cutoffMs: number): boolean {
|
|
22
|
+
if (!item.date) return false;
|
|
23
|
+
const published = Date.parse(item.date);
|
|
24
|
+
return Number.isFinite(published) && published < cutoffMs;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: number, locale: Locale = 'zh-CN'): string {
|
|
16
28
|
const lines = [t(locale, 'newItems', { title: feedTitle, count: items.length })];
|
|
17
29
|
for (const item of items.slice(0, maxItems)) {
|
|
@@ -26,19 +38,28 @@ export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: n
|
|
|
26
38
|
return lines.join('\n');
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
export async function markItemsSeen(
|
|
41
|
+
export async function markItemsSeen(
|
|
42
|
+
runtime: RssRuntime,
|
|
43
|
+
feedUrl: string,
|
|
44
|
+
items: FeedItem[],
|
|
45
|
+
/** Guids already stored for this feed; loaded once when the caller does not have them. */
|
|
46
|
+
knownGuids?: ReadonlySet<string>,
|
|
47
|
+
): Promise<void> {
|
|
30
48
|
const Seen = getRssSeen(runtime.db);
|
|
31
|
-
if (!Seen) return;
|
|
49
|
+
if (!Seen || items.length === 0) return;
|
|
50
|
+
const known = new Set(knownGuids
|
|
51
|
+
?? (await Seen.select().where({ feed_url: feedUrl })).map((row) => String(row.item_guid ?? '')));
|
|
52
|
+
// One timestamp per batch so cleanup can drop the whole batch with a single delete.
|
|
53
|
+
const seenAt = ts();
|
|
32
54
|
for (const item of items) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
55
|
+
if (known.has(item.guid)) continue;
|
|
56
|
+
known.add(item.guid);
|
|
57
|
+
await Seen.insert({
|
|
58
|
+
feed_url: feedUrl,
|
|
59
|
+
item_guid: item.guid,
|
|
60
|
+
item_title: item.title,
|
|
61
|
+
seen_at: seenAt,
|
|
62
|
+
});
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
|
@@ -110,15 +131,28 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
|
|
|
110
131
|
|
|
111
132
|
for (const url of options.urls) {
|
|
112
133
|
try {
|
|
113
|
-
const { title, items } = await fetchFeed(url, runtime.config
|
|
134
|
+
const { title, items } = await fetchFeed(url, runtime.config);
|
|
114
135
|
const seenRows = await Seen.select().where({ feed_url: url });
|
|
115
|
-
const
|
|
116
|
-
const
|
|
136
|
+
const seenAt = new Map(seenRows.map((r) => [String(r.item_guid ?? ''), String(r.seen_at ?? '')]));
|
|
137
|
+
const seenGuids = new Set(seenAt.keys());
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
// Keep the seen row of an item that is still listed, so it is not re-announced after cleanup.
|
|
140
|
+
const refreshBefore = new Date(now - SEEN_REFRESH_MS).toISOString();
|
|
141
|
+
const refreshed = ts();
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const stamp = seenAt.get(item.guid);
|
|
144
|
+
if (item.guid && stamp !== undefined && stamp < refreshBefore) {
|
|
145
|
+
await Seen.update({ seen_at: refreshed }).where({ feed_url: url, item_guid: item.guid });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// An item older than the retention window can no longer have a seen row; never treat it as new.
|
|
149
|
+
const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid)
|
|
150
|
+
&& !isStale(item, now - SEEN_RETENTION_MS));
|
|
117
151
|
if (newItems.length === 0) {
|
|
118
152
|
parts.push(t(locale, 'noNew', { title: title || url }));
|
|
119
153
|
continue;
|
|
120
154
|
}
|
|
121
|
-
await markItemsSeen(runtime, url, newItems);
|
|
155
|
+
await markItemsSeen(runtime, url, newItems, seenGuids);
|
|
122
156
|
totalNew += newItems.length;
|
|
123
157
|
const body = formatNewItems(title || url, newItems, runtime.config.maxItems, locale);
|
|
124
158
|
parts.push(body);
|
|
@@ -139,21 +173,22 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
|
|
|
139
173
|
}
|
|
140
174
|
|
|
141
175
|
/**
|
|
142
|
-
* Periodically clean seen records older than 7 days.
|
|
176
|
+
* Periodically clean seen records older than 7 days. Rows of items still listed in their
|
|
177
|
+
* feed are re-stamped by checkSubscriptions and therefore survive.
|
|
143
178
|
*/
|
|
144
179
|
export async function cleanOldSeen(runtime: RssRuntime): Promise<void> {
|
|
145
180
|
const Seen = getRssSeen(runtime.db);
|
|
146
181
|
if (!Seen) return;
|
|
147
182
|
try {
|
|
148
|
-
const
|
|
149
|
-
cutoff.setDate(cutoff.getDate() - 7);
|
|
150
|
-
const cutoffStr = cutoff.toISOString();
|
|
183
|
+
const cutoffStr = new Date(Date.now() - SEEN_RETENTION_MS).toISOString();
|
|
151
184
|
const all = await Seen.select();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
// The table has no id column. Rows of one batch share (feed_url, seen_at), so one delete per batch.
|
|
186
|
+
const batches = new Map<string, { feed_url: unknown; seen_at: string }>();
|
|
187
|
+
for (const row of all) {
|
|
188
|
+
if (typeof row.seen_at !== 'string' || row.seen_at >= cutoffStr) continue;
|
|
189
|
+
batches.set(`${String(row.feed_url)}\0${row.seen_at}`, { feed_url: row.feed_url, seen_at: row.seen_at });
|
|
156
190
|
}
|
|
191
|
+
for (const batch of batches.values()) await Seen.delete().where(batch);
|
|
157
192
|
} catch (e) {
|
|
158
193
|
// Cleanup failure must not break the main flow, but it must be visible.
|
|
159
194
|
console.warn(`[rss] cleanOldSeen failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound address policy for URLs that come from chat users or third-party pages (SSRF guard).
|
|
3
|
+
*
|
|
4
|
+
* This process connects to the target itself, so a URL is requested only when its host is a
|
|
5
|
+
* public name and every address it resolves to is public. Redirects are followed by hand and
|
|
6
|
+
* each hop is checked again. The check and the connection use separate DNS lookups, so a
|
|
7
|
+
* rebinding resolver can still race them; that needs a connection-level lookup hook.
|
|
8
|
+
*
|
|
9
|
+
* Plugins may not depend on each other, so this small module is duplicated in the plugins that
|
|
10
|
+
* fetch user-supplied URLs. Keep the copies identical.
|
|
11
|
+
*/
|
|
12
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
13
|
+
import { BlockList, isIP } from 'node:net';
|
|
14
|
+
|
|
15
|
+
export class BlockedUrlError extends Error {
|
|
16
|
+
readonly code = 'blocked_url';
|
|
17
|
+
constructor(reason: string) {
|
|
18
|
+
super(`refusing to fetch: ${reason}`);
|
|
19
|
+
this.name = 'BlockedUrlError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const blockedV4 = new BlockList();
|
|
24
|
+
for (const [base, bits] of [
|
|
25
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
26
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
|
|
27
|
+
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
28
|
+
] as const) blockedV4.addSubnet(base, bits, 'ipv4');
|
|
29
|
+
// Host-internal endpoints that sit in otherwise public space (Alibaba metadata, Azure WireServer).
|
|
30
|
+
for (const address of ['100.100.100.200', '168.63.129.16']) blockedV4.addAddress(address, 'ipv4');
|
|
31
|
+
|
|
32
|
+
// IPv6 is an allow-list: global unicast only, minus the special ranges inside it.
|
|
33
|
+
const globalV6 = new BlockList();
|
|
34
|
+
globalV6.addSubnet('2000::', 3, 'ipv6');
|
|
35
|
+
const blockedV6 = new BlockList();
|
|
36
|
+
for (const [base, bits] of [
|
|
37
|
+
['2001::', 32], ['2001:2::', 48], ['2001:10::', 28], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
38
|
+
] as const) blockedV6.addSubnet(base, bits, 'ipv6');
|
|
39
|
+
const mappedV4 = new BlockList();
|
|
40
|
+
mappedV4.addSubnet('::ffff:0:0', 96, 'ipv6');
|
|
41
|
+
|
|
42
|
+
/** True when a literal IP address is globally routable and not a known host-internal endpoint. */
|
|
43
|
+
export function isPublicAddress(address: string): boolean {
|
|
44
|
+
const family = isIP(address);
|
|
45
|
+
if (family === 4) return !blockedV4.check(address, 'ipv4');
|
|
46
|
+
if (family !== 6) return false;
|
|
47
|
+
// BlockList matches IPv4 rules against IPv4-mapped IPv6 addresses.
|
|
48
|
+
if (mappedV4.check(address, 'ipv6')) return !blockedV4.check(address, 'ipv6');
|
|
49
|
+
return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BLOCKED_SUFFIXES = [
|
|
53
|
+
'localhost', 'local', 'localdomain', 'internal', 'intranet', 'private', 'corp', 'home', 'lan',
|
|
54
|
+
'arpa', 'test', 'invalid', 'example', 'onion', 'metadata.goog',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Resolves a host name to every address it maps to. */
|
|
58
|
+
export type HostLookup = (hostname: string) => Promise<readonly string[]>;
|
|
59
|
+
|
|
60
|
+
const systemLookup: HostLookup = async (hostname) =>
|
|
61
|
+
(await dnsLookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse `input` and refuse anything that is not an http(s) URL on a public host.
|
|
65
|
+
* Fails closed: an unresolvable name, or a name with any non-public address, is refused.
|
|
66
|
+
*/
|
|
67
|
+
export async function assertPublicUrl(
|
|
68
|
+
input: string | URL,
|
|
69
|
+
lookup: HostLookup = systemLookup,
|
|
70
|
+
allowPrivateNetwork = false,
|
|
71
|
+
): Promise<URL> {
|
|
72
|
+
let url: URL;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(input);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new BlockedUrlError('not a valid URL');
|
|
77
|
+
}
|
|
78
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new BlockedUrlError('only http and https are allowed');
|
|
79
|
+
if (url.username || url.password) throw new BlockedUrlError('credentials in the URL are not allowed');
|
|
80
|
+
// Operator opt-in (never chat users): scheme and credential rules still apply, the address policy does not.
|
|
81
|
+
if (allowPrivateNetwork) return url;
|
|
82
|
+
// The WHATWG parser already canonicalised odd IPv4 spellings (0x7f.1, 2130706433) to dotted quads.
|
|
83
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
84
|
+
if (isIP(host)) {
|
|
85
|
+
if (!isPublicAddress(host)) throw new BlockedUrlError(`${host} is not a public address`);
|
|
86
|
+
return url;
|
|
87
|
+
}
|
|
88
|
+
if (!host.includes('.') || BLOCKED_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))) {
|
|
89
|
+
throw new BlockedUrlError(`${host} is not a public host name`);
|
|
90
|
+
}
|
|
91
|
+
let addresses: readonly string[];
|
|
92
|
+
try {
|
|
93
|
+
addresses = await lookup(host);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new BlockedUrlError(`${host} could not be resolved`);
|
|
96
|
+
}
|
|
97
|
+
if (addresses.length === 0) throw new BlockedUrlError(`${host} could not be resolved`);
|
|
98
|
+
const internal = addresses.find((address) => !isPublicAddress(address));
|
|
99
|
+
if (internal !== undefined) throw new BlockedUrlError(`${host} resolves to a non-public address`);
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PublicFetchOptions {
|
|
104
|
+
/** Bounds every hop and the body read of the returned response. */
|
|
105
|
+
readonly timeoutMs?: number;
|
|
106
|
+
readonly maxRedirects?: number;
|
|
107
|
+
readonly init?: Omit<RequestInit, 'redirect' | 'signal'>;
|
|
108
|
+
/** Extra per-hop rule on top of the address policy (for example a host allow-list). */
|
|
109
|
+
readonly allow?: (url: URL) => boolean;
|
|
110
|
+
readonly lookup?: HostLookup;
|
|
111
|
+
/** Skip the public-address requirement. Only for an explicit operator setting; default false. */
|
|
112
|
+
readonly allowPrivateNetwork?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface PublicResponse {
|
|
116
|
+
readonly response: Response;
|
|
117
|
+
/** URL of the hop that produced `response`. */
|
|
118
|
+
readonly url: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fetch with redirects followed by hand so that every hop passes the address policy first. */
|
|
122
|
+
export async function fetchPublic(input: string, options: PublicFetchOptions = {}): Promise<PublicResponse> {
|
|
123
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 10_000);
|
|
124
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
125
|
+
let current = input;
|
|
126
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
127
|
+
const url = await assertPublicUrl(current, options.lookup, options.allowPrivateNetwork);
|
|
128
|
+
if (options.allow && !options.allow(url)) throw new BlockedUrlError(`${url.hostname} is outside the allow-list`);
|
|
129
|
+
const response = await fetch(url.href, { ...options.init, redirect: 'manual', signal });
|
|
130
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null;
|
|
131
|
+
if (!location) return { response, url: url.href };
|
|
132
|
+
await response.body?.cancel();
|
|
133
|
+
current = new URL(location, url).href;
|
|
134
|
+
}
|
|
135
|
+
throw new BlockedUrlError('too many redirects');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Read a body with a byte cap; the stream is cancelled and `undefined` returned once it is exceeded. */
|
|
139
|
+
export async function readCappedBody(response: Response, maxBytes: number): Promise<Buffer | undefined> {
|
|
140
|
+
const declared = Number(response.headers.get('content-length'));
|
|
141
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
142
|
+
await response.body?.cancel();
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (!response.body) return Buffer.alloc(0);
|
|
146
|
+
const chunks: Uint8Array[] = [];
|
|
147
|
+
let total = 0;
|
|
148
|
+
const reader = response.body.getReader();
|
|
149
|
+
for (;;) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
total += value.byteLength;
|
|
153
|
+
if (total > maxBytes) {
|
|
154
|
+
await reader.cancel();
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
chunks.push(value);
|
|
158
|
+
}
|
|
159
|
+
return Buffer.concat(chunks);
|
|
160
|
+
}
|