@khorsheed/dsh-reader 0.2.0 → 0.3.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/CHANGELOG.md +14 -0
- package/README.en.md +108 -54
- package/README.i18n.yaml +2 -2
- package/README.md +108 -54
- package/lib/client.js +2703 -1371
- package/lib/index.js +486 -0
- package/lib/typert.host.js +91 -31
- package/lib/typert.remote-client.d.ts +3 -1
- package/lib/typert.remote-client.js +69 -31
- package/lib/types/client/ReaderPane.js +46 -1
- package/lib/types/client/contract.d.ts +9 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/image-fetch.d.ts +81 -0
- package/lib/types/image-fetch.js +385 -0
- package/lib/types/remote.d.ts +9 -1
- package/lib/types/remote.js +11 -0
- package/lib/types/service.d.ts +30 -1
- package/lib/types/service.js +40 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +12 -11
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/** An article figure is a few hundred KB; 8 MiB leaves room for print-resolution PNGs. */
|
|
2
|
+
export const IMAGE_FETCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
3
|
+
/** One image fetch never hangs the rescue: the pane's picture stays broken instead. */
|
|
4
|
+
export const IMAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
5
|
+
/** Redirect hops per image, each re-validated end to end (the classic SSRF hop). */
|
|
6
|
+
export const IMAGE_FETCH_MAX_REDIRECTS = 3;
|
|
7
|
+
/** An honest agent line — bot walls are reported, never impersonated around. */
|
|
8
|
+
const IMAGE_FETCH_USER_AGENT = 'dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)';
|
|
9
|
+
/**
|
|
10
|
+
* The raster/vector types a rescued image may declare. The pane re-points the
|
|
11
|
+
* failed `<img>` at these bytes, so an SVG rides an image context (which never
|
|
12
|
+
* scripts); anything else the origin answers — HTML above all — is refused
|
|
13
|
+
* before the body is read.
|
|
14
|
+
*/
|
|
15
|
+
const IMAGE_MIME = /^image\/(?:png|jpe?g|gif|webp|avif|svg\+xml|bmp|x-icon|vnd\.microsoft\.icon)$/i;
|
|
16
|
+
/**
|
|
17
|
+
* The byte budget of the in-memory rescue cache (wire size — base64 is what
|
|
18
|
+
* the map actually holds). A figure-heavy article is a few MB; the budget
|
|
19
|
+
* covers several of them, and the oldest entry leaves first.
|
|
20
|
+
*/
|
|
21
|
+
export const IMAGE_CACHE_BUDGET_BYTES = 32 * 1024 * 1024;
|
|
22
|
+
/**
|
|
23
|
+
* The rescue cache: a pane remount (the sidebar unmounts wholesale when
|
|
24
|
+
* another panel takes over) re-fails every CORP-blocked image, and this map is
|
|
25
|
+
* what keeps that a memory read instead of a dozen outbound requests. LRU by
|
|
26
|
+
* insertion refresh, bounded by bytes.
|
|
27
|
+
*/
|
|
28
|
+
export class ImageFetchCache {
|
|
29
|
+
budgetBytes;
|
|
30
|
+
entries = new Map();
|
|
31
|
+
bytes = 0;
|
|
32
|
+
constructor(budgetBytes = IMAGE_CACHE_BUDGET_BYTES) {
|
|
33
|
+
this.budgetBytes = budgetBytes;
|
|
34
|
+
}
|
|
35
|
+
/** The cached bytes for one URL, refreshing its recency. */
|
|
36
|
+
get(url) {
|
|
37
|
+
const hit = this.entries.get(url);
|
|
38
|
+
if (hit === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
this.entries.delete(url);
|
|
41
|
+
this.entries.set(url, hit);
|
|
42
|
+
return hit;
|
|
43
|
+
}
|
|
44
|
+
/** Cache one fetched image, evicting the oldest entries until it fits. */
|
|
45
|
+
set(url, mime, base64) {
|
|
46
|
+
if (base64.length > this.budgetBytes)
|
|
47
|
+
return;
|
|
48
|
+
const existing = this.entries.get(url);
|
|
49
|
+
if (existing !== undefined) {
|
|
50
|
+
this.bytes -= existing.base64.length;
|
|
51
|
+
this.entries.delete(url);
|
|
52
|
+
}
|
|
53
|
+
while (this.bytes + base64.length > this.budgetBytes) {
|
|
54
|
+
const oldest = this.entries.keys().next();
|
|
55
|
+
if (oldest.done)
|
|
56
|
+
break;
|
|
57
|
+
const dropped = this.entries.get(oldest.value);
|
|
58
|
+
this.entries.delete(oldest.value);
|
|
59
|
+
this.bytes -= dropped?.base64.length ?? 0;
|
|
60
|
+
}
|
|
61
|
+
this.entries.set(url, { mime, base64 });
|
|
62
|
+
this.bytes += base64.length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fetch one image, policy-checked end to end.
|
|
67
|
+
*
|
|
68
|
+
* Errors are values: the pane's only answer to a failed rescue is leaving the
|
|
69
|
+
* broken image as it was, so the reason string is for logs, not for display.
|
|
70
|
+
*
|
|
71
|
+
* @param rawUrl - the image URL the pane failed on.
|
|
72
|
+
* @param seams - resolver/lookup overrides (tests only; production pins).
|
|
73
|
+
* @param limits - byte/timeout/redirect overrides (tests only).
|
|
74
|
+
* @returns the image bytes base64-encoded with their declared type, or why not.
|
|
75
|
+
*/
|
|
76
|
+
export async function fetchImageBytes(rawUrl, seams = {}, limits = {}) {
|
|
77
|
+
const maxBytes = limits.maxBytes ?? IMAGE_FETCH_MAX_BYTES;
|
|
78
|
+
const timeoutMs = limits.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS;
|
|
79
|
+
const maxRedirects = limits.maxRedirects ?? IMAGE_FETCH_MAX_REDIRECTS;
|
|
80
|
+
let current = rawUrl;
|
|
81
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
82
|
+
const gate = await checkImageTarget(current, seams.resolve);
|
|
83
|
+
if (!gate.ok)
|
|
84
|
+
return { ok: false, url: rawUrl, error: gate.error };
|
|
85
|
+
const response = await requestOnce(gate.url, gate.addresses, timeoutMs, seams.lookup);
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
return { ok: false, url: rawUrl, error: response.error };
|
|
88
|
+
const { message } = response;
|
|
89
|
+
const status = message.statusCode ?? 0;
|
|
90
|
+
if (status >= 300 && status < 400) {
|
|
91
|
+
message.resume();
|
|
92
|
+
const location = message.headers['location'];
|
|
93
|
+
if (typeof location !== 'string' || location.length === 0) {
|
|
94
|
+
return { ok: false, url: rawUrl, error: `HTTP ${status} without a location` };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
current = new URL(location, current).toString();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { ok: false, url: rawUrl, error: `HTTP ${status} to an unparseable location` };
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (status < 200 || status >= 300) {
|
|
105
|
+
message.resume();
|
|
106
|
+
return { ok: false, url: rawUrl, error: `HTTP ${status}` };
|
|
107
|
+
}
|
|
108
|
+
const mime = (message.headers['content-type'] ?? '').replace(/;.*$/s, '').trim().toLowerCase();
|
|
109
|
+
if (!IMAGE_MIME.test(mime)) {
|
|
110
|
+
message.resume();
|
|
111
|
+
return { ok: false, url: rawUrl, error: `the response is not an image (content-type "${mime || 'unknown'}")` };
|
|
112
|
+
}
|
|
113
|
+
const declared = Number(message.headers['content-length'] ?? Number.NaN);
|
|
114
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
115
|
+
message.resume();
|
|
116
|
+
return { ok: false, url: rawUrl, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` };
|
|
117
|
+
}
|
|
118
|
+
const body = await readBody(message, maxBytes);
|
|
119
|
+
if (!body.ok)
|
|
120
|
+
return { ok: false, url: rawUrl, error: body.error };
|
|
121
|
+
return { ok: true, url: rawUrl, mime, base64: body.base64 };
|
|
122
|
+
}
|
|
123
|
+
return { ok: false, url: rawUrl, error: `more than ${maxRedirects} redirects` };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The syntax gate plus the address gate, in the order capture's navigation
|
|
127
|
+
* policy runs them. The address set travels with the verdict: the transport
|
|
128
|
+
* dials exactly these, so the hostname is never resolved twice.
|
|
129
|
+
*/
|
|
130
|
+
async function checkImageTarget(raw, resolve) {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(raw);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { ok: false, error: 'invalid-url' };
|
|
137
|
+
}
|
|
138
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
139
|
+
return { ok: false, error: `only http(s) images can be rescued, not ${url.protocol}` };
|
|
140
|
+
}
|
|
141
|
+
if (url.username !== '' || url.password !== '') {
|
|
142
|
+
return { ok: false, error: 'URLs with embedded credentials are never fetched' };
|
|
143
|
+
}
|
|
144
|
+
const hostname = url.hostname.replace(/\.$/, '');
|
|
145
|
+
if (hostname === '')
|
|
146
|
+
return { ok: false, error: 'invalid-url' };
|
|
147
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
148
|
+
return { ok: false, error: 'localhost is never fetched' };
|
|
149
|
+
}
|
|
150
|
+
if (isIpLiteral(hostname)) {
|
|
151
|
+
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
|
152
|
+
const cls = classifyAddress(bare);
|
|
153
|
+
if (cls !== 'public')
|
|
154
|
+
return { ok: false, error: `${bare} is a ${cls} address` };
|
|
155
|
+
return { ok: true, url, addresses: [{ address: bare, family: bare.includes(':') ? 6 : 4 }] };
|
|
156
|
+
}
|
|
157
|
+
const resolver = resolve ?? defaultResolve;
|
|
158
|
+
let addresses;
|
|
159
|
+
try {
|
|
160
|
+
addresses = await resolver(hostname.replace(/^\[(.*)\]$/, '$1'));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return { ok: false, error: `cannot resolve ${hostname}: ${error instanceof Error ? error.message : String(error)}` };
|
|
164
|
+
}
|
|
165
|
+
if (addresses.length === 0)
|
|
166
|
+
return { ok: false, error: `cannot resolve ${hostname}: no addresses` };
|
|
167
|
+
for (const { address } of addresses) {
|
|
168
|
+
const cls = classifyAddress(address);
|
|
169
|
+
if (cls !== 'public')
|
|
170
|
+
return { ok: false, error: `${hostname} resolves to a ${cls} address` };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, url, addresses };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Issue one GET against the validated address set.
|
|
176
|
+
*
|
|
177
|
+
* The `lookup` handed to the transport answers with the validated addresses
|
|
178
|
+
* and nothing else — DNS is consulted once, by the gate, and the connection
|
|
179
|
+
* cannot re-resolve the hostname into a private network (the rebinding window
|
|
180
|
+
* a plain fetch leaves open). Node still verifies TLS against the HOSTNAME;
|
|
181
|
+
* the lookup only maps it to an address.
|
|
182
|
+
*/
|
|
183
|
+
function requestOnce(url, addresses, timeoutMs, lookup) {
|
|
184
|
+
return new Promise((resolvePromise) => {
|
|
185
|
+
void (async () => {
|
|
186
|
+
const transport = url.protocol === 'https:'
|
|
187
|
+
? await import('node:https')
|
|
188
|
+
: await import('node:http');
|
|
189
|
+
const request = transport.request({
|
|
190
|
+
protocol: url.protocol,
|
|
191
|
+
// Bracketed IPv6 literals are a URL spelling, not a host option.
|
|
192
|
+
hostname: url.hostname.replace(/^\[(.*)\]$/, '$1'),
|
|
193
|
+
port: url.port,
|
|
194
|
+
path: `${url.pathname}${url.search}`,
|
|
195
|
+
method: 'GET',
|
|
196
|
+
headers: {
|
|
197
|
+
accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8',
|
|
198
|
+
'accept-encoding': 'identity',
|
|
199
|
+
'user-agent': IMAGE_FETCH_USER_AGENT,
|
|
200
|
+
},
|
|
201
|
+
lookup: lookup ?? pinnedLookup(addresses),
|
|
202
|
+
}, (message) => resolvePromise({ ok: true, message }));
|
|
203
|
+
request.setTimeout(timeoutMs, () => {
|
|
204
|
+
request.destroy(new Error(`the image fetch timed out after ${timeoutMs} ms`));
|
|
205
|
+
});
|
|
206
|
+
request.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
|
|
207
|
+
request.end();
|
|
208
|
+
})().catch((error) => resolvePromise({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** The lookup that pins a request to the gate's validated answers. */
|
|
212
|
+
function pinnedLookup(addresses) {
|
|
213
|
+
return (_hostname, options, callback) => {
|
|
214
|
+
if (options.all === true) {
|
|
215
|
+
callback(null, addresses.map(({ address, family }) => ({ address, family })));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const first = addresses[0];
|
|
219
|
+
callback(null, first.address, first.family);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** The body, capped; over the cap is a refusal, not a truncation. */
|
|
223
|
+
function readBody(message, maxBytes) {
|
|
224
|
+
return new Promise((resolvePromise) => {
|
|
225
|
+
const chunks = [];
|
|
226
|
+
let received = 0;
|
|
227
|
+
message.on('data', (chunk) => {
|
|
228
|
+
received += chunk.byteLength;
|
|
229
|
+
if (received > maxBytes) {
|
|
230
|
+
message.destroy();
|
|
231
|
+
resolvePromise({ ok: false, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
});
|
|
236
|
+
message.on('end', () => resolvePromise({ ok: true, base64: Buffer.concat(chunks).toString('base64') }));
|
|
237
|
+
message.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** The system resolver, bound lazily so importing this module never touches `node:dns`. */
|
|
241
|
+
async function defaultResolve(hostname) {
|
|
242
|
+
const dns = await import('node:dns');
|
|
243
|
+
return dns.promises.lookup(hostname, { all: true, verbatim: true });
|
|
244
|
+
}
|
|
245
|
+
/** Parse a canonical IPv4 string into its 32-bit value, or undefined. */
|
|
246
|
+
function parseIpv4(address) {
|
|
247
|
+
const parts = address.split('.');
|
|
248
|
+
if (parts.length !== 4)
|
|
249
|
+
return undefined;
|
|
250
|
+
let value = 0;
|
|
251
|
+
for (const part of parts) {
|
|
252
|
+
if (!/^\d{1,3}$/.test(part))
|
|
253
|
+
return undefined;
|
|
254
|
+
if (part.length > 1 && part.startsWith('0'))
|
|
255
|
+
return undefined; // legacy octal-looking octets: fail closed
|
|
256
|
+
const octet = Number(part);
|
|
257
|
+
if (octet > 255)
|
|
258
|
+
return undefined;
|
|
259
|
+
value = value * 256 + octet;
|
|
260
|
+
}
|
|
261
|
+
return value >>> 0;
|
|
262
|
+
}
|
|
263
|
+
/** Expand an IPv6 string into its eight 16-bit groups, or undefined when malformed. */
|
|
264
|
+
function parseIpv6Groups(address) {
|
|
265
|
+
// Strip a zone id (link-local literals like fe80::1%eth0).
|
|
266
|
+
const zoneless = address.split('%')[0];
|
|
267
|
+
if (!/^[0-9a-fA-F:.]+$/.test(zoneless))
|
|
268
|
+
return undefined;
|
|
269
|
+
const halves = zoneless.split('::');
|
|
270
|
+
if (halves.length > 2)
|
|
271
|
+
return undefined;
|
|
272
|
+
const parseHalf = (half) => {
|
|
273
|
+
if (half === '')
|
|
274
|
+
return [];
|
|
275
|
+
const groups = [];
|
|
276
|
+
const parts = half.split(':');
|
|
277
|
+
for (const [index, part] of parts.entries()) {
|
|
278
|
+
// An embedded dotted-quad tail (deprecated compatible / mapped forms).
|
|
279
|
+
if (part.includes('.')) {
|
|
280
|
+
if (index !== parts.length - 1)
|
|
281
|
+
return undefined;
|
|
282
|
+
const v4 = parseIpv4(part);
|
|
283
|
+
if (v4 === undefined)
|
|
284
|
+
return undefined;
|
|
285
|
+
groups.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
289
|
+
return undefined;
|
|
290
|
+
groups.push(parseInt(part, 16));
|
|
291
|
+
}
|
|
292
|
+
return groups;
|
|
293
|
+
};
|
|
294
|
+
const head = parseHalf(halves[0]);
|
|
295
|
+
if (head === undefined)
|
|
296
|
+
return undefined;
|
|
297
|
+
if (halves.length === 1)
|
|
298
|
+
return head.length === 8 ? head : undefined;
|
|
299
|
+
const tail = parseHalf(halves[1]);
|
|
300
|
+
if (tail === undefined)
|
|
301
|
+
return undefined;
|
|
302
|
+
const missing = 8 - head.length - tail.length;
|
|
303
|
+
if (missing < 0 || (missing === 0 && halves[1] !== ''))
|
|
304
|
+
return undefined;
|
|
305
|
+
return [...head, ...Array(missing).fill(0), ...tail];
|
|
306
|
+
}
|
|
307
|
+
/** The embedded IPv4 value of an IPv4-mapped, NAT64, or 6to4 IPv6 address. */
|
|
308
|
+
function embeddedIpv4(groups) {
|
|
309
|
+
// ::ffff:a.b.c.d (IPv4-mapped): groups 0-4 zero, group 5 0xffff.
|
|
310
|
+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
|
|
311
|
+
return ((groups[6] << 16) | groups[7]) >>> 0;
|
|
312
|
+
}
|
|
313
|
+
// 64:ff9b::/96 (NAT64 well-known prefix): the v4 address is the last 32 bits.
|
|
314
|
+
if (groups[0] === 0x0064 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0)) {
|
|
315
|
+
return ((groups[6] << 16) | groups[7]) >>> 0;
|
|
316
|
+
}
|
|
317
|
+
// 2002::/16 (6to4): the v4 address is groups 1-2.
|
|
318
|
+
if (groups[0] === 0x2002) {
|
|
319
|
+
return ((groups[1] << 16) | groups[2]) >>> 0;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
/** Classify a canonical dotted-quad IPv4 value. */
|
|
324
|
+
function classifyIpv4(value) {
|
|
325
|
+
const a = (value >>> 24) & 0xff;
|
|
326
|
+
const b = (value >>> 16) & 0xff;
|
|
327
|
+
if (a === 0)
|
|
328
|
+
return 'unspecified'; // 0.0.0.0/8 "this host"
|
|
329
|
+
if (a === 127)
|
|
330
|
+
return 'loopback';
|
|
331
|
+
if (a === 10)
|
|
332
|
+
return 'private';
|
|
333
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
334
|
+
return 'private';
|
|
335
|
+
if (a === 192 && b === 168)
|
|
336
|
+
return 'private';
|
|
337
|
+
if (a === 169 && b === 254)
|
|
338
|
+
return 'link-local'; // incl. 169.254.169.254 cloud metadata
|
|
339
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
340
|
+
return 'private'; // CGNAT shared space
|
|
341
|
+
if (a === 192 && b === 0)
|
|
342
|
+
return 'reserved'; // 192.0.0.0/24 protocol assignments, 192.0.2.0/24 documentation
|
|
343
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
344
|
+
return 'reserved'; // benchmarking
|
|
345
|
+
if (a === 198 && b === 51)
|
|
346
|
+
return 'reserved'; // documentation
|
|
347
|
+
if (a === 203 && b === 0)
|
|
348
|
+
return 'reserved'; // documentation
|
|
349
|
+
if (a >= 224 && a <= 239)
|
|
350
|
+
return 'multicast';
|
|
351
|
+
if (a >= 240)
|
|
352
|
+
return 'reserved';
|
|
353
|
+
return 'public';
|
|
354
|
+
}
|
|
355
|
+
/** Classify one resolved address (IPv4 or IPv6, canonical or transition form). */
|
|
356
|
+
function classifyAddress(address) {
|
|
357
|
+
const v4 = parseIpv4(address);
|
|
358
|
+
if (v4 !== undefined)
|
|
359
|
+
return classifyIpv4(v4);
|
|
360
|
+
const groups = parseIpv6Groups(address);
|
|
361
|
+
if (groups === undefined)
|
|
362
|
+
return 'reserved'; // unparseable is never public
|
|
363
|
+
const embedded = embeddedIpv4(groups);
|
|
364
|
+
if (embedded !== undefined)
|
|
365
|
+
return classifyIpv4(embedded);
|
|
366
|
+
const first = groups[0];
|
|
367
|
+
if (groups.every((g) => g === 0))
|
|
368
|
+
return 'unspecified'; // ::
|
|
369
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
|
|
370
|
+
return 'loopback'; // ::1
|
|
371
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
372
|
+
return 'private'; // fc00::/7 unique local
|
|
373
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
374
|
+
return 'link-local'; // fe80::/10
|
|
375
|
+
if ((first & 0xff00) === 0xff00)
|
|
376
|
+
return 'multicast'; // ff00::/8
|
|
377
|
+
if (first === 0x2001 && groups[1] === 0x0db8)
|
|
378
|
+
return 'reserved'; // documentation
|
|
379
|
+
return 'public';
|
|
380
|
+
}
|
|
381
|
+
/** Whether one hostname string is already an IP literal (bracketed v6 or dotted v4). */
|
|
382
|
+
function isIpLiteral(hostname) {
|
|
383
|
+
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
384
|
+
return parseIpv4(bare) !== undefined || parseIpv6Groups(bare) !== undefined;
|
|
385
|
+
}
|
package/lib/types/remote.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
13
13
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
14
|
-
import type { ReaderAddFailure, ReaderAddOutcome, ReaderAddRefusal, ReaderAnnotationOutcome, ReaderBackfillCandidate, ReaderEntryBodyView, ReaderEntryFetchState, ReaderBody, ReaderCapabilities, ReaderEntryTranslationView, ReaderMutationOutcome, ReaderRecentEntry, ReaderRefreshResult, ReaderSentenceLearn, ReaderSourceSummary, ReaderStorageStats, ReaderTag } from './types.ts';
|
|
14
|
+
import type { ReaderAddFailure, ReaderAddOutcome, ReaderAddRefusal, ReaderAnnotationOutcome, ReaderBackfillCandidate, ReaderEntryBodyView, ReaderImageResult, ReaderEntryFetchState, ReaderBody, ReaderCapabilities, ReaderEntryTranslationView, ReaderMutationOutcome, ReaderRecentEntry, ReaderRefreshResult, ReaderSentenceLearn, ReaderSourceSummary, ReaderStorageStats, ReaderTag } from './types.ts';
|
|
15
15
|
declare module '@deepseek-ai/cordis' {
|
|
16
16
|
interface Context {
|
|
17
17
|
readerRemote: ReaderRemoteService;
|
|
@@ -80,6 +80,14 @@ export declare class ReaderRemoteService extends TypertRemoteService<ReaderRemot
|
|
|
80
80
|
truncated?: boolean;
|
|
81
81
|
error?: string;
|
|
82
82
|
}>;
|
|
83
|
+
/**
|
|
84
|
+
* Fetch one image the browser could not load cross-origin (CORP /
|
|
85
|
+
* hotlink-protected), so the pane can re-point the failed `<img>` at the
|
|
86
|
+
* bytes. Called per failed image, never for a healthy one.
|
|
87
|
+
*/
|
|
88
|
+
fetchImage(request: {
|
|
89
|
+
url: string;
|
|
90
|
+
}): Promise<ReaderImageResult>;
|
|
83
91
|
/** Cache the markup the browser extracted for one entry. */
|
|
84
92
|
storeEntryBody(request: {
|
|
85
93
|
entryId: string;
|
package/lib/types/remote.js
CHANGED
|
@@ -46,6 +46,7 @@ let ReaderRemoteService = (() => {
|
|
|
46
46
|
let _getBodies_decorators;
|
|
47
47
|
let _getEntryBody_decorators;
|
|
48
48
|
let _fetchEntryBody_decorators;
|
|
49
|
+
let _fetchImage_decorators;
|
|
49
50
|
let _storeEntryBody_decorators;
|
|
50
51
|
let _entryFetchStates_decorators;
|
|
51
52
|
let _getRawBody_decorators;
|
|
@@ -80,6 +81,7 @@ let ReaderRemoteService = (() => {
|
|
|
80
81
|
_getBodies_decorators = [Remote('getBodies')];
|
|
81
82
|
_getEntryBody_decorators = [Remote('getEntryBody')];
|
|
82
83
|
_fetchEntryBody_decorators = [Remote('fetchEntryBody')];
|
|
84
|
+
_fetchImage_decorators = [Remote('fetchImage')];
|
|
83
85
|
_storeEntryBody_decorators = [Remote('storeEntryBody')];
|
|
84
86
|
_entryFetchStates_decorators = [Remote('entryFetchStates')];
|
|
85
87
|
_getRawBody_decorators = [Remote('getRawBody')];
|
|
@@ -111,6 +113,7 @@ let ReaderRemoteService = (() => {
|
|
|
111
113
|
__esDecorate(this, null, _getBodies_decorators, { kind: "method", name: "getBodies", static: false, private: false, access: { has: obj => "getBodies" in obj, get: obj => obj.getBodies }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
112
114
|
__esDecorate(this, null, _getEntryBody_decorators, { kind: "method", name: "getEntryBody", static: false, private: false, access: { has: obj => "getEntryBody" in obj, get: obj => obj.getEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
113
115
|
__esDecorate(this, null, _fetchEntryBody_decorators, { kind: "method", name: "fetchEntryBody", static: false, private: false, access: { has: obj => "fetchEntryBody" in obj, get: obj => obj.fetchEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
116
|
+
__esDecorate(this, null, _fetchImage_decorators, { kind: "method", name: "fetchImage", static: false, private: false, access: { has: obj => "fetchImage" in obj, get: obj => obj.fetchImage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
114
117
|
__esDecorate(this, null, _storeEntryBody_decorators, { kind: "method", name: "storeEntryBody", static: false, private: false, access: { has: obj => "storeEntryBody" in obj, get: obj => obj.storeEntryBody }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
115
118
|
__esDecorate(this, null, _entryFetchStates_decorators, { kind: "method", name: "entryFetchStates", static: false, private: false, access: { has: obj => "entryFetchStates" in obj, get: obj => obj.entryFetchStates }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
116
119
|
__esDecorate(this, null, _getRawBody_decorators, { kind: "method", name: "getRawBody", static: false, private: false, access: { has: obj => "getRawBody" in obj, get: obj => obj.getRawBody }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -181,6 +184,14 @@ let ReaderRemoteService = (() => {
|
|
|
181
184
|
fetchEntryBody(request) {
|
|
182
185
|
return this.core.fetchEntryBody(request);
|
|
183
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Fetch one image the browser could not load cross-origin (CORP /
|
|
189
|
+
* hotlink-protected), so the pane can re-point the failed `<img>` at the
|
|
190
|
+
* bytes. Called per failed image, never for a healthy one.
|
|
191
|
+
*/
|
|
192
|
+
fetchImage(request) {
|
|
193
|
+
return this.core.fetchImage(request);
|
|
194
|
+
}
|
|
184
195
|
/** Cache the markup the browser extracted for one entry. */
|
|
185
196
|
storeEntryBody(request) {
|
|
186
197
|
return this.core.storeEntryBody(request);
|
package/lib/types/service.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* @module @khorsheed/dsh-reader/service
|
|
36
36
|
*/
|
|
37
37
|
import type { Context } from '@deepseek-ai/cordis';
|
|
38
|
-
import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag } from './types.ts';
|
|
38
|
+
import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderImageResult, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag } from './types.ts';
|
|
39
39
|
/**
|
|
40
40
|
* How many entries one automatic backfill run may fetch.
|
|
41
41
|
*
|
|
@@ -78,6 +78,8 @@ export declare class ReaderService {
|
|
|
78
78
|
private disposed;
|
|
79
79
|
/** The document of record when no filesystem is mounted. */
|
|
80
80
|
private memoryDoc;
|
|
81
|
+
/** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
|
|
82
|
+
private readonly imageCache;
|
|
81
83
|
/**
|
|
82
84
|
* @param ctx - owning context.
|
|
83
85
|
* @param config - optional state-root override.
|
|
@@ -230,6 +232,33 @@ export declare class ReaderService {
|
|
|
230
232
|
truncated?: boolean;
|
|
231
233
|
error?: string;
|
|
232
234
|
}>;
|
|
235
|
+
/**
|
|
236
|
+
* One image the browser could not load cross-origin, fetched host-side.
|
|
237
|
+
*
|
|
238
|
+
* The pane calls this only for an `<img>` that already FAILED: the page's
|
|
239
|
+
* bytes arrived fine (a direct fetch gets a 200) but the origin answers them
|
|
240
|
+
* with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
|
|
241
|
+
* them from this page no matter what the tag asks — measured on claude.dev's
|
|
242
|
+
* `/media/*` figures (2026-09-29), which broke with the alt text showing.
|
|
243
|
+
* CORP is an embedder-side check, so a host-process fetch is the way through.
|
|
244
|
+
*
|
|
245
|
+
* This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
|
|
246
|
+
* a closed html|text union and its provider refuses binary content types
|
|
247
|
+
* outright. The image fetch re-implements the seam's hardening at image
|
|
248
|
+
* scope instead (public-address validation with the connection pinned to the
|
|
249
|
+
* validated answers, a byte cap, a timeout, per-hop re-validation, and an
|
|
250
|
+
* image/* content-type gate) — see `image-fetch.ts`.
|
|
251
|
+
*
|
|
252
|
+
* Rescued bytes are kept in an in-memory LRU under a byte budget: the
|
|
253
|
+
* sidebar unmounts wholesale when another panel takes over, and a remount
|
|
254
|
+
* re-fails every blocked image before this cache can answer it.
|
|
255
|
+
*
|
|
256
|
+
* @param request - the image URL the pane failed on.
|
|
257
|
+
* @returns the bytes and their declared type, or why there are none.
|
|
258
|
+
*/
|
|
259
|
+
fetchImage(request: {
|
|
260
|
+
url: string;
|
|
261
|
+
}): Promise<ReaderImageResult>;
|
|
233
262
|
/**
|
|
234
263
|
* Which entries have a body, a stored raw payload, or a recorded failure.
|
|
235
264
|
*
|
package/lib/types/service.js
CHANGED
|
@@ -2,6 +2,7 @@ import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, e
|
|
|
2
2
|
import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, } from "./types.js";
|
|
3
3
|
import { delayUntilNext, isCatchUpDue } from "./schedule.js";
|
|
4
4
|
import { arxivHtmlUrl } from "./arxiv.js";
|
|
5
|
+
import { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
|
|
5
6
|
import { resolveLink } from "./link-resolvers.js";
|
|
6
7
|
/**
|
|
7
8
|
* How many entries one automatic backfill run may fetch.
|
|
@@ -81,6 +82,8 @@ export class ReaderService {
|
|
|
81
82
|
disposed = false;
|
|
82
83
|
/** The document of record when no filesystem is mounted. */
|
|
83
84
|
memoryDoc;
|
|
85
|
+
/** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
|
|
86
|
+
imageCache = new ImageFetchCache();
|
|
84
87
|
/**
|
|
85
88
|
* @param ctx - owning context.
|
|
86
89
|
* @param config - optional state-root override.
|
|
@@ -549,6 +552,43 @@ export class ReaderService {
|
|
|
549
552
|
return { entryId: request.entryId, error: failure.message };
|
|
550
553
|
}
|
|
551
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* One image the browser could not load cross-origin, fetched host-side.
|
|
557
|
+
*
|
|
558
|
+
* The pane calls this only for an `<img>` that already FAILED: the page's
|
|
559
|
+
* bytes arrived fine (a direct fetch gets a 200) but the origin answers them
|
|
560
|
+
* with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
|
|
561
|
+
* them from this page no matter what the tag asks — measured on claude.dev's
|
|
562
|
+
* `/media/*` figures (2026-09-29), which broke with the alt text showing.
|
|
563
|
+
* CORP is an embedder-side check, so a host-process fetch is the way through.
|
|
564
|
+
*
|
|
565
|
+
* This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
|
|
566
|
+
* a closed html|text union and its provider refuses binary content types
|
|
567
|
+
* outright. The image fetch re-implements the seam's hardening at image
|
|
568
|
+
* scope instead (public-address validation with the connection pinned to the
|
|
569
|
+
* validated answers, a byte cap, a timeout, per-hop re-validation, and an
|
|
570
|
+
* image/* content-type gate) — see `image-fetch.ts`.
|
|
571
|
+
*
|
|
572
|
+
* Rescued bytes are kept in an in-memory LRU under a byte budget: the
|
|
573
|
+
* sidebar unmounts wholesale when another panel takes over, and a remount
|
|
574
|
+
* re-fails every blocked image before this cache can answer it.
|
|
575
|
+
*
|
|
576
|
+
* @param request - the image URL the pane failed on.
|
|
577
|
+
* @returns the bytes and their declared type, or why there are none.
|
|
578
|
+
*/
|
|
579
|
+
async fetchImage(request) {
|
|
580
|
+
const url = normalizeUrl(request.url);
|
|
581
|
+
if (url === undefined)
|
|
582
|
+
return { url: request.url, error: 'invalid-url' };
|
|
583
|
+
const cached = this.imageCache.get(url);
|
|
584
|
+
if (cached !== undefined)
|
|
585
|
+
return { url, mime: cached.mime, base64: cached.base64 };
|
|
586
|
+
const fetched = await fetchImageBytes(url);
|
|
587
|
+
if (!fetched.ok)
|
|
588
|
+
return { url, error: fetched.error };
|
|
589
|
+
this.imageCache.set(url, fetched.mime, fetched.base64);
|
|
590
|
+
return { url, mime: fetched.mime, base64: fetched.base64 };
|
|
591
|
+
}
|
|
552
592
|
/**
|
|
553
593
|
* Which entries have a body, a stored raw payload, or a recorded failure.
|
|
554
594
|
*
|
package/lib/types/types.d.ts
CHANGED
|
@@ -707,4 +707,23 @@ export interface ReaderEntryBodyView {
|
|
|
707
707
|
/** Why a fetch could not produce a body, when one was attempted. */
|
|
708
708
|
readonly error?: string;
|
|
709
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* One image the pane could not load cross-origin, fetched host-side.
|
|
712
|
+
*
|
|
713
|
+
* The browser-side block is CORP/hotlink protection: the origin answers the
|
|
714
|
+
* bytes (a direct fetch gets a 200) but withholds them from a cross-origin
|
|
715
|
+
* page. The rescue hands the bytes over the wire base64-encoded — the pane
|
|
716
|
+
* re-points the failed `<img>` at a `data:` URI built from them. A failed
|
|
717
|
+
* rescue carries `error` instead; the pane leaves the broken image alone.
|
|
718
|
+
*/
|
|
719
|
+
export interface ReaderImageResult {
|
|
720
|
+
/** The URL the pane asked about (echoed, so a late answer can be matched). */
|
|
721
|
+
readonly url: string;
|
|
722
|
+
/** The image's declared content type, when the fetch produced bytes. */
|
|
723
|
+
readonly mime?: string;
|
|
724
|
+
/** The image bytes, base64-encoded, when the fetch produced them. */
|
|
725
|
+
readonly base64?: string;
|
|
726
|
+
/** Why the rescue failed, when it did. */
|
|
727
|
+
readonly error?: string;
|
|
728
|
+
}
|
|
710
729
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khorsheed/dsh-reader",
|
|
3
3
|
"description": "Link reader tab: RSS/Atom subscriptions plus pasted article links, a card feed with a readable detail view, host-side fetch and daily refresh over a Typert Remote data face",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"lib/typert.remote-client.d.ts",
|
|
42
42
|
"lib/typert.remote-client.js",
|
|
43
43
|
"cordis.patch.yml",
|
|
44
|
-
"locale/*.json"
|
|
44
|
+
"locale/*.json",
|
|
45
|
+
"CHANGELOG.md"
|
|
45
46
|
],
|
|
46
47
|
"license": "MIT",
|
|
47
48
|
"dependencies": {
|
|
@@ -49,15 +50,15 @@
|
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
|
|
53
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6",
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1",
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
58
|
-
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
59
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
60
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
61
62
|
"react": "^18.2.0"
|
|
62
63
|
},
|
|
63
64
|
"peerDependenciesMeta": {
|