@bedolla/enrivision 0.1.5 → 0.1.6

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.
Files changed (64) hide show
  1. package/README.md +31 -6
  2. package/dist/client/EnriProxyClient.d.ts +289 -244
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +841 -115
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/client/EnriProxyClientContract.d.ts +425 -0
  7. package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
  8. package/dist/client/EnriProxyClientContract.js +87 -0
  9. package/dist/client/EnriProxyClientContract.js.map +1 -0
  10. package/dist/index.js +23 -12
  11. package/dist/index.js.map +1 -1
  12. package/dist/package-info.d.ts +28 -0
  13. package/dist/package-info.d.ts.map +1 -1
  14. package/dist/package-info.js +28 -0
  15. package/dist/package-info.js.map +1 -1
  16. package/dist/server/EnriVisionServer.d.ts +186 -0
  17. package/dist/server/EnriVisionServer.d.ts.map +1 -1
  18. package/dist/server/EnriVisionServer.js +780 -93
  19. package/dist/server/EnriVisionServer.js.map +1 -1
  20. package/dist/shared/codepointTruncation.d.ts +61 -0
  21. package/dist/shared/codepointTruncation.d.ts.map +1 -0
  22. package/dist/shared/codepointTruncation.js +73 -0
  23. package/dist/shared/codepointTruncation.js.map +1 -0
  24. package/dist/shared/mediaUrlFetcher.d.ts +247 -9
  25. package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
  26. package/dist/shared/mediaUrlFetcher.js +712 -53
  27. package/dist/shared/mediaUrlFetcher.js.map +1 -1
  28. package/dist/shared/tar.d.ts +82 -2
  29. package/dist/shared/tar.d.ts.map +1 -1
  30. package/dist/shared/tar.js +106 -43
  31. package/dist/shared/tar.js.map +1 -1
  32. package/dist/shared/validation.d.ts +96 -2
  33. package/dist/shared/validation.d.ts.map +1 -1
  34. package/dist/shared/validation.js +169 -10
  35. package/dist/shared/validation.js.map +1 -1
  36. package/dist/tools/AnalyzeMediaContract.d.ts +457 -0
  37. package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
  38. package/dist/tools/AnalyzeMediaContract.js +161 -0
  39. package/dist/tools/AnalyzeMediaContract.js.map +1 -0
  40. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
  41. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
  42. package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
  43. package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
  44. package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
  45. package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
  46. package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
  47. package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
  48. package/dist/tools/AnalyzeMediaParamParser.d.ts +299 -0
  49. package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
  50. package/dist/tools/AnalyzeMediaParamParser.js +824 -0
  51. package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
  52. package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
  53. package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
  54. package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
  55. package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
  56. package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
  57. package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
  58. package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
  59. package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
  60. package/dist/tools/AnalyzeMediaTool.d.ts +155 -294
  61. package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
  62. package/dist/tools/AnalyzeMediaTool.js +600 -457
  63. package/dist/tools/AnalyzeMediaTool.js.map +1 -1
  64. package/package.json +2 -1
@@ -4,34 +4,41 @@
4
4
  * Downloads one http(s) media resource into a temporary directory so the
5
5
  * upload pipeline can treat it like any local file.
6
6
  *
7
- * Protections: 64 MiB size cap, 60 s timeout, SSRF guard (literal private
7
+ * Protections: 64 MiB size cap, 60 s timeout, 30 s stall watchdog (a hung
8
+ * `reader.read()` aborts the download), SSRF guard (literal private
8
9
  * addresses rejected, DNS-resolved addresses rejected, per-hop validation of
9
- * up to 3 redirects), and an owned cleanup callback.
10
+ * up to 5 redirects), and an owned cleanup callback.
10
11
  *
11
12
  * @module shared/mediaUrlFetcher
12
13
  */
13
14
  import { isIP } from "node:net";
14
15
  import { lookup as dnsLookup } from "node:dns/promises";
15
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
16
+ import { createWriteStream } from "node:fs";
17
+ import { mkdtemp, rm } from "node:fs/promises";
16
18
  import { tmpdir } from "node:os";
17
19
  import { join } from "node:path";
20
+ import { finished } from "node:stream/promises";
21
+ import { extension as mimeExtension, lookup as mimeLookup } from "mime-types";
18
22
  /**
19
23
  * Immutable media URL fetch result record.
20
24
  */
21
25
  class MediaUrlFetchResultRecord {
22
26
  localPath;
23
27
  contentType;
28
+ extensionSynthesized;
24
29
  cleanupCallback;
25
30
  /**
26
31
  * Creates one fetch result.
27
32
  *
28
33
  * @param localPath - Downloaded file path.
29
34
  * @param contentType - Reported content type.
35
+ * @param extensionSynthesized - Whether the file extension was synthesized.
30
36
  * @param cleanupCallback - Owned cleanup callback.
31
37
  */
32
- constructor(localPath, contentType, cleanupCallback) {
38
+ constructor(localPath, contentType, extensionSynthesized, cleanupCallback) {
33
39
  this.localPath = localPath;
34
40
  this.contentType = contentType;
41
+ this.extensionSynthesized = extensionSynthesized;
35
42
  this.cleanupCallback = cleanupCallback;
36
43
  }
37
44
  /**
@@ -49,14 +56,46 @@ export class MediaUrlFetcher {
49
56
  * Maximum accepted download size in bytes.
50
57
  */
51
58
  static MAX_BYTES = 64 * 1024 * 1024;
59
+ /**
60
+ * Stable size-cap marker embedded in every size-cap rejection message.
61
+ *
62
+ * Routers match on this marker (via {@link MediaUrlFetcher.isSizeCapError}),
63
+ * never on the human-scale budget prose.
64
+ */
65
+ static URL_SIZE_CAP_MARKER = "[ENRIVISION_MEDIA_URL_SIZE_CAP]";
66
+ /**
67
+ * Reports whether an error is a client-side URL size-cap rejection.
68
+ *
69
+ * @param error - Unknown caught failure.
70
+ * @returns True only for size-cap rejections carrying the stable marker.
71
+ */
72
+ static isSizeCapError(error) {
73
+ return error instanceof Error && error.message.includes(MediaUrlFetcher.URL_SIZE_CAP_MARKER);
74
+ }
52
75
  /**
53
76
  * Download timeout in milliseconds.
54
77
  */
55
78
  static TIMEOUT_MS = 60_000;
56
79
  /**
57
- * Maximum followed redirects (each hop re-validated).
80
+ * Maximum followed redirects (each hop re-validated, mirrors EnriCode).
81
+ */
82
+ static MAX_REDIRECTS = 5;
83
+ /**
84
+ * Redirect statuses that carry a `Location` hop.
85
+ *
86
+ * @remarks
87
+ * Mirrors EnriCode `VisionAnalyzeMediaUrlFetcher.REDIRECT_STATUSES`: 304
88
+ * (Not Modified, a cache validator without `Location` semantics), 305
89
+ * (deprecated proxy directive), and 306 (unused since HTTP/1.1) never
90
+ * start a new hop. Treating them as terminal keeps redirect-chasing
91
+ * predictable and avoids method/body-rewrite surprises.
92
+ */
93
+ static REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
94
+ /**
95
+ * Stall watchdog in milliseconds: abort when no body bytes arrive within
96
+ * this window (mirrors EnriCode `BODY_STALL_MS`).
58
97
  */
59
- static MAX_REDIRECTS = 3;
98
+ static BODY_STALL_MS = 30_000;
60
99
  /**
61
100
  * Fetch implementation (injectable for tests).
62
101
  */
@@ -89,105 +128,661 @@ export class MediaUrlFetcher {
89
128
  static isHttpUrl(value) {
90
129
  return /^https?:\/\//iu.test(String(value ?? "").trim());
91
130
  }
131
+ /**
132
+ * Reports whether one served content type is analyzable media.
133
+ *
134
+ * @remarks
135
+ * Exact-match set mirroring EnriCode `VisionAnalyzeMediaUrlFetcher`: Office
136
+ * types never match by substring, so crafted types such as
137
+ * `application/x-wordprocessing-evil` are rejected.
138
+ *
139
+ * @param contentType - Served content type without parameters.
140
+ * @returns True for image, video, audio, PDF, and Office documents.
141
+ */
142
+ static isAllowedMediaContentType(contentType) {
143
+ const normalized = String(contentType ?? "").trim().toLowerCase();
144
+ if (normalized.length === 0) {
145
+ return false;
146
+ }
147
+ if (normalized.startsWith("image/")
148
+ || normalized.startsWith("video/")
149
+ || normalized.startsWith("audio/")) {
150
+ return true;
151
+ }
152
+ return ALLOWED_EXACT_CONTENT_TYPES.has(normalized);
153
+ }
154
+ /**
155
+ * Reports whether a URL path carries a known media extension.
156
+ *
157
+ * @remarks
158
+ * Fallback for servers that omit `content-type`: the extension is mapped
159
+ * through the canonical `mime-types` table and the mapped type must pass
160
+ * {@link isAllowedMediaContentType}, so this fallback can never drift
161
+ * from the allowlist (mirrors EnriCode
162
+ * `VisionAnalyzeMediaUrlFetcher.hasKnownMediaExtension`). A lying
163
+ * extension still resolves through the authoritative server type
164
+ * downstream, exactly like the EnriCode contract.
165
+ *
166
+ * @param url - Final URL (after redirects).
167
+ * @returns True when the URL path ends with an extension mapping to analyzable media.
168
+ */
169
+ static hasKnownMediaExtension(url) {
170
+ const withoutQuery = String(url ?? "").split(/[?#]/)[0] ?? "";
171
+ const baseName = withoutQuery.split("/").pop() ?? "";
172
+ const match = /\.([A-Za-z0-9]{1,10})$/u.exec(baseName);
173
+ if (match === null) {
174
+ return false;
175
+ }
176
+ const mapped = mimeLookup(match[1].toLowerCase());
177
+ if (typeof mapped !== "string") {
178
+ return false;
179
+ }
180
+ return MediaUrlFetcher.isAllowedMediaContentType(mapped.split(";", 1)[0].trim());
181
+ }
92
182
  /**
93
183
  * Downloads one http(s) URL into a temporary directory.
94
184
  *
185
+ * @remarks
186
+ * The response body is streamed directly to disk so large downloads never
187
+ * sit fully in memory; the 64 MiB cap is enforced incrementally while
188
+ * streaming.
189
+ *
95
190
  * @param url - Absolute http(s) URL.
191
+ * @param options - Optional fetch options (cancellation signal).
96
192
  * @returns Bounded fetch result with owned cleanup.
97
- * @throws Error when the destination is blocked, too large, or the download fails.
193
+ * @throws Error when the destination is blocked, too large, cancelled, or the download fails.
98
194
  */
99
- async fetch(url) {
195
+ async fetch(url, options) {
196
+ const callerSignal = options?.signal;
197
+ if (callerSignal?.aborted) {
198
+ throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
199
+ }
100
200
  let currentUrl = String(url ?? "").trim();
101
201
  if (!MediaUrlFetcher.isHttpUrl(currentUrl)) {
102
- throw new Error("Sólo se aceptan URLs http(s) en path/paths.");
202
+ throw new Error("Sólo se aceptan URLs http(s) en path/paths. / Only http(s) URLs are accepted in path/paths.");
103
203
  }
104
204
  const timeoutSignal = AbortSignal.timeout(MediaUrlFetcher.TIMEOUT_MS);
105
- let response = await this.requestValidated(currentUrl, timeoutSignal);
205
+ const combinedSignal = callerSignal
206
+ ? AbortSignal.any([timeoutSignal, callerSignal])
207
+ : timeoutSignal;
208
+ let response = await this.requestValidated(currentUrl, combinedSignal, callerSignal);
106
209
  for (let redirectCount = 0; redirectCount < MediaUrlFetcher.MAX_REDIRECTS; redirectCount += 1) {
107
- if (response.status < 301 || response.status > 308) {
210
+ if (!MediaUrlFetcher.REDIRECT_STATUSES.has(response.status)) {
108
211
  break;
109
212
  }
110
213
  const location = response.headers.get("location");
111
214
  if (!location) {
112
- break;
215
+ throw new Error(`La redirección HTTP ${String(response.status)} no incluyó Location. / HTTP redirect ${String(response.status)} included no Location.`);
216
+ }
217
+ try {
218
+ currentUrl = new URL(location, currentUrl).toString();
219
+ }
220
+ catch {
221
+ throw new Error(`La redirección trae una URL inválida: '${location}'. / Redirect carries an invalid URL: '${location}'.`);
113
222
  }
114
- currentUrl = new URL(location, currentUrl).toString();
115
223
  if (!MediaUrlFetcher.isHttpUrl(currentUrl)) {
116
- throw new Error("La redirección apunta fuera de http(s).");
224
+ throw new Error("La redirección apunta fuera de http(s). / Redirect points outside http(s).");
225
+ }
226
+ // Release the intermediate hop body before following the redirect so
227
+ // a hostile redirect chain with large bodies cannot retain a socket
228
+ // and buffered bytes per hop.
229
+ try {
230
+ await response.body?.cancel();
231
+ }
232
+ catch {
233
+ // Best-effort only.
117
234
  }
118
- response = await this.requestValidated(currentUrl, timeoutSignal);
235
+ response = await this.requestValidated(currentUrl, combinedSignal, callerSignal);
236
+ }
237
+ // A redirect status surviving the loop means the chain is exhausted
238
+ // (more than MAX_REDIRECTS hops, or a hop without Location): coach in
239
+ // Spanish like EnriCode instead of surfacing a bare HTTP 3xx. The body
240
+ // is always released first so the terminal hop never retains a socket.
241
+ if (MediaUrlFetcher.REDIRECT_STATUSES.has(response.status)) {
242
+ try {
243
+ await response.body?.cancel();
244
+ }
245
+ catch {
246
+ // Best-effort only.
247
+ }
248
+ throw new Error(`La URL excede el máximo de ${String(MediaUrlFetcher.MAX_REDIRECTS)} redirecciones. / URL exceeds the maximum of ${String(MediaUrlFetcher.MAX_REDIRECTS)} redirects.`);
119
249
  }
120
250
  if (!response.ok) {
121
- throw new Error(`La URL respondió HTTP ${String(response.status)}.`);
251
+ try {
252
+ await response.body?.cancel();
253
+ }
254
+ catch {
255
+ // Best-effort only.
256
+ }
257
+ throw new Error(`La URL respondió HTTP ${String(response.status)}. / URL answered HTTP ${String(response.status)}.`);
122
258
  }
123
259
  const declaredLengthHeader = response.headers.get("content-length");
124
260
  if (declaredLengthHeader !== null &&
125
261
  Number(declaredLengthHeader) > MediaUrlFetcher.MAX_BYTES) {
126
- throw new Error(`El archivo remoto excede el límite de 64 MiB.`);
127
- }
128
- const buffer = await response.arrayBuffer();
129
- if (buffer.byteLength > MediaUrlFetcher.MAX_BYTES) {
130
- throw new Error(`El archivo remoto excede el límite de 64 MiB.`);
262
+ throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
131
263
  }
132
- const contentType = (response.headers.get("content-type") ?? "")
264
+ const servedContentType = (response.headers.get("content-type") ?? "")
133
265
  .split(";", 1)[0]
134
266
  .trim()
135
267
  .toLowerCase();
268
+ // When the server omits content-type, a known media extension in the
269
+ // final URL rescues the download (mirrors EnriCode): only reject when
270
+ // both signals fail. A disallowed served type (e.g. `text/html` behind
271
+ // a `.png` URL) is blanked instead of trusted verbatim so the resolver
272
+ // infers the type from the rescuing extension, exactly like the EnriCode
273
+ // `trustedContentType === ""` contract; uploading `text/html` bytes as
274
+ // media is never useful.
275
+ if (!MediaUrlFetcher.isAllowedMediaContentType(servedContentType)
276
+ && !MediaUrlFetcher.hasKnownMediaExtension(currentUrl)) {
277
+ try {
278
+ await response.body?.cancel();
279
+ }
280
+ catch {
281
+ // Best-effort only.
282
+ }
283
+ throw new Error(`La URL no sirvió un archivo de media válido (content-type: ${servedContentType.length > 0 ? servedContentType : "desconocido"}). Solo se aceptan imagen, video, audio, PDF y documentos de Office. / URL did not serve a valid media file (content-type: ${servedContentType.length > 0 ? servedContentType : "desconocido"}). Only image, video, audio, PDF, and Office documents are accepted.`);
284
+ }
285
+ const contentType = MediaUrlFetcher.isAllowedMediaContentType(servedContentType)
286
+ ? servedContentType
287
+ : "";
136
288
  const temporaryDirectory = await mkdtemp(join(tmpdir(), "enrivision-url-"));
137
- const localPath = join(temporaryDirectory, this.deriveFileName(currentUrl, contentType));
289
+ const derived = this.deriveFileName(currentUrl, contentType);
290
+ const localPath = join(temporaryDirectory, derived.fileName);
138
291
  try {
139
- await writeFile(localPath, Buffer.from(buffer));
292
+ await this.streamBodyToFile(response, localPath, callerSignal);
140
293
  }
141
294
  catch (error) {
142
295
  await rm(temporaryDirectory, { recursive: true, force: true });
143
- throw new Error(`No se pudo guardar la media descargada: ${error instanceof Error ? error.message : String(error)}`);
296
+ if (callerSignal?.aborted) {
297
+ throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
298
+ }
299
+ if (error instanceof Error && isOwnedDownloadError(error)) {
300
+ throw error;
301
+ }
302
+ // Abort-shaped rejections that reach this boundary with no caller
303
+ // cancellation are the overall 60 s deadline firing during the
304
+ // request, redirect hops, or a null-body buffer read: surface the
305
+ // Spanish-first timeout instead of Node's English abort text.
306
+ if (error instanceof Error
307
+ && (error.name === "AbortError" || error.name === "TimeoutError" || error.code === "ABORT_ERR")) {
308
+ throw new Error(`La descarga de media expiró: superó el límite de ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s. / The media download timed out: it exceeded the ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s limit.`);
309
+ }
310
+ throw new Error(`La descarga de media falló: ${error instanceof Error ? error.message : String(error)} / Media download failed: ${error instanceof Error ? error.message : String(error)}`);
144
311
  }
145
- return new MediaUrlFetchResultRecord(localPath, contentType, async () => {
312
+ return new MediaUrlFetchResultRecord(localPath, contentType, derived.extensionSynthesized, async () => {
146
313
  await rm(temporaryDirectory, { recursive: true, force: true });
147
314
  });
148
315
  }
316
+ /**
317
+ * Streams one validated response body directly to a file.
318
+ *
319
+ * @param response - Validated fetch response.
320
+ * @param localPath - Destination file path.
321
+ * @param callerSignal - Optional caller cancellation signal.
322
+ * @throws Error when the body exceeds the size cap, is cancelled, or cannot be written.
323
+ */
324
+ async streamBodyToFile(response, localPath, callerSignal) {
325
+ if (!response.body) {
326
+ const buffer = await response.arrayBuffer();
327
+ if (buffer.byteLength > MediaUrlFetcher.MAX_BYTES) {
328
+ throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
329
+ }
330
+ const fileStream = createWriteStream(localPath);
331
+ const completion = finished(fileStream);
332
+ fileStream.end(Buffer.from(buffer));
333
+ try {
334
+ await completion;
335
+ }
336
+ catch (error) {
337
+ throw new Error(`No se pudo guardar la media descargada: ${error instanceof Error ? error.message : String(error)} / Could not save the downloaded media: ${error instanceof Error ? error.message : String(error)}`);
338
+ }
339
+ return;
340
+ }
341
+ const fileStream = createWriteStream(localPath);
342
+ const completion = finished(fileStream);
343
+ // The completion promise is only awaited on the success path; mark it
344
+ // handled so error-path destroy() never surfaces as unhandled rejection.
345
+ completion.catch(() => undefined);
346
+ const reader = response.body.getReader();
347
+ try {
348
+ let writtenBytes = 0;
349
+ for (;;) {
350
+ if (callerSignal?.aborted) {
351
+ throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
352
+ }
353
+ const read = await MediaUrlFetcher.readWithStallWatchdog(reader, MediaUrlFetcher.BODY_STALL_MS);
354
+ if (read.done) {
355
+ break;
356
+ }
357
+ writtenBytes += read.value.byteLength;
358
+ if (writtenBytes > MediaUrlFetcher.MAX_BYTES) {
359
+ throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
360
+ }
361
+ const chunk = Buffer.from(read.value);
362
+ const accepted = fileStream.write(chunk);
363
+ if (!accepted) {
364
+ await new Promise((resolve, reject) => {
365
+ fileStream.once("drain", () => resolve());
366
+ fileStream.once("error", (streamError) => reject(streamError));
367
+ });
368
+ }
369
+ }
370
+ fileStream.end();
371
+ await completion;
372
+ }
373
+ catch (error) {
374
+ fileStream.destroy();
375
+ try {
376
+ await reader.cancel();
377
+ }
378
+ catch {
379
+ // Ignore reader-cancel failures; the original error wins.
380
+ }
381
+ if (error instanceof Error && isOwnedDownloadError(error)) {
382
+ throw error;
383
+ }
384
+ // The combined signal only aborts early for the overall 60 s deadline
385
+ // or caller cancellation (already handled above): a raw abort-shaped
386
+ // rejection here means the deadline fired mid-body, so surface the
387
+ // Spanish-first timeout instead of Node's English abort text and the
388
+ // generic save-failure envelope.
389
+ if (error instanceof Error
390
+ && (error.name === "AbortError" || error.name === "TimeoutError" || error.code === "ABORT_ERR")) {
391
+ throw new Error(`La descarga de media expiró: superó el límite de ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s. / The media download timed out: it exceeded the ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s limit.`);
392
+ }
393
+ throw new Error(`No se pudo guardar la media descargada: ${error instanceof Error ? error.message : String(error)} / Could not save the downloaded media: ${error instanceof Error ? error.message : String(error)}`);
394
+ }
395
+ finally {
396
+ reader.releaseLock();
397
+ }
398
+ }
399
+ /**
400
+ * Reads one body chunk guarded by the stall watchdog.
401
+ *
402
+ * @remarks
403
+ * The global 60 s timeout alone cannot fire while one `reader.read()`
404
+ * hangs forever; racing each read against `BODY_STALL_MS` aborts stalled
405
+ * connections with a Spanish error.
406
+ *
407
+ * @param reader - Body reader.
408
+ * @param stallMs - Stall budget in milliseconds.
409
+ * @returns Read result.
410
+ * @throws Error with an Spanish-first bilingual message when no bytes arrive within the stall budget.
411
+ */
412
+ static async readWithStallWatchdog(reader, stallMs) {
413
+ let timer;
414
+ try {
415
+ return await Promise.race([
416
+ reader.read(),
417
+ new Promise((_, reject) => {
418
+ timer = setTimeout(() => {
419
+ try {
420
+ void reader.cancel();
421
+ }
422
+ catch {
423
+ // Best-effort only; the rejection below carries the error.
424
+ }
425
+ reject(new Error("La descarga de media se detuvo por inactividad (sin bytes durante 30 s). / Media download stalled (no bytes for 30 s)."));
426
+ }, stallMs);
427
+ }),
428
+ ]);
429
+ }
430
+ finally {
431
+ if (timer !== undefined) {
432
+ clearTimeout(timer);
433
+ }
434
+ }
435
+ }
149
436
  /**
150
437
  * Performs one fetch request after validating the destination addresses.
151
438
  *
439
+ * @remarks
440
+ * Residual SSRF note (DNS-rebinding TOCTOU): the hostname is resolved and
441
+ * validated here, but the HTTP stack re-resolves it when connecting, so a
442
+ * hostile DNS could answer the second lookup with a private address. IP
443
+ * pinning is intentionally not implemented: the global fetch/undici stack
444
+ * used here exposes no connection-level IP pin without a custom
445
+ * dispatcher, and every redirect hop is re-validated to keep the race
446
+ * window minimal. This residual risk is accepted post-release, mirroring
447
+ * EnriProxy's documented DNS-rebinding stance.
448
+ *
152
449
  * @param url - Candidate URL (already normalized).
153
- * @param timeoutSignal - Timeout signal shared by every hop.
450
+ * @param requestSignal - Timeout/cancellation signal shared by every hop.
451
+ * @param callerSignal - Caller cancellation signal used to report bilingual cancel errors.
154
452
  * @returns Fetch response.
155
453
  * @throws Error when the hostname resolves to a blocked address or the request fails.
156
454
  */
157
- async requestValidated(url, timeoutSignal) {
455
+ async requestValidated(url, requestSignal, callerSignal) {
158
456
  await this.assertPublicDestination(url);
159
457
  try {
160
- return await this.fetchImpl(url, { signal: timeoutSignal, redirect: "manual" });
458
+ return await this.fetchImpl(url, { signal: requestSignal, redirect: "manual" });
161
459
  }
162
460
  catch (error) {
163
- throw new Error(`La descarga de media falló: ${error instanceof Error ? error.message : String(error)}`);
461
+ if (callerSignal?.aborted) {
462
+ throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
463
+ }
464
+ throw new Error(`La descarga de media falló: ${error instanceof Error ? error.message : String(error)} / Media download failed: ${error instanceof Error ? error.message : String(error)}`);
164
465
  }
165
466
  }
166
467
  /**
167
468
  * Validates that the URL hostname is not a private/loopback/link-local target.
168
469
  *
470
+ * @remarks
471
+ * Non-canonical IPv4 literals (decimal `2130706433`, hex `0x7f.0.0.1`,
472
+ * octal `0177.0.0.1`) are normalized to dotted quads before validation so
473
+ * they cannot bypass the literal-IP check and reach DNS.
474
+ *
169
475
  * @param url - Candidate URL.
170
476
  * @throws Error when a literal or resolved address is blocked.
171
477
  */
172
478
  async assertPublicDestination(url) {
173
- const hostname = new URL(url).hostname.replace(/^\[|\]$/gu, "");
479
+ let rawHostname;
480
+ try {
481
+ rawHostname = new URL(url).hostname.replace(/^\[|\]$/gu, "");
482
+ }
483
+ catch {
484
+ throw new Error(`URL inválida: '${url}'. Use una URL http(s) completa. / Invalid URL: '${url}'. Use a complete http(s) URL.`);
485
+ }
486
+ const hostname = rawHostname.replace(/\.+$/u, "").toLowerCase();
174
487
  if (hostname === "localhost") {
175
- throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
488
+ throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
489
+ }
490
+ const normalized = MediaUrlFetcher.normalizeHostnameToIpAddress(hostname);
491
+ if (normalized !== null) {
492
+ this.assertPublicAddress(normalized);
493
+ return;
176
494
  }
177
495
  if (isIP(hostname) !== 0) {
178
496
  this.assertPublicAddress(hostname);
179
497
  return;
180
498
  }
181
- const addresses = await this.resolveHostAddresses(hostname);
499
+ const addresses = await this.resolveHostAddresses(rawHostname);
182
500
  if (addresses.length === 0) {
183
- throw new Error("No se pudo resolver el host antes de descargar la media.");
501
+ throw new Error("No se pudo resolver el host antes de descargar la media. / Could not resolve the host before downloading media.");
184
502
  }
185
503
  for (const address of addresses) {
186
504
  this.assertPublicAddress(address);
187
505
  }
188
506
  }
189
507
  /**
190
- * Rejects private, loopback, link-local, and unspecified addresses.
508
+ * Normalizes a hostname to an IP address when it is a non-canonical IP literal.
509
+ *
510
+ * @remarks
511
+ * Handles decimal (`2130706433`), hex (`0x7f.0.0.1`), and octal
512
+ * (`0177.0.0.1`) IPv4 forms per `inet_aton` rules, plus IPv6
513
+ * `::ffff:`-mapped forms in hex (`::ffff:7f00:1`) and full
514
+ * (`0:0:0:0:0:ffff:127.0.0.1`) notation. Returns null for real hostnames
515
+ * so they keep flowing to DNS validation.
516
+ *
517
+ * @param hostname - Lowercased hostname without brackets or trailing dots.
518
+ * @returns Normalized IP address or null when not an IP literal.
519
+ */
520
+ static normalizeHostnameToIpAddress(hostname) {
521
+ if (hostname.length === 0 || hostname.includes(":")) {
522
+ return MediaUrlFetcher.normalizeMappedIpv6(hostname);
523
+ }
524
+ if (!/^[0-9a-z]+(\.[0-9a-z]+)*$/u.test(hostname)) {
525
+ return null;
526
+ }
527
+ const parts = hostname.split(".");
528
+ if (parts.length < 1 || parts.length > 4) {
529
+ return null;
530
+ }
531
+ const numbers = [];
532
+ for (const part of parts) {
533
+ const parsed = MediaUrlFetcher.parseIpv4Part(part);
534
+ if (parsed === null) {
535
+ return null;
536
+ }
537
+ numbers.push(parsed);
538
+ }
539
+ if (numbers.length === 1) {
540
+ const value = numbers[0];
541
+ if (value > 4294967295) {
542
+ return null;
543
+ }
544
+ return `${String((value >>> 24) & 255)}.${String((value >>> 16) & 255)}.${String((value >>> 8) & 255)}.${String(value & 255)}`;
545
+ }
546
+ if (numbers.length === 2) {
547
+ const first = numbers[0];
548
+ const rest = numbers[1];
549
+ if (first > 255 || rest > 16777215) {
550
+ return null;
551
+ }
552
+ return `${String(first)}.${String((rest >>> 16) & 255)}.${String((rest >>> 8) & 255)}.${String(rest & 255)}`;
553
+ }
554
+ if (numbers.length === 3) {
555
+ const first = numbers[0];
556
+ const second = numbers[1];
557
+ const rest = numbers[2];
558
+ if (first > 255 || second > 255 || rest > 65535) {
559
+ return null;
560
+ }
561
+ return `${String(first)}.${String(second)}.${String((rest >>> 8) & 255)}.${String(rest & 255)}`;
562
+ }
563
+ for (const octet of numbers) {
564
+ if (octet > 255) {
565
+ return null;
566
+ }
567
+ }
568
+ return numbers.map((octet) => String(octet)).join(".");
569
+ }
570
+ /**
571
+ * Parses one IPv4 address part accepting decimal, hex, and octal forms.
572
+ *
573
+ * @param part - Raw address part.
574
+ * @returns Parsed value or null when not a numeric part.
575
+ */
576
+ static parseIpv4Part(part) {
577
+ if (/^0x[0-9a-f]+$/u.test(part)) {
578
+ const parsed = Number.parseInt(part, 16);
579
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
580
+ }
581
+ if (/^0[0-9]+$/u.test(part)) {
582
+ if (!/^0[0-7]*$/u.test(part)) {
583
+ return null;
584
+ }
585
+ const parsed = Number.parseInt(part, 8);
586
+ return Number.isSafeInteger(parsed) ? parsed : null;
587
+ }
588
+ if (/^[0-9]+$/u.test(part)) {
589
+ const parsed = Number.parseInt(part, 10);
590
+ return Number.isSafeInteger(parsed) ? parsed : null;
591
+ }
592
+ return null;
593
+ }
594
+ /**
595
+ * Normalizes IPv6 `::ffff:`-mapped literals to their embedded IPv4 address.
596
+ *
597
+ * @remarks
598
+ * Covers the compact hex form (`::ffff:7f00:1`), the compact dotted form
599
+ * (`::ffff:127.0.0.1`), and the full zero-expanded forms
600
+ * (`0:0:0:0:0:ffff:127.0.0.1`, `0:0:0:0:0:ffff:7f00:1`).
601
+ *
602
+ * @param hostname - Lowercased hostname.
603
+ * @returns Embedded IPv4 dotted quad or null when not a mapped literal.
604
+ */
605
+ static normalizeMappedIpv6(hostname) {
606
+ const compactHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(hostname);
607
+ if (compactHex !== null) {
608
+ const high = Number.parseInt(compactHex[1], 16);
609
+ const low = Number.parseInt(compactHex[2], 16);
610
+ return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
611
+ }
612
+ const fullHex = /^0(?::0){4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(hostname);
613
+ if (fullHex !== null) {
614
+ const high = Number.parseInt(fullHex[1], 16);
615
+ const low = Number.parseInt(fullHex[2], 16);
616
+ return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
617
+ }
618
+ const fullDotted = /^0(?::0){4}:ffff:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(hostname);
619
+ if (fullDotted !== null) {
620
+ return fullDotted[1];
621
+ }
622
+ return null;
623
+ }
624
+ /**
625
+ * Expands one IPv6 literal into eight hex groups.
626
+ *
627
+ * @remarks
628
+ * Returns null for dotted-quad tails (handled by the compatible/mapped
629
+ * helpers), malformed group counts, and non-hex groups, so callers only
630
+ * ever judge fully-validated literals.
631
+ *
632
+ * @param normalized - Lowercased IPv6 literal without a zone id.
633
+ * @returns Eight lowercase hex groups, or null when not parseable.
634
+ */
635
+ static expandIpv6Groups(normalized) {
636
+ if (normalized.includes(".")) {
637
+ return null;
638
+ }
639
+ const halves = normalized.split("::");
640
+ if (halves.length > 2) {
641
+ return null;
642
+ }
643
+ const left = halves[0].length === 0 ? [] : halves[0].split(":");
644
+ const right = halves.length === 2 ? (halves[1].length === 0 ? [] : halves[1].split(":")) : [];
645
+ if (halves.length === 1) {
646
+ return left.length === 8 ? left : null;
647
+ }
648
+ const missing = 8 - left.length - right.length;
649
+ if (missing < 1) {
650
+ return null;
651
+ }
652
+ const groups = [...left, ...new Array(missing).fill("0"), ...right];
653
+ for (const group of groups) {
654
+ if (!/^[0-9a-f]{1,4}$/u.test(group)) {
655
+ return null;
656
+ }
657
+ }
658
+ return groups;
659
+ }
660
+ /**
661
+ * Reports whether one IPv6 literal is unspecified or loopback in any spelling.
662
+ *
663
+ * @remarks
664
+ * The exact-only `"::"` / `"::1"` comparison misses zero-expanded
665
+ * spellings (`0:0:0:0:0:0:0:0`, `0:0:0:0:0:0:0:1`) and leading-zero
666
+ * variants (`::01`): expanding first closes the SSRF loopback bypass.
667
+ *
668
+ * @param normalized - Lowercased IPv6 literal.
669
+ * @returns True for `::/128` and `::1/128` in any spelling.
670
+ */
671
+ static isUnspecifiedOrLoopbackV6(normalized) {
672
+ const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
673
+ if (groups === null) {
674
+ return normalized === "::" || normalized === "::1";
675
+ }
676
+ if (groups.every((group) => Number.parseInt(group, 16) === 0)) {
677
+ return true;
678
+ }
679
+ return groups.slice(0, 7).every((group) => Number.parseInt(group, 16) === 0)
680
+ && Number.parseInt(groups[7], 16) === 1;
681
+ }
682
+ /**
683
+ * Extracts the embedded IPv4 address from a deprecated IPv4-compatible literal.
684
+ *
685
+ * @remarks
686
+ * `::127.0.0.1` (and its zero-expanded `0:0:0:0:0:0:127.0.0.1` form)
687
+ * carries no `ffff` marker, so the mapped-address helper ignores it and
688
+ * the quad would sail through as "public". Recursing into the IPv4
689
+ * validator closes the bypass.
690
+ *
691
+ * @param normalized - Lowercased IPv6 literal.
692
+ * @returns Embedded IPv4 dotted quad or null when not a compatible literal.
693
+ */
694
+ static extractCompatibleV4(normalized) {
695
+ const compact = /^::((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
696
+ if (compact !== null) {
697
+ return compact[1];
698
+ }
699
+ const expanded = /^0(?::0){5}:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
700
+ if (expanded !== null) {
701
+ return expanded[1];
702
+ }
703
+ return null;
704
+ }
705
+ /**
706
+ * Extracts the embedded IPv4 address from a zero-prefixed hex literal.
707
+ *
708
+ * @remarks
709
+ * `::7f00:1` (and expanded `0:0:0:0:0:0:7f00:1`) embeds `127.0.0.1` in
710
+ * the low 32 bits with neither dots nor an `ffff` marker, so neither the
711
+ * compatible helper nor the mapped helper fires. Treating the low 32
712
+ * bits of any 96-bit-zero literal as IPv4 closes the bypass; literals
713
+ * with a nonzero prefix (including `::ffff:`-mapped) return null.
714
+ *
715
+ * @param normalized - Lowercased IPv6 literal.
716
+ * @returns Embedded IPv4 dotted quad or null when the prefix is nonzero.
717
+ */
718
+ static extractZeroPrefixedV4(normalized) {
719
+ const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
720
+ if (groups === null) {
721
+ return null;
722
+ }
723
+ for (let index = 0; index < 6; index += 1) {
724
+ if (Number.parseInt(groups[index], 16) !== 0) {
725
+ return null;
726
+ }
727
+ }
728
+ const high = Number.parseInt(groups[6], 16);
729
+ const low = Number.parseInt(groups[7], 16);
730
+ return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
731
+ }
732
+ /**
733
+ * Extracts the embedded IPv4 address from a normalized IPv6 literal.
734
+ *
735
+ * @param normalized - Lowercased IPv6 literal.
736
+ * @returns Embedded IPv4 dotted quad or undefined when not mapped.
737
+ */
738
+ static extractMappedV4(normalized) {
739
+ const compactDotted = /^::ffff:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
740
+ if (compactDotted !== null) {
741
+ return compactDotted[1];
742
+ }
743
+ return MediaUrlFetcher.normalizeMappedIpv6(normalized) ?? undefined;
744
+ }
745
+ /**
746
+ * Extracts the embedded IPv4 address from a NAT64 `64:ff9b::/96` literal.
747
+ *
748
+ * @remarks
749
+ * On NAT64 networks `64:ff9b::7f00:1` routes to `127.0.0.1`, so the
750
+ * literal sails through prefix tests as "public". Judging the embedded
751
+ * quad as IPv4 closes the bypass (parity with EnriCode
752
+ * `Nat64AddressGuard.extractNat64EmbeddedIpv4`).
753
+ *
754
+ * @param normalized - Lowercased IPv6 literal.
755
+ * @returns Embedded IPv4 dotted quad or null when not a NAT64 literal.
756
+ */
757
+ static extractNat64V4(normalized) {
758
+ const dotted = /^64:ff9b::((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
759
+ if (dotted !== null) {
760
+ return dotted[1];
761
+ }
762
+ const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
763
+ if (groups === null) {
764
+ return null;
765
+ }
766
+ if (Number.parseInt(groups[0], 16) !== 0x64
767
+ || Number.parseInt(groups[1], 16) !== 0xff9b
768
+ || Number.parseInt(groups[2], 16) !== 0
769
+ || Number.parseInt(groups[3], 16) !== 0
770
+ || Number.parseInt(groups[4], 16) !== 0
771
+ || Number.parseInt(groups[5], 16) !== 0) {
772
+ return null;
773
+ }
774
+ const high = Number.parseInt(groups[6], 16);
775
+ const low = Number.parseInt(groups[7], 16);
776
+ return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
777
+ }
778
+ /**
779
+ * Rejects private, loopback, link-local, multicast, and reserved addresses.
780
+ *
781
+ * @remarks
782
+ * Documentation TEST-NET ranges (`192.0.2.0/24`, `198.51.100.0/24`,
783
+ * `203.0.113.0/24`) intentionally stay allowed: they are publicly routed
784
+ * test fixtures, never host-local media, and blocking them would break
785
+ * legitimate fixture URLs.
191
786
  *
192
787
  * @param address - Literal IP address.
193
788
  * @throws Error when the address belongs to a blocked range.
@@ -196,36 +791,49 @@ export class MediaUrlFetcher {
196
791
  const version = isIP(address);
197
792
  if (version === 4) {
198
793
  const octets = address.split(".").map((part) => Number(part));
199
- const [first = 0, second = 0] = octets;
794
+ const [first = 0, second = 0, third = 0] = octets;
200
795
  const blocked = first === 0 ||
201
796
  first === 10 ||
202
797
  first === 127 ||
798
+ (first === 100 && second >= 64 && second <= 127) ||
203
799
  (first === 169 && second === 254) ||
204
800
  (first === 172 && second >= 16 && second <= 31) ||
801
+ (first === 192 && second === 0 && third === 0) ||
205
802
  (first === 192 && second === 168) ||
803
+ (first === 198 && second >= 18 && second <= 19) ||
206
804
  first >= 224;
207
805
  if (blocked) {
208
- throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
806
+ throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
209
807
  }
210
808
  return;
211
809
  }
212
810
  if (version === 6) {
213
811
  const normalized = address.toLowerCase();
214
- const blocked = normalized === "::" ||
215
- normalized === "::1" ||
812
+ // Deprecated compatible literals (::127.0.0.1, ::7f00:1, expanded
813
+ // spellings) hide an IPv4 quad with no ffff marker: judge the
814
+ // embedded quad as IPv4 before any prefix test.
815
+ const compatibleV4 = MediaUrlFetcher.extractCompatibleV4(normalized)
816
+ ?? MediaUrlFetcher.extractZeroPrefixedV4(normalized)
817
+ ?? MediaUrlFetcher.extractNat64V4(normalized);
818
+ if (compatibleV4 !== null) {
819
+ this.assertPublicAddress(compatibleV4);
820
+ return;
821
+ }
822
+ const blocked = MediaUrlFetcher.isUnspecifiedOrLoopbackV6(normalized) ||
216
823
  normalized.startsWith("fc") ||
217
824
  normalized.startsWith("fd") ||
218
825
  normalized.startsWith("fe8") ||
219
826
  normalized.startsWith("fe9") ||
220
827
  normalized.startsWith("fea") ||
221
- normalized.startsWith("feb");
222
- const mappedV4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/u.exec(normalized)?.[1] ?? null;
828
+ normalized.startsWith("feb") ||
829
+ normalized.startsWith("ff");
830
+ const mappedV4 = MediaUrlFetcher.extractMappedV4(normalized) ?? null;
223
831
  if (mappedV4 !== null) {
224
832
  this.assertPublicAddress(mappedV4);
225
833
  return;
226
834
  }
227
835
  if (blocked) {
228
- throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
836
+ throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
229
837
  }
230
838
  }
231
839
  }
@@ -234,36 +842,87 @@ export class MediaUrlFetcher {
234
842
  *
235
843
  * @param url - Source URL.
236
844
  * @param contentType - Reported content type.
237
- * @returns Bounded file name with a recognized extension.
845
+ * @returns Bounded file name with a recognized extension plus whether the extension was synthesized.
238
846
  */
239
847
  deriveFileName(url, contentType) {
240
- const withoutQuery = url.split("?")[0] ?? url;
848
+ const withoutQuery = url.split(/[?#]/)[0] ?? url;
241
849
  const baseName = withoutQuery.split("/").pop() ?? "media";
242
850
  const sanitized = baseName.replace(/[^A-Za-z0-9._-]/gu, "").slice(0, 80);
243
851
  if (sanitized.length > 0 && /\.[A-Za-z0-9]{2,5}$/u.test(sanitized)) {
244
- return sanitized;
852
+ return { fileName: sanitized, extensionSynthesized: false };
245
853
  }
246
854
  const extension = this.extensionForContentType(contentType);
247
- return `${sanitized.length > 0 ? sanitized : "media"}${extension}`;
855
+ return {
856
+ fileName: `${sanitized.length > 0 ? sanitized : "media"}${extension}`,
857
+ extensionSynthesized: true,
858
+ };
248
859
  }
249
860
  /**
250
- * Resolves one default extension for a content type.
861
+ * Resolves one precise extension for a content type.
862
+ *
863
+ * @remarks
864
+ * The exact `mime-types` mapping wins (e.g., `image/webp` yields `.webp`,
865
+ * never the generic `.png`) so the synthesized extension can never shadow
866
+ * the authoritative server content type downstream.
251
867
  *
252
868
  * @param contentType - Reported content type.
253
869
  * @returns Extension with leading dot.
254
870
  */
255
871
  extensionForContentType(contentType) {
256
- if (contentType.startsWith("image/"))
872
+ const normalized = contentType.trim().toLowerCase();
873
+ if (normalized && normalized !== "application/octet-stream") {
874
+ const mapped = mimeExtension(normalized);
875
+ if (typeof mapped === "string" && /^[a-z0-9]+$/iu.test(mapped)) {
876
+ return `.${mapped.toLowerCase()}`;
877
+ }
878
+ }
879
+ if (normalized.startsWith("image/"))
257
880
  return ".png";
258
- if (contentType.startsWith("video/"))
881
+ if (normalized.startsWith("video/"))
259
882
  return ".mp4";
260
- if (contentType.startsWith("audio/"))
883
+ if (normalized.startsWith("audio/"))
261
884
  return ".mp3";
262
- if (contentType.includes("pdf"))
885
+ if (normalized.includes("pdf"))
263
886
  return ".pdf";
264
- if (contentType.includes("tar"))
265
- return ".tar";
266
887
  return ".bin";
267
888
  }
268
889
  }
890
+ /**
891
+ * Exact-match Office/document content types accepted for analysis.
892
+ *
893
+ * @remarks
894
+ * Mirrors EnriCode `VisionAnalyzeMediaUrlFetcher`: never extend this set
895
+ * with substring matching, so crafted types cannot bypass the filter.
896
+ */
897
+ const ALLOWED_EXACT_CONTENT_TYPES = new Set([
898
+ "application/pdf",
899
+ "application/msword",
900
+ "application/rtf",
901
+ "text/csv",
902
+ "text/plain",
903
+ "application/vnd.ms-powerpoint",
904
+ "application/vnd.ms-excel",
905
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
906
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
907
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
908
+ "application/vnd.oasis.opendocument.text",
909
+ "application/vnd.oasis.opendocument.presentation",
910
+ "application/vnd.oasis.opendocument.spreadsheet",
911
+ ]);
912
+ /**
913
+ * Reports whether an error message is already an owned bilingual download error.
914
+ *
915
+ * @param error - Error to inspect.
916
+ * @returns True when the message must propagate unchanged.
917
+ */
918
+ function isOwnedDownloadError(error) {
919
+ return (error.message.includes(MediaUrlFetcher.URL_SIZE_CAP_MARKER)
920
+ || error.message.startsWith("Media download")
921
+ || error.message.startsWith("Could not save the downloaded media")
922
+ || error.message.startsWith("El archivo remoto")
923
+ || error.message.startsWith("La descarga de media fue cancelada")
924
+ || error.message.startsWith("La descarga de media se detuvo")
925
+ || error.message.startsWith("La descarga de media expiró")
926
+ || error.message.startsWith("No se pudo guardar la media descargada"));
927
+ }
269
928
  //# sourceMappingURL=mediaUrlFetcher.js.map