gupt-sdk 0.1.0 → 0.2.1
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 +58 -2
- package/package.json +2 -1
- package/src/index.js +98 -10
- package/src/media.js +477 -0
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
|
|
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,54 @@ 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
|
+
|
|
52
|
+
## Encrypted ntfy.sh-style notifications
|
|
53
|
+
|
|
54
|
+
A bot can initiate a message using only the recipient's GUPT public key; `reply()` and `replyFile()`
|
|
55
|
+
do not require a preceding inbound message. This makes `gupt-sdk` useful as an end-to-end encrypted,
|
|
56
|
+
self-hosted alternative to ntfy.sh for monitoring jobs, backups, CI, and server alerts.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { GuptBot } from "gupt-sdk";
|
|
60
|
+
|
|
61
|
+
const bot = new GuptBot({
|
|
62
|
+
secretHex: process.env.GUPT_BOT_KEY,
|
|
63
|
+
relays: ["wss://relay-a.example", "wss://relay-b.example"],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await bot.start();
|
|
67
|
+
|
|
68
|
+
await bot.reply(process.env.GUPT_USER_PUBKEY, "Backup completed successfully");
|
|
69
|
+
await bot.replyFile(process.env.GUPT_USER_PUBKEY, "./backup-report.txt", {
|
|
70
|
+
mime: "text/plain",
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Unlike a public notification topic, the recipient public key identifies who can decrypt the
|
|
75
|
+
notification. Events remain encrypted on relays and carry GUPT's standard 100-day expiration.
|
|
76
|
+
|
|
77
|
+
Public keys do not contain relay addresses. For reliable delivery, configure at least one relay
|
|
78
|
+
that the recipient also uses, or have the recipient message the bot first so it can learn their
|
|
79
|
+
signed relay hint. Learned hints are currently memory-only and are rediscovered after a bot
|
|
80
|
+
restart. GUPT retrieves stored notifications when it reconnects, but this is not an operating-system
|
|
81
|
+
push wake-up mechanism while the app is fully closed.
|
|
82
|
+
|
|
27
83
|
Inbound messages teach the bot both the ingress relay and the sender relay hint carried in the
|
|
28
84
|
signed `p` tag. Learned hints are bounded and obvious local/private addresses are rejected. Replies
|
|
29
85
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
-
|
|
185
|
-
if (
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
}
|