gupt-sdk 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Node.js SDK for running end-to-end encrypted GUPT bots over user-selected relays.
4
4
 
5
- The SDK currently supports one-to-one text messages. Use a dedicated bot identity; never reuse a
6
- personal GUPT key.
5
+ The SDK supports one-to-one text, file, and voice-note messages. Use a dedicated bot identity;
6
+ never reuse a personal GUPT key.
7
7
 
8
8
  ```js
9
9
  import { GuptBot } from "gupt-sdk";
@@ -14,6 +14,14 @@ const bot = new GuptBot({
14
14
  });
15
15
 
16
16
  bot.onMessage(async (ctx) => {
17
+ if (ctx.file) {
18
+ const downloaded = await ctx.downloadFile();
19
+ await ctx.replyFile(downloaded.data, {
20
+ name: downloaded.name,
21
+ mime: downloaded.mime,
22
+ });
23
+ return;
24
+ }
17
25
  if (ctx.text.startsWith("/echo ")) await ctx.reply(ctx.text.slice(6));
18
26
  });
19
27
 
@@ -24,6 +32,23 @@ await bot.start();
24
32
  At least two distinct `wss://` bootstrap relays are required. The default Originless server is
25
33
  `https://originless.gupt.app`; pass `originless` as a URL or URL array to override it.
26
34
 
35
+ File contents use a separate AES-256-GCM key and nonce that remain inside the encrypted DM payload.
36
+ `ctx.file` exposes safe metadata without downloading anything. `ctx.downloadFile()` fetches the CID
37
+ through the configured Originless/IPFS gateways, enforces the advertised size, and returns a
38
+ `Uint8Array`. `ctx.replyFile()` accepts a file path, `Blob`, `Buffer`, `Uint8Array`, or
39
+ `ArrayBuffer`. The default per-file limit is 100 MiB and can be changed with
40
+ `mediaOptions.maxBytes`.
41
+
42
+ ```js
43
+ await ctx.replyFile("./report.pdf", {
44
+ name: "report.pdf",
45
+ mime: "application/pdf",
46
+ onProgress(update) {
47
+ console.log(update.phase, update.status);
48
+ },
49
+ });
50
+ ```
51
+
27
52
  Inbound messages teach the bot both the ingress relay and the sender relay hint carried in the
28
53
  signed `p` tag. Learned hints are bounded and obvious local/private addresses are rejected. Replies
29
54
  try the ingress relay first, then the peer's learned relays and the configured bootstrap relays.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gupt-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Node.js SDK for building end-to-end encrypted GUPT bots",
5
5
  "homepage": "https://github.com/besoeasy/gupt/tree/main/sdk#readme",
6
6
  "bugs": "https://github.com/besoeasy/gupt/issues",
@@ -17,6 +17,7 @@
17
17
  "exports": {
18
18
  ".": "./src/index.js",
19
19
  "./ingestion": "./src/ingestion.js",
20
+ "./media": "./src/media.js",
20
21
  "./queue": "./src/queue.js",
21
22
  "./relay-book": "./src/relayBook.js",
22
23
  "./wire": "./src/wire.js"
package/src/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { IngestionPipeline, RECENCY_WINDOW_MS } from "./ingestion.js";
2
+ import {
3
+ createMediaPayload,
4
+ downloadMediaPayload,
5
+ MAX_MEDIA_BYTES,
6
+ parseMediaPayload,
7
+ } from "./media.js";
2
8
  import { RelayPool } from "./pool.js";
3
9
  import { SendQueue } from "./queue.js";
4
10
  import { normalizeRelayUrl, RelayBook } from "./relayBook.js";
@@ -51,6 +57,7 @@ export class GuptBot {
51
57
  WebSocketImpl = globalThis.WebSocket,
52
58
  onDrop = null,
53
59
  logger = console,
60
+ mediaOptions = {},
54
61
  poolOptions = {},
55
62
  queueOptions = {},
56
63
  } = {}) {
@@ -91,6 +98,13 @@ export class GuptBot {
91
98
  this.maxHandlerBacklog = Math.max(1, Number(maxHandlerBacklog) || 1);
92
99
  this.acceptBotMessages = Boolean(acceptBotMessages);
93
100
  this.logger = logger;
101
+ this.mediaOptions = {
102
+ fetchImpl: mediaOptions.fetchImpl || globalThis.fetch,
103
+ gateways: mediaOptions.gateways,
104
+ maxBytes: Math.max(1, Number(mediaOptions.maxBytes) || MAX_MEDIA_BYTES),
105
+ uploadTimeoutMs: mediaOptions.uploadTimeoutMs,
106
+ downloadTimeoutMs: mediaOptions.downloadTimeoutMs,
107
+ };
94
108
  this.status = "idle";
95
109
  this.handlers = new Set();
96
110
  this.errorHandlers = new Set();
@@ -181,8 +195,19 @@ export class GuptBot {
181
195
  }
182
196
 
183
197
  const { payload, senderPubkey } = message;
184
- if (payload.type !== "text" || typeof payload.text !== "string") return;
185
- if (!payload.text.trim() || payload.text.length > MAX_TEXT_LENGTH) return;
198
+ let attachment = null;
199
+ if (payload.type === "text") {
200
+ if (typeof payload.text !== "string") return;
201
+ if (!payload.text.trim() || payload.text.length > MAX_TEXT_LENGTH) return;
202
+ } else if (payload.type === "media" || payload.type === "voice") {
203
+ try {
204
+ attachment = parseMediaPayload(payload, { maxBytes: this.mediaOptions.maxBytes });
205
+ } catch {
206
+ return;
207
+ }
208
+ } else {
209
+ return;
210
+ }
186
211
  if (payload.bot === true && !this.acceptBotMessages) return;
187
212
  if (this.allowlist && !this.allowlist.has(senderPubkey)) return;
188
213
 
@@ -203,7 +228,7 @@ export class GuptBot {
203
228
  const previous = this.handlerChains.get(senderPubkey) || Promise.resolve();
204
229
  const next = previous
205
230
  .catch(() => {})
206
- .then(() => this.dispatchMessage(message))
231
+ .then(() => this.dispatchMessage(message, attachment))
207
232
  .catch((error) =>
208
233
  this.emitError(error, { eventId: message.event.id, senderPubkey: message.senderPubkey }),
209
234
  )
@@ -219,15 +244,34 @@ export class GuptBot {
219
244
  this.handlerChains.set(senderPubkey, next);
220
245
  }
221
246
 
222
- async dispatchMessage(message) {
247
+ async dispatchMessage(message, attachment = null) {
248
+ const payload = Object.freeze({
249
+ ...message.payload,
250
+ ...(message.payload.media ? { media: Object.freeze({ ...message.payload.media }) } : {}),
251
+ });
252
+ const file = attachment
253
+ ? Object.freeze({
254
+ type: attachment.type,
255
+ name: attachment.name,
256
+ mime: attachment.mime,
257
+ size: attachment.size,
258
+ cid: attachment.cid,
259
+ durationMs: attachment.durationMs,
260
+ })
261
+ : null;
223
262
  const context = Object.freeze({
224
263
  id: message.event.id,
225
264
  senderPubkey: message.senderPubkey,
226
- text: message.payload.text,
227
- payload: Object.freeze({ ...message.payload }),
265
+ type: message.payload.type,
266
+ text: message.payload.text || "",
267
+ payload,
268
+ file,
228
269
  relayUrl: message.relayUrl,
229
270
  receivedAt: message.receivedAt,
230
271
  reply: (text) => this.reply(message.senderPubkey, text, message.relayUrl),
272
+ replyFile: (input, options) =>
273
+ this.replyFile(message.senderPubkey, input, options, message.relayUrl),
274
+ downloadFile: (options) => this.downloadFile(message.payload, options),
231
275
  });
232
276
  for (const handler of this.handlers) await handler(context);
233
277
  }
@@ -242,15 +286,58 @@ export class GuptBot {
242
286
  }
243
287
  this.reserveReply(peer);
244
288
 
245
- const event = buildDirectMessageEvent({
246
- secretHex: this.secretHex,
247
- recipientPubkey: peer,
248
- payload: {
289
+ return this.sendPayload(
290
+ peer,
291
+ {
249
292
  type: "text",
250
293
  text: replyText,
251
294
  ts: Date.now(),
252
295
  bot: true,
253
296
  },
297
+ ingressRelay,
298
+ );
299
+ }
300
+
301
+ async replyFile(peerPubkey, input, options = {}, ingressRelay = null) {
302
+ if (this.status !== "running") throw new Error("Bot is not running");
303
+ const peer = normalizePubkey(peerPubkey);
304
+ this.reserveReply(peer);
305
+ const payload = await createMediaPayload(input, {
306
+ fetchImpl: this.mediaOptions.fetchImpl,
307
+ maxBytes: this.mediaOptions.maxBytes,
308
+ timeoutMs: this.mediaOptions.uploadTimeoutMs,
309
+ ...options,
310
+ originlessServers: this.originlessServers,
311
+ allowPrivateServers: this.allowPrivateRelays,
312
+ });
313
+ return this.sendPayload(
314
+ peer,
315
+ {
316
+ ...payload,
317
+ ts: Date.now(),
318
+ bot: true,
319
+ },
320
+ ingressRelay,
321
+ );
322
+ }
323
+
324
+ downloadFile(payload, options = {}) {
325
+ return downloadMediaPayload(payload, {
326
+ fetchImpl: this.mediaOptions.fetchImpl,
327
+ gateways: this.mediaOptions.gateways,
328
+ maxBytes: this.mediaOptions.maxBytes,
329
+ timeoutMs: this.mediaOptions.downloadTimeoutMs,
330
+ ...options,
331
+ originlessServers: this.originlessServers,
332
+ allowPrivateServers: this.allowPrivateRelays,
333
+ });
334
+ }
335
+
336
+ sendPayload(peer, payload, ingressRelay) {
337
+ const event = buildDirectMessageEvent({
338
+ secretHex: this.secretHex,
339
+ recipientPubkey: peer,
340
+ payload,
254
341
  });
255
342
  const targets = this.relayBook.replyRelays(peer, ingressRelay);
256
343
  return this.queue.enqueue({
@@ -296,6 +383,7 @@ export class GuptBot {
296
383
  }
297
384
 
298
385
  export * from "./ingestion.js";
386
+ export * from "./media.js";
299
387
  export * from "./queue.js";
300
388
  export * from "./relayBook.js";
301
389
  export * from "./wire.js";
package/src/media.js ADDED
@@ -0,0 +1,477 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+
4
+ import { gcm } from "@noble/ciphers/aes.js";
5
+
6
+ export const MAX_MEDIA_BYTES = 100 * 1024 * 1024;
7
+ export const MEDIA_FETCH_TIMEOUT_MS = 10_000;
8
+ export const MEDIA_UPLOAD_BASE_TIMEOUT_MS = 30_000;
9
+ export const MEDIA_UPLOAD_MIN_BYTES_PER_SEC = 50_000;
10
+ export const MEDIA_UPLOAD_REDUNDANCY = 2;
11
+ export const PUBLIC_IPFS_GATEWAYS = Object.freeze([
12
+ "https://ipfs.io/ipfs/",
13
+ "https://inbrowser.link/ipfs/",
14
+ ]);
15
+
16
+ const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
17
+ const CID_RE = /^[A-Za-z0-9]{10,200}$/;
18
+
19
+ export class MediaError extends Error {
20
+ constructor(message, kind = "unknown", options) {
21
+ super(message, options);
22
+ this.name = "MediaError";
23
+ this.kind = kind;
24
+ }
25
+ }
26
+
27
+ function asBytes(value) {
28
+ if (value instanceof Uint8Array) {
29
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
30
+ }
31
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
32
+ if (ArrayBuffer.isView(value)) {
33
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function encodeBase64(value) {
39
+ return Buffer.from(value).toString("base64");
40
+ }
41
+
42
+ function decodeBase64(value, expectedLength, label) {
43
+ const encoded = String(value || "").trim();
44
+ if (!encoded || !BASE64_RE.test(encoded)) {
45
+ throw new MediaError(`Invalid media ${label}.`, "payload");
46
+ }
47
+ const bytes = Buffer.from(encoded, "base64");
48
+ if (bytes.length !== expectedLength) {
49
+ throw new MediaError(`Invalid media ${label} length.`, "payload");
50
+ }
51
+ return new Uint8Array(bytes);
52
+ }
53
+
54
+ function normalizeCid(value) {
55
+ const cid = String(value || "").trim();
56
+ if (!CID_RE.test(cid)) throw new MediaError("Invalid or missing media CID.", "payload");
57
+ return cid;
58
+ }
59
+
60
+ function normalizeName(value) {
61
+ const name = basename(String(value || "").trim()).slice(0, 255);
62
+ return name || "attachment.bin";
63
+ }
64
+
65
+ function normalizeMime(value) {
66
+ return (
67
+ String(value || "application/octet-stream")
68
+ .trim()
69
+ .slice(0, 200) || "application/octet-stream"
70
+ );
71
+ }
72
+
73
+ function normalizeSize(value, maxBytes) {
74
+ const size = Number(value);
75
+ if (!Number.isSafeInteger(size) || size < 0) {
76
+ throw new MediaError("Invalid media size.", "payload");
77
+ }
78
+ if (size > maxBytes) throw new MediaError(`Media exceeds the ${maxBytes}-byte limit.`, "size");
79
+ return size;
80
+ }
81
+
82
+ function normalizeServer(value, allowPrivate = false) {
83
+ try {
84
+ const url = new URL(String(value || "").trim());
85
+ if (url.username || url.password || url.search || url.hash) return null;
86
+ if (url.protocol !== "https:" && !(allowPrivate && url.protocol === "http:")) return null;
87
+ url.pathname = url.pathname.replace(/\/upload\/?$/i, "").replace(/\/+$/, "");
88
+ return url.toString().replace(/\/$/, "");
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function normalizeGateway(value) {
95
+ try {
96
+ const url = new URL(String(value || "").trim());
97
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
98
+ return null;
99
+ }
100
+ return url.toString().replace(/\/+$/, "") + "/";
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function pickUploadCid(payload) {
107
+ if (!payload || typeof payload !== "object") return null;
108
+ const direct = payload.cid || payload.CID || payload.hash || payload.Hash || payload.ipfs;
109
+ if (typeof direct === "string" && direct.trim()) return direct.trim();
110
+ return pickUploadCid(payload.value);
111
+ }
112
+
113
+ async function attachmentInput(input, options, maxBytes) {
114
+ let bytes;
115
+ let inferredName = "";
116
+ let inferredMime = "";
117
+
118
+ if (typeof input === "string") {
119
+ const details = await stat(input);
120
+ if (!details.isFile()) throw new MediaError("Attachment path must be a regular file.", "input");
121
+ if (details.size > maxBytes) {
122
+ throw new MediaError(`Media exceeds the ${maxBytes}-byte limit.`, "size");
123
+ }
124
+ bytes = new Uint8Array(await readFile(input));
125
+ inferredName = basename(input);
126
+ } else if (typeof Blob !== "undefined" && input instanceof Blob) {
127
+ if (input.size > maxBytes) {
128
+ throw new MediaError(`Media exceeds the ${maxBytes}-byte limit.`, "size");
129
+ }
130
+ bytes = new Uint8Array(await input.arrayBuffer());
131
+ inferredName = typeof input.name === "string" ? input.name : "";
132
+ inferredMime = input.type;
133
+ } else {
134
+ bytes = asBytes(input);
135
+ }
136
+
137
+ if (!bytes) {
138
+ throw new TypeError("File input must be a path, Blob, Buffer, Uint8Array, or ArrayBuffer");
139
+ }
140
+ if (bytes.byteLength > maxBytes) {
141
+ throw new MediaError(`Media exceeds the ${maxBytes}-byte limit.`, "size");
142
+ }
143
+
144
+ return {
145
+ bytes,
146
+ name: normalizeName(options.name || inferredName),
147
+ mime: normalizeMime(options.mime || inferredMime),
148
+ };
149
+ }
150
+
151
+ export function parseMediaPayload(payload, { maxBytes = MAX_MEDIA_BYTES } = {}) {
152
+ const type = String(payload?.type || "");
153
+ if (type !== "media" && type !== "voice") return null;
154
+ const media = payload?.media;
155
+ if (!media || typeof media !== "object" || Array.isArray(media)) {
156
+ throw new MediaError("Missing media payload.", "payload");
157
+ }
158
+
159
+ return {
160
+ type,
161
+ name: normalizeName(media.name || payload.text),
162
+ mime: normalizeMime(media.mime),
163
+ size: normalizeSize(media.size, maxBytes),
164
+ cid: normalizeCid(media.cid),
165
+ durationMs: Number.isFinite(Number(payload.durationMs))
166
+ ? Math.max(0, Number(payload.durationMs))
167
+ : 0,
168
+ key: decodeBase64(media.key, 32, "key"),
169
+ nonce: decodeBase64(media.nonce, 12, "nonce"),
170
+ };
171
+ }
172
+
173
+ export function encryptAttachmentBytes(value, options = {}) {
174
+ const bytes = asBytes(value);
175
+ if (!bytes) throw new TypeError("Attachment data must be bytes");
176
+ const key = options.key
177
+ ? Uint8Array.from(options.key)
178
+ : crypto.getRandomValues(new Uint8Array(32));
179
+ const nonce = options.nonce
180
+ ? Uint8Array.from(options.nonce)
181
+ : crypto.getRandomValues(new Uint8Array(12));
182
+ if (key.length !== 32) throw new TypeError("Media key must contain 32 bytes");
183
+ if (nonce.length !== 12) throw new TypeError("Media nonce must contain 12 bytes");
184
+ return {
185
+ encrypted: gcm(key, nonce).encrypt(bytes),
186
+ key,
187
+ nonce,
188
+ };
189
+ }
190
+
191
+ export function decryptAttachmentBytes(encrypted, key, nonce) {
192
+ const ciphertext = asBytes(encrypted);
193
+ if (!ciphertext) throw new TypeError("Encrypted attachment must be bytes");
194
+ try {
195
+ return gcm(Uint8Array.from(key), Uint8Array.from(nonce)).decrypt(ciphertext);
196
+ } catch (error) {
197
+ throw new MediaError("Unable to decrypt media attachment.", "decrypt", { cause: error });
198
+ }
199
+ }
200
+
201
+ function uploadTimeoutMs(size, override) {
202
+ if (override != null) return Math.max(1, Number(override) || 1);
203
+ return Math.max(
204
+ MEDIA_UPLOAD_BASE_TIMEOUT_MS,
205
+ Math.ceil((size / MEDIA_UPLOAD_MIN_BYTES_PER_SEC) * 1000),
206
+ );
207
+ }
208
+
209
+ async function uploadOne(server, encrypted, name, options) {
210
+ const controller = new AbortController();
211
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
212
+ const abort = () => controller.abort(options.signal?.reason);
213
+ options.signal?.addEventListener("abort", abort, { once: true });
214
+ if (options.signal?.aborted) abort();
215
+
216
+ try {
217
+ const form = new FormData();
218
+ form.append("file", new Blob([encrypted], { type: "application/octet-stream" }), `${name}.enc`);
219
+ const response = await options.fetchImpl(`${server}/upload`, {
220
+ method: "POST",
221
+ body: form,
222
+ signal: controller.signal,
223
+ });
224
+ if (!response.ok) {
225
+ const detail = (await response.text().catch(() => "")).trim().slice(0, 200);
226
+ throw new MediaError(
227
+ `Upload failed (${response.status})${detail ? `: ${detail}` : ""}`,
228
+ "upload",
229
+ );
230
+ }
231
+ const cid = normalizeCid(pickUploadCid(await response.json()));
232
+ return { cid, server };
233
+ } catch (error) {
234
+ if (error instanceof MediaError) throw error;
235
+ throw new MediaError(error?.message || "Media upload failed.", "upload", { cause: error });
236
+ } finally {
237
+ clearTimeout(timeout);
238
+ options.signal?.removeEventListener("abort", abort);
239
+ }
240
+ }
241
+
242
+ export async function uploadEncryptedAttachment(
243
+ encrypted,
244
+ {
245
+ name = "attachment.bin",
246
+ originlessServers,
247
+ fetchImpl = globalThis.fetch,
248
+ timeoutMs,
249
+ signal,
250
+ allowPrivateServers = false,
251
+ onProgress,
252
+ } = {},
253
+ ) {
254
+ if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required");
255
+ const bytes = asBytes(encrypted);
256
+ if (!bytes) throw new TypeError("Encrypted attachment must be bytes");
257
+ const servers = [
258
+ ...new Set(
259
+ (Array.isArray(originlessServers) ? originlessServers : [])
260
+ .map((server) => normalizeServer(server, allowPrivateServers))
261
+ .filter(Boolean),
262
+ ),
263
+ ];
264
+ if (!servers.length) throw new MediaError("No valid Originless server configured.", "upload");
265
+
266
+ const target = Math.min(MEDIA_UPLOAD_REDUNDANCY, servers.length);
267
+ const successes = [];
268
+ const failures = [];
269
+ let cursor = 0;
270
+
271
+ async function worker() {
272
+ while (cursor < servers.length && successes.length < target) {
273
+ const server = servers[cursor++];
274
+ onProgress?.({ phase: "uploading", status: "started", server });
275
+ try {
276
+ const result = await uploadOne(server, bytes, normalizeName(name), {
277
+ fetchImpl,
278
+ timeoutMs: uploadTimeoutMs(bytes.byteLength, timeoutMs),
279
+ signal,
280
+ });
281
+ successes.push(result);
282
+ onProgress?.({ phase: "uploading", status: "done", server });
283
+ } catch (error) {
284
+ failures.push(error);
285
+ onProgress?.({ phase: "uploading", status: "failed", server, error: error.message });
286
+ }
287
+ }
288
+ }
289
+
290
+ await Promise.all(Array.from({ length: target }, () => worker()));
291
+ if (!successes.length) {
292
+ throw new MediaError(
293
+ failures
294
+ .map((error) => error.message)
295
+ .filter(Boolean)
296
+ .join(" | ") || "Upload failed on all Originless servers.",
297
+ "upload",
298
+ );
299
+ }
300
+ return {
301
+ cid: successes[0].cid,
302
+ server: successes[0].server,
303
+ servers: successes.map((result) => result.server),
304
+ redundancyCount: successes.length,
305
+ };
306
+ }
307
+
308
+ export async function createMediaPayload(
309
+ input,
310
+ {
311
+ originlessServers,
312
+ type = "media",
313
+ name,
314
+ mime,
315
+ durationMs = 0,
316
+ maxBytes = MAX_MEDIA_BYTES,
317
+ fetchImpl = globalThis.fetch,
318
+ timeoutMs,
319
+ signal,
320
+ allowPrivateServers = false,
321
+ onProgress,
322
+ } = {},
323
+ ) {
324
+ if (type !== "media" && type !== "voice") {
325
+ throw new TypeError("Attachment type must be media or voice");
326
+ }
327
+ const attachment = await attachmentInput(input, { name, mime }, maxBytes);
328
+ onProgress?.({ phase: "encrypting", status: "started" });
329
+ const { encrypted, key, nonce } = encryptAttachmentBytes(attachment.bytes);
330
+ onProgress?.({ phase: "encrypting", status: "done" });
331
+ const uploaded = await uploadEncryptedAttachment(encrypted, {
332
+ name: attachment.name,
333
+ originlessServers,
334
+ fetchImpl,
335
+ timeoutMs,
336
+ signal,
337
+ allowPrivateServers,
338
+ onProgress,
339
+ });
340
+ return {
341
+ type,
342
+ text: attachment.name,
343
+ media: {
344
+ key: encodeBase64(key),
345
+ nonce: encodeBase64(nonce),
346
+ mime: attachment.mime,
347
+ name: attachment.name,
348
+ size: attachment.bytes.byteLength,
349
+ cid: uploaded.cid,
350
+ },
351
+ durationMs: Number.isFinite(Number(durationMs)) ? Math.max(0, Number(durationMs)) : 0,
352
+ };
353
+ }
354
+
355
+ async function readBoundedResponse(response, maxBytes) {
356
+ if (!response.ok) throw new MediaError(`Media fetch failed (${response.status}).`, "fetch");
357
+ const contentLength = Number(response.headers.get("content-length"));
358
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
359
+ throw new MediaError("Encrypted media response is too large.", "size");
360
+ }
361
+ if (!response.body?.getReader) {
362
+ const bytes = new Uint8Array(await response.arrayBuffer());
363
+ if (bytes.byteLength > maxBytes) {
364
+ throw new MediaError("Encrypted media response is too large.", "size");
365
+ }
366
+ return bytes;
367
+ }
368
+
369
+ const chunks = [];
370
+ let total = 0;
371
+ const reader = response.body.getReader();
372
+ while (true) {
373
+ const { value, done } = await reader.read();
374
+ if (done) break;
375
+ total += value.byteLength;
376
+ if (total > maxBytes) {
377
+ await reader.cancel();
378
+ throw new MediaError("Encrypted media response is too large.", "size");
379
+ }
380
+ chunks.push(value);
381
+ }
382
+ const result = new Uint8Array(total);
383
+ let offset = 0;
384
+ for (const chunk of chunks) {
385
+ result.set(chunk, offset);
386
+ offset += chunk.byteLength;
387
+ }
388
+ return result;
389
+ }
390
+
391
+ async function fetchEncrypted(url, options) {
392
+ const controller = new AbortController();
393
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
394
+ const abort = () => controller.abort(options.signal?.reason);
395
+ options.signal?.addEventListener("abort", abort, { once: true });
396
+ if (options.signal?.aborted) abort();
397
+ try {
398
+ const response = await options.fetchImpl(url, { signal: controller.signal });
399
+ return await readBoundedResponse(response, options.maxBytes);
400
+ } finally {
401
+ clearTimeout(timeout);
402
+ options.signal?.removeEventListener("abort", abort);
403
+ }
404
+ }
405
+
406
+ export async function downloadMediaPayload(
407
+ payload,
408
+ {
409
+ originlessServers = [],
410
+ gateways = PUBLIC_IPFS_GATEWAYS,
411
+ fetchImpl = globalThis.fetch,
412
+ timeoutMs = MEDIA_FETCH_TIMEOUT_MS,
413
+ maxBytes = MAX_MEDIA_BYTES,
414
+ signal,
415
+ allowPrivateServers = false,
416
+ } = {},
417
+ ) {
418
+ if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required");
419
+ const attachment = parseMediaPayload(payload, { maxBytes });
420
+ if (!attachment) throw new MediaError("Message does not contain a file.", "payload");
421
+
422
+ const originless = (Array.isArray(originlessServers) ? originlessServers : [])
423
+ .map((server) => normalizeServer(server, allowPrivateServers))
424
+ .filter(Boolean)
425
+ .map((server) => `${server}/ipfs/`);
426
+ const gatewayBases = (Array.isArray(gateways) ? gateways : [])
427
+ .map(normalizeGateway)
428
+ .filter(Boolean);
429
+ const urls = [...new Set([...originless, ...gatewayBases])].map(
430
+ (base) => `${base}${attachment.cid}`,
431
+ );
432
+ if (!urls.length) throw new MediaError("No media download gateway configured.", "fetch");
433
+
434
+ const controllers = urls.map(() => new AbortController());
435
+ const abortAll = () => controllers.forEach((controller) => controller.abort(signal?.reason));
436
+ signal?.addEventListener("abort", abortAll, { once: true });
437
+ if (signal?.aborted) abortAll();
438
+ try {
439
+ const result = await Promise.any(
440
+ urls.map(async (url, index) => {
441
+ const encrypted = await fetchEncrypted(url, {
442
+ fetchImpl,
443
+ timeoutMs,
444
+ maxBytes: attachment.size + 16,
445
+ signal: controllers[index].signal,
446
+ });
447
+ const data = decryptAttachmentBytes(encrypted, attachment.key, attachment.nonce);
448
+ if (data.byteLength !== attachment.size) {
449
+ throw new MediaError("Decrypted media size does not match its payload.", "decrypt");
450
+ }
451
+ controllers.forEach((controller, controllerIndex) => {
452
+ if (controllerIndex !== index) controller.abort();
453
+ });
454
+ return {
455
+ data,
456
+ name: attachment.name,
457
+ mime: attachment.mime,
458
+ size: attachment.size,
459
+ cid: attachment.cid,
460
+ type: attachment.type,
461
+ durationMs: attachment.durationMs,
462
+ sourceUrl: url,
463
+ };
464
+ }),
465
+ );
466
+ return result;
467
+ } catch (error) {
468
+ const reason = error instanceof AggregateError ? error.errors?.find(Boolean) : error;
469
+ if (reason instanceof MediaError) throw reason;
470
+ throw new MediaError(reason?.message || "Unable to download media.", "fetch", {
471
+ cause: reason,
472
+ });
473
+ } finally {
474
+ abortAll();
475
+ signal?.removeEventListener("abort", abortAll);
476
+ }
477
+ }