@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.
@@ -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
- constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN') {
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.timeout); }
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
- try {
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.timeout,
95
+ options: number | Pick<RssConfig, 'timeout' | 'allowPrivateNetwork'> = DEFAULT_RSS_CONFIG,
96
+ lookup?: HostLookup,
85
97
  ): Promise<{ title: string; items: FeedItem[] }> {
86
- const parser = new Parser({
87
- timeout,
88
- headers: {
89
- 'User-Agent': 'ZhinBot-RSS/1.0',
90
- Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
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
- const feed = await parser.parseURL(url);
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(runtime: RssRuntime, feedUrl: string, items: FeedItem[]): Promise<void> {
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
- const dup = await Seen.select().where({ feed_url: feedUrl, item_guid: item.guid });
34
- if (dup.length === 0) {
35
- await Seen.insert({
36
- feed_url: feedUrl,
37
- item_guid: item.guid,
38
- item_title: item.title,
39
- seen_at: ts(),
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.timeout);
134
+ const { title, items } = await fetchFeed(url, runtime.config);
114
135
  const seenRows = await Seen.select().where({ feed_url: url });
115
- const seenGuids = new Set(seenRows.map((r) => String(r.item_guid ?? '')));
116
- const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid));
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 cutoff = new Date();
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
- const expired = all.filter((r) => typeof r.seen_at === 'string' && r.seen_at < cutoffStr);
153
- for (const row of expired) {
154
- // 表结构没有 id 列,按业务键 (feed_url, item_guid) 删除。
155
- await Seen.delete().where({ feed_url: row.feed_url, item_guid: row.item_guid });
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
+ }