@minhvuong/pirate-storage-discord 0.1.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 ADDED
@@ -0,0 +1,15 @@
1
+ # @minhvuong/pirate-storage-discord
2
+
3
+ Discord storage provider for `@minhvuong/pirate-storage`.
4
+
5
+ - 8 MiB default parts (10 MiB validated maximum)
6
+ - bounded parallel uploads and up to 10 attachments per Discord message
7
+ - HMAC-signed durable manifests
8
+ - expiring CDN URL refresh
9
+ - logical HTTP Range streaming with CDN fallback
10
+ - native small-image previews and optional generated previews
11
+ - best-effort Discord media proxy transforms
12
+ - rollback and deletion
13
+
14
+ Discord does not expose scoped presigned upload URLs. Credentials must remain in the consumer's
15
+ server or Worker, which stays in the upload byte path.
@@ -0,0 +1,3 @@
1
+ export declare function signManifest(value: unknown, secret: string | Uint8Array): Promise<string>;
2
+ export declare function verifyManifestSignature(value: unknown, signature: string, secret: string | Uint8Array): Promise<boolean>;
3
+ export declare function validateManifestSecret(secret: string | Uint8Array): void;
@@ -0,0 +1,3 @@
1
+ export * from "./provider";
2
+ export * from "./transport";
3
+ export * from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,1046 @@
1
+ // src/provider.ts
2
+ import {
3
+ RangeNotSatisfiableError,
4
+ StorageError as StorageError2,
5
+ parseByteRange,
6
+ streamParts,
7
+ validateUploadSource
8
+ } from "@minhvuong/pirate-storage";
9
+ import { sha256 } from "@noble/hashes/sha2.js";
10
+ import { bytesToHex } from "@noble/hashes/utils.js";
11
+
12
+ // src/crypto.ts
13
+ var encoder = new TextEncoder;
14
+ async function signManifest(value, secret) {
15
+ const key = await crypto.subtle.importKey("raw", secretBytes(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
16
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(stableStringify(value)));
17
+ return toHex(new Uint8Array(signature));
18
+ }
19
+ async function verifyManifestSignature(value, signature, secret) {
20
+ if (!/^[a-f0-9]{64}$/.test(signature))
21
+ return false;
22
+ const expected = await signManifest(value, secret);
23
+ let difference = 0;
24
+ for (let index = 0;index < expected.length; index += 1) {
25
+ difference |= expected.charCodeAt(index) ^ signature.charCodeAt(index);
26
+ }
27
+ return difference === 0;
28
+ }
29
+ function validateManifestSecret(secret) {
30
+ if (secretBytes(secret).byteLength < 32) {
31
+ throw new Error("manifestSecret must contain at least 32 UTF-8 bytes");
32
+ }
33
+ }
34
+ function secretBytes(secret) {
35
+ const bytes = typeof secret === "string" ? encoder.encode(secret) : secret;
36
+ return new Uint8Array(bytes);
37
+ }
38
+ function stableStringify(value) {
39
+ if (value === null || typeof value !== "object")
40
+ return JSON.stringify(value) ?? "null";
41
+ if (Array.isArray(value))
42
+ return `[${value.map(stableStringify).join(",")}]`;
43
+ const record = value;
44
+ return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
45
+ }
46
+ function toHex(bytes) {
47
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
48
+ }
49
+
50
+ // src/transport.ts
51
+ import { StorageError } from "@minhvuong/pirate-storage";
52
+
53
+ // src/types.ts
54
+ import { z } from "zod";
55
+ var discordAttachmentSchema = z.object({
56
+ id: z.string(),
57
+ filename: z.string(),
58
+ size: z.number().int().nonnegative(),
59
+ url: z.string().url(),
60
+ proxy_url: z.string().url().optional(),
61
+ content_type: z.string().optional(),
62
+ width: z.number().int().positive().optional(),
63
+ height: z.number().int().positive().optional()
64
+ });
65
+ var discordMessageSchema = z.object({
66
+ id: z.string(),
67
+ channel_id: z.string(),
68
+ attachments: z.array(discordAttachmentSchema)
69
+ });
70
+ var discordChunkSchema = z.object({
71
+ index: z.number().int().nonnegative(),
72
+ size: z.number().int().positive(),
73
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
74
+ messageId: z.string(),
75
+ attachmentId: z.string(),
76
+ filename: z.string()
77
+ });
78
+ var discordPreviewLocatorSchema = z.object({
79
+ size: z.number().int().positive(),
80
+ contentType: z.string().regex(/^image\//),
81
+ width: z.number().int().positive(),
82
+ height: z.number().int().positive(),
83
+ messageId: z.string(),
84
+ attachmentId: z.string(),
85
+ filename: z.string()
86
+ });
87
+ var discordManifestSchema = z.object({
88
+ version: z.literal(1),
89
+ uploadId: z.string().uuid(),
90
+ connectionId: z.string().min(1),
91
+ name: z.string().min(1).max(255),
92
+ contentType: z.string().min(1).max(200),
93
+ size: z.number().int().positive(),
94
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
95
+ partSize: z.number().int().positive(),
96
+ createdAt: z.string().datetime(),
97
+ chunks: z.array(discordChunkSchema).min(1),
98
+ preview: discordPreviewLocatorSchema.optional()
99
+ });
100
+ var discordManifestEnvelopeSchema = z.object({
101
+ version: z.literal(1),
102
+ algorithm: z.literal("HMAC-SHA256"),
103
+ manifest: discordManifestSchema,
104
+ signature: z.string().regex(/^[a-f0-9]{64}$/)
105
+ });
106
+ var discordReceiptLocatorSchema = z.object({
107
+ channelId: z.string(),
108
+ manifestMessageId: z.string(),
109
+ manifestAttachmentId: z.string()
110
+ });
111
+
112
+ // src/transport.ts
113
+ var DISCORD_HOSTS = new Set(["discord.com", "discordapp.com"]);
114
+
115
+ class DiscordWebhookTransport {
116
+ connectionId;
117
+ #webhookUrl;
118
+ #fetch;
119
+ #logger;
120
+ #messageCache = new Map;
121
+ #nextRequestAt = 0;
122
+ constructor(webhookUrl, fetchImplementation = globalThis.fetch, logger) {
123
+ const parsed = validateWebhookUrl(webhookUrl);
124
+ this.#webhookUrl = parsed.url;
125
+ this.connectionId = `webhook:${parsed.webhookId}`;
126
+ this.#fetch = fetchImplementation;
127
+ this.#logger = logger;
128
+ }
129
+ async uploadAttachments(files, description, options = {}) {
130
+ if (files.length < 1 || files.length > 10) {
131
+ throw new StorageError("INVALID_INPUT", "Discord accepts between 1 and 10 attachments");
132
+ }
133
+ this.#logger?.debug?.("discord.upload.started", {
134
+ attachments: files.length,
135
+ bytes: files.reduce((total, file) => total + file.size, 0)
136
+ });
137
+ await this.#respectRateLimit(options.signal);
138
+ const form = new FormData;
139
+ form.append("payload_json", JSON.stringify({
140
+ content: description.slice(0, 2000),
141
+ allowed_mentions: { parse: [] },
142
+ attachments: files.map((file, index) => ({ id: index, filename: file.name }))
143
+ }));
144
+ files.forEach((file, index) => form.append(`files[${index}]`, file, file.name));
145
+ const response = await this.#fetchWithRateLimit(`${this.#webhookUrl}?wait=true`, { method: "POST", body: form, signal: options.signal }, options.signal);
146
+ const message = discordMessageSchema.parse(await response.json());
147
+ this.#logger?.debug?.("discord.upload.complete", {
148
+ messageId: message.id,
149
+ attachments: message.attachments.length
150
+ });
151
+ return message;
152
+ }
153
+ async getFreshAttachmentUrl(messageId, attachmentId, options = {}) {
154
+ const message = await this.getMessage(messageId, { signal: options.signal });
155
+ const attachment = attachmentId ? message.attachments.find((candidate) => candidate.id === attachmentId) : message.attachments[0];
156
+ if (!attachment) {
157
+ throw new StorageError("READ_FAILED", `Attachment '${attachmentId ?? "first"}' is missing from Discord message '${messageId}'`);
158
+ }
159
+ return options.proxy ? attachment.proxy_url ?? attachment.url : attachment.url;
160
+ }
161
+ invalidateMessage(messageId) {
162
+ this.#messageCache.delete(messageId);
163
+ this.#logger?.debug?.("discord.message_cache.invalidated", { messageId });
164
+ }
165
+ async deleteMessage(messageId, options = {}) {
166
+ this.invalidateMessage(messageId);
167
+ for (let attempt = 0;attempt < 6; attempt += 1) {
168
+ await this.#respectRateLimit(options.signal);
169
+ const response = await this.#fetch(`${this.#webhookUrl}/messages/${messageId}`, {
170
+ method: "DELETE",
171
+ signal: options.signal
172
+ });
173
+ this.#rememberRateLimit(response);
174
+ if (response.ok || response.status === 404)
175
+ return;
176
+ if (response.status !== 429) {
177
+ throw new StorageError("DELETE_FAILED", `Discord message deletion failed (${response.status})`, { status: 502 });
178
+ }
179
+ await sleep(await getRetryAfter(response), options.signal);
180
+ }
181
+ throw new StorageError("DELETE_FAILED", "Discord message deletion remained rate limited", {
182
+ status: 502
183
+ });
184
+ }
185
+ async getMessage(messageId, options = {}) {
186
+ const cached = this.#messageCache.get(messageId);
187
+ if (cached && cached.expiresAt > Date.now()) {
188
+ this.#logger?.debug?.("discord.message_cache.hit", { messageId });
189
+ return cached.message;
190
+ }
191
+ this.#logger?.debug?.("discord.message_cache.miss", { messageId });
192
+ await this.#respectRateLimit(options.signal);
193
+ const response = await this.#fetchWithRateLimit(`${this.#webhookUrl}/messages/${encodeURIComponent(messageId)}`, { signal: options.signal }, options.signal);
194
+ const message = discordMessageSchema.parse(await response.json());
195
+ this.#messageCache.set(messageId, {
196
+ message,
197
+ expiresAt: attachmentCacheExpiry(message)
198
+ });
199
+ return message;
200
+ }
201
+ async#fetchWithRateLimit(url, init, signal) {
202
+ for (let attempt = 0;attempt < 6; attempt += 1) {
203
+ const response = await this.#fetch(url, init);
204
+ this.#rememberRateLimit(response);
205
+ if (response.status !== 429) {
206
+ if (!response.ok) {
207
+ const detail = await response.text();
208
+ throw new StorageError("UPLOAD_FAILED", `Discord API failed (${response.status}): ${detail.slice(0, 300)}`, { status: response.status === 413 ? 413 : 502 });
209
+ }
210
+ return response;
211
+ }
212
+ const retryAfter = await getRetryAfter(response);
213
+ this.#logger?.warn?.("discord.rate_limit.retry", { attempt: attempt + 1, retryAfter });
214
+ await sleep(retryAfter, signal);
215
+ }
216
+ throw new StorageError("UPLOAD_FAILED", "Discord rate limit did not clear after six retries", {
217
+ status: 503
218
+ });
219
+ }
220
+ #rememberRateLimit(response) {
221
+ if (response.headers.get("x-ratelimit-remaining") !== "0")
222
+ return;
223
+ const resetAfterSeconds = Number(response.headers.get("x-ratelimit-reset-after"));
224
+ if (Number.isFinite(resetAfterSeconds)) {
225
+ this.#nextRequestAt = Math.max(this.#nextRequestAt, Date.now() + resetAfterSeconds * 1000);
226
+ }
227
+ }
228
+ async#respectRateLimit(signal) {
229
+ const delay = this.#nextRequestAt - Date.now();
230
+ if (delay > 0) {
231
+ this.#logger?.debug?.("discord.rate_limit.wait", { delay });
232
+ await sleep(delay, signal);
233
+ }
234
+ }
235
+ }
236
+ function validateWebhookUrl(value) {
237
+ const url = new URL(value);
238
+ const pathMatch = url.pathname.match(/^\/api(?:\/v\d+)?\/webhooks\/(\d+)\/[^/]+\/?$/);
239
+ if (url.protocol !== "https:" || !DISCORD_HOSTS.has(url.hostname) || !pathMatch?.[1]) {
240
+ throw new StorageError("INVALID_INPUT", "webhookUrl must be an HTTPS Discord incoming webhook URL");
241
+ }
242
+ url.search = "";
243
+ url.hash = "";
244
+ return { url: url.toString().replace(/\/$/, ""), webhookId: pathMatch[1] };
245
+ }
246
+ function attachmentCacheExpiry(message) {
247
+ const fallback = Date.now() + 5 * 60 * 1000;
248
+ let earliest = Number.POSITIVE_INFINITY;
249
+ for (const attachment of message.attachments) {
250
+ const expiryHex = new URL(attachment.url).searchParams.get("ex");
251
+ if (!expiryHex)
252
+ continue;
253
+ const expiry = Number.parseInt(expiryHex, 16) * 1000 - 60000;
254
+ if (Number.isFinite(expiry))
255
+ earliest = Math.min(earliest, expiry);
256
+ }
257
+ return Math.max(Date.now() + 5000, Math.min(fallback, earliest));
258
+ }
259
+ async function getRetryAfter(response) {
260
+ try {
261
+ const body = await response.clone().json();
262
+ if (typeof body.retry_after === "number")
263
+ return Math.max(250, body.retry_after * 1000);
264
+ } catch {}
265
+ const headerSeconds = Number(response.headers.get("retry-after"));
266
+ return Number.isFinite(headerSeconds) ? Math.max(250, headerSeconds * 1000) : 1000;
267
+ }
268
+ function sleep(milliseconds, signal) {
269
+ if (signal?.aborted)
270
+ return Promise.reject(signal.reason);
271
+ return new Promise((resolve, reject) => {
272
+ const timeout = setTimeout(resolve, milliseconds);
273
+ signal?.addEventListener("abort", () => {
274
+ clearTimeout(timeout);
275
+ reject(signal.reason);
276
+ }, { once: true });
277
+ });
278
+ }
279
+
280
+ // src/provider.ts
281
+ var MEBIBYTE = 1024 * 1024;
282
+ var DEFAULT_PART_SIZE = 8 * MEBIBYTE;
283
+ var DEFAULT_MAX_FILE_SIZE = 2000000000;
284
+ var DEFAULT_MEDIA_WINDOW = 2 * MEBIBYTE;
285
+ var MAX_DISCORD_ATTACHMENT_SIZE = 10 * MEBIBYTE;
286
+ var MANIFEST_CACHE_MS = 5 * 60 * 1000;
287
+
288
+ class DiscordStorageProvider {
289
+ id = "discord";
290
+ capabilities;
291
+ #transport;
292
+ #secret;
293
+ #connectionId;
294
+ #partSize;
295
+ #maxFileSize;
296
+ #attachmentsPerMessage;
297
+ #uploadConcurrency;
298
+ #mediaWindowBytes;
299
+ #fetch;
300
+ #logger;
301
+ #manifestCache = new Map;
302
+ constructor(options) {
303
+ validateManifestSecret(options.manifestSecret);
304
+ if (!options.transport && !options.webhookUrl) {
305
+ throw new StorageError2("INVALID_INPUT", "Discord requires webhookUrl or a custom transport");
306
+ }
307
+ this.#fetch = options.fetch ?? globalThis.fetch;
308
+ this.#transport = options.transport ?? new DiscordWebhookTransport(options.webhookUrl, this.#fetch, options.logger);
309
+ this.#connectionId = options.connectionId ?? this.#transport.connectionId;
310
+ this.#secret = options.manifestSecret;
311
+ this.#partSize = boundedInteger(options.partSize ?? DEFAULT_PART_SIZE, 1, MAX_DISCORD_ATTACHMENT_SIZE, "partSize");
312
+ this.#maxFileSize = boundedInteger(options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, 1, Number.MAX_SAFE_INTEGER, "maxFileSize");
313
+ this.#attachmentsPerMessage = boundedInteger(options.attachmentsPerMessage ?? 1, 1, 10, "attachmentsPerMessage");
314
+ this.#uploadConcurrency = boundedInteger(options.uploadConcurrency ?? 3, 1, 16, "uploadConcurrency");
315
+ this.#mediaWindowBytes = boundedInteger(options.mediaWindowBytes ?? DEFAULT_MEDIA_WINDOW, 1, Number.MAX_SAFE_INTEGER, "mediaWindowBytes");
316
+ this.#logger = options.logger;
317
+ this.capabilities = Object.freeze({
318
+ upload: true,
319
+ uploadStream: true,
320
+ multipartUpload: true,
321
+ parallelUpload: true,
322
+ resumableUpload: true,
323
+ read: true,
324
+ rangeRead: true,
325
+ delete: true,
326
+ preview: true,
327
+ nativePreview: true,
328
+ imageTransform: "experimental",
329
+ directClientUpload: false,
330
+ maxFileSize: this.#maxFileSize,
331
+ preferredPartSize: this.#partSize,
332
+ maxPartSize: MAX_DISCORD_ATTACHMENT_SIZE,
333
+ maxPartsPerRequest: 10
334
+ });
335
+ }
336
+ async upload(source, options) {
337
+ validateUploadSource(source, this.#maxFileSize);
338
+ validatePreview(options.preview, this.#partSize);
339
+ options.signal?.throwIfAborted();
340
+ const uploadId = crypto.randomUUID();
341
+ const createdMessageIds = new Set;
342
+ const chunks = [];
343
+ chunks.length = Math.ceil(source.size / this.#partSize);
344
+ const hasher = sha256.create();
345
+ const concurrency = boundedInteger(options.concurrency ?? this.#uploadConcurrency, 1, 16, "concurrency");
346
+ const inFlight = new Set;
347
+ const allOperations = [];
348
+ let batch = [];
349
+ let uploadedBytes = 0;
350
+ let preview;
351
+ const schedule = (pending) => {
352
+ const operation = this.#uploadChunkBatch(pending, source, chunks.length, createdMessageIds, options.signal).then((uploaded) => {
353
+ for (const chunk of uploaded) {
354
+ chunks[chunk.index] = chunk;
355
+ uploadedBytes += chunk.size;
356
+ }
357
+ options.onProgress?.({
358
+ phase: "uploading",
359
+ uploadedBytes,
360
+ totalBytes: source.size,
361
+ completedParts: chunks.filter(Boolean).length,
362
+ totalParts: chunks.length
363
+ });
364
+ });
365
+ inFlight.add(operation);
366
+ allOperations.push(operation);
367
+ operation.then(() => inFlight.delete(operation), () => inFlight.delete(operation));
368
+ };
369
+ try {
370
+ let index = 0;
371
+ for await (const bytes of streamParts(source.stream(), source.size, this.#partSize)) {
372
+ options.signal?.throwIfAborted();
373
+ hasher.update(bytes);
374
+ const directImage = chunks.length === 1 && source.contentType.startsWith("image/");
375
+ const filename = directImage ? source.name : `${uploadId}.part-${String(index + 1).padStart(6, "0")}`;
376
+ batch.push({
377
+ index,
378
+ bytes,
379
+ sha256: bytesToHex(sha256(bytes)),
380
+ filename,
381
+ file: new File([exactArrayBuffer(bytes)], filename, {
382
+ type: directImage ? source.contentType : "application/octet-stream"
383
+ })
384
+ });
385
+ index += 1;
386
+ if (batch.length === this.#attachmentsPerMessage) {
387
+ schedule(batch);
388
+ batch = [];
389
+ if (inFlight.size >= concurrency)
390
+ await Promise.race(inFlight);
391
+ }
392
+ }
393
+ if (batch.length > 0)
394
+ schedule(batch);
395
+ await Promise.all(allOperations);
396
+ if (chunks.some((chunk) => !chunk)) {
397
+ throw new StorageError2("UPLOAD_FAILED", "Discord upload did not return every file part");
398
+ }
399
+ const directAttachment = chunks.length === 1 ? chunks[0] : undefined;
400
+ if (directAttachment && source.contentType.startsWith("image/")) {
401
+ const message = await this.#transport.getMessage(directAttachment.messageId, {
402
+ signal: options.signal
403
+ });
404
+ const attachment = message.attachments.find((candidate) => candidate.id === directAttachment.attachmentId);
405
+ if (attachment?.width && attachment.height) {
406
+ preview = {
407
+ size: attachment.size,
408
+ contentType: attachment.content_type ?? source.contentType,
409
+ width: attachment.width,
410
+ height: attachment.height,
411
+ messageId: directAttachment.messageId,
412
+ attachmentId: directAttachment.attachmentId,
413
+ filename: attachment.filename
414
+ };
415
+ }
416
+ }
417
+ if (options.preview) {
418
+ preview = await this.#uploadPreview(source.name, options.preview, createdMessageIds, options.signal);
419
+ }
420
+ const manifest = {
421
+ version: 1,
422
+ uploadId,
423
+ connectionId: this.#connectionId,
424
+ name: source.name,
425
+ contentType: source.contentType,
426
+ size: source.size,
427
+ sha256: bytesToHex(hasher.digest()),
428
+ partSize: this.#partSize,
429
+ createdAt: new Date().toISOString(),
430
+ chunks,
431
+ preview
432
+ };
433
+ const envelope = {
434
+ version: 1,
435
+ algorithm: "HMAC-SHA256",
436
+ manifest,
437
+ signature: await signManifest(manifest, this.#secret)
438
+ };
439
+ options.onProgress?.({
440
+ phase: "finalizing",
441
+ uploadedBytes: source.size,
442
+ totalBytes: source.size,
443
+ completedParts: chunks.length,
444
+ totalParts: chunks.length
445
+ });
446
+ const manifestFile = new File([JSON.stringify(envelope)], `${uploadId}.manifest.json`, {
447
+ type: "application/json"
448
+ });
449
+ const manifestMessage = await this.#transport.uploadAttachments([manifestFile], `Storage manifest · ${safeMessageName(source.name)} · ${chunks.length} parts`, { signal: options.signal });
450
+ createdMessageIds.add(manifestMessage.id);
451
+ const manifestAttachment = manifestMessage.attachments[0];
452
+ if (!manifestAttachment || manifestAttachment.size !== manifestFile.size) {
453
+ throw new StorageError2("UPLOAD_FAILED", "Discord returned an invalid manifest attachment");
454
+ }
455
+ this.#manifestCache.set(manifestMessage.id, {
456
+ manifest,
457
+ expiresAt: Date.now() + MANIFEST_CACHE_MS
458
+ });
459
+ return toReceipt(manifest, manifestMessage.id, manifestMessage.channel_id, manifestAttachment.id);
460
+ } catch (error) {
461
+ await Promise.allSettled(allOperations);
462
+ await Promise.allSettled([...createdMessageIds].map((messageId) => this.#transport.deleteMessage(messageId).catch((cleanupError) => {
463
+ this.#logger?.warn?.("Could not roll back Discord message", {
464
+ messageId,
465
+ error: String(cleanupError)
466
+ });
467
+ })));
468
+ throw error;
469
+ }
470
+ }
471
+ async stat(receipt) {
472
+ const manifest = await this.#getManifest(receipt);
473
+ return {
474
+ name: manifest.name,
475
+ contentType: manifest.contentType,
476
+ size: manifest.size,
477
+ sha256: manifest.sha256,
478
+ preview: manifest.preview ? previewSummary(manifest.preview) : undefined
479
+ };
480
+ }
481
+ async beginMultipart(file, _options) {
482
+ validateUploadSource({
483
+ ...file,
484
+ stream: () => {
485
+ throw new StorageError2("INVALID_INPUT", "Multipart sources do not expose one stream");
486
+ }
487
+ }, this.#maxFileSize);
488
+ return {
489
+ partSize: this.#partSize,
490
+ maxConcurrency: this.#uploadConcurrency,
491
+ state: {
492
+ version: 1,
493
+ uploadId: crypto.randomUUID(),
494
+ connectionId: this.#connectionId,
495
+ name: file.name,
496
+ contentType: file.contentType,
497
+ size: file.size,
498
+ createdAt: new Date().toISOString()
499
+ }
500
+ };
501
+ }
502
+ async uploadPart(session, part) {
503
+ const state = parseMultipartState(session.state, this.#connectionId);
504
+ if (!Number.isInteger(part.index) || part.index < 0) {
505
+ throw new StorageError2("INVALID_INPUT", "Multipart part index must be non-negative");
506
+ }
507
+ if (part.bytes.byteLength <= 0 || part.bytes.byteLength > this.#partSize) {
508
+ throw new StorageError2("INVALID_INPUT", `Multipart part must contain between 1 and ${this.#partSize} bytes`);
509
+ }
510
+ const expectedParts = Math.ceil(state.size / this.#partSize);
511
+ if (part.index >= expectedParts) {
512
+ throw new StorageError2("INVALID_INPUT", "Multipart part index exceeds the declared file");
513
+ }
514
+ const expectedSize = part.index === expectedParts - 1 ? state.size - part.index * this.#partSize : this.#partSize;
515
+ if (part.bytes.byteLength !== expectedSize) {
516
+ throw new StorageError2("INVALID_INPUT", `Multipart part ${part.index} must contain exactly ${expectedSize} bytes`);
517
+ }
518
+ part.signal?.throwIfAborted();
519
+ const directImage = expectedParts === 1 && state.contentType.startsWith("image/");
520
+ const filename = directImage ? state.name : `${state.uploadId}.part-${String(part.index + 1).padStart(6, "0")}`;
521
+ const message = await this.#transport.uploadAttachments([
522
+ new File([exactArrayBuffer(part.bytes)], filename, {
523
+ type: directImage ? state.contentType : "application/octet-stream"
524
+ })
525
+ ], `Storage part ${part.index + 1}/${expectedParts} · ${safeMessageName(state.name)}`, { signal: part.signal });
526
+ const attachment = message.attachments[0];
527
+ if (!attachment || attachment.size !== part.bytes.byteLength) {
528
+ await this.#transport.deleteMessage(message.id).catch(() => {
529
+ return;
530
+ });
531
+ throw new StorageError2("UPLOAD_FAILED", `Discord returned an invalid multipart part`);
532
+ }
533
+ return {
534
+ index: part.index,
535
+ size: part.bytes.byteLength,
536
+ sha256: bytesToHex(sha256(part.bytes)),
537
+ locator: {
538
+ messageId: message.id,
539
+ attachmentId: attachment.id,
540
+ filename: attachment.filename,
541
+ contentType: attachment.content_type,
542
+ width: attachment.width,
543
+ height: attachment.height
544
+ }
545
+ };
546
+ }
547
+ async completeMultipart(session, parts, file, options) {
548
+ const state = parseMultipartState(session.state, this.#connectionId);
549
+ if (file.name !== state.name || file.contentType !== state.contentType || file.size !== state.size || !/^[a-f0-9]{64}$/.test(file.sha256)) {
550
+ throw new StorageError2("INVALID_INPUT", "Completed file metadata does not match the session");
551
+ }
552
+ validatePreview(options.preview, this.#partSize);
553
+ const sorted = parts.toSorted((left, right) => left.index - right.index);
554
+ const expectedParts = Math.ceil(file.size / this.#partSize);
555
+ if (sorted.length !== expectedParts) {
556
+ throw new StorageError2("INVALID_INPUT", `Expected ${expectedParts} multipart parts`);
557
+ }
558
+ const chunks = sorted.map((part, index) => {
559
+ const locator = parseMultipartPartLocator(part.locator);
560
+ const expectedSize = index === expectedParts - 1 ? file.size - index * this.#partSize : this.#partSize;
561
+ if (part.index !== index || part.size !== expectedSize || !/^[a-f0-9]{64}$/.test(part.sha256)) {
562
+ throw new StorageError2("INTEGRITY_FAILED", `Multipart part ${index} is invalid`);
563
+ }
564
+ return { index, size: part.size, sha256: part.sha256, ...locator };
565
+ });
566
+ const createdMessageIds = new Set;
567
+ try {
568
+ const nativePreview = chunks.length === 1 ? previewFromMultipartPart(sorted[0], state.contentType) : undefined;
569
+ const preview = options.preview ? await this.#uploadPreview(state.name, options.preview, createdMessageIds, options.signal) : nativePreview;
570
+ const manifest = {
571
+ version: 1,
572
+ uploadId: state.uploadId,
573
+ connectionId: state.connectionId,
574
+ name: state.name,
575
+ contentType: state.contentType,
576
+ size: state.size,
577
+ sha256: file.sha256,
578
+ partSize: this.#partSize,
579
+ createdAt: state.createdAt,
580
+ chunks,
581
+ preview
582
+ };
583
+ const envelope = {
584
+ version: 1,
585
+ algorithm: "HMAC-SHA256",
586
+ manifest,
587
+ signature: await signManifest(manifest, this.#secret)
588
+ };
589
+ const manifestFile = new File([JSON.stringify(envelope)], `${state.uploadId}.manifest.json`, {
590
+ type: "application/json"
591
+ });
592
+ const message = await this.#transport.uploadAttachments([manifestFile], `Storage manifest · ${safeMessageName(state.name)} · ${chunks.length} parts`, { signal: options.signal });
593
+ createdMessageIds.add(message.id);
594
+ const attachment = message.attachments[0];
595
+ if (!attachment || attachment.size !== manifestFile.size) {
596
+ throw new StorageError2("UPLOAD_FAILED", "Discord returned an invalid manifest attachment");
597
+ }
598
+ this.#manifestCache.set(message.id, {
599
+ manifest,
600
+ expiresAt: Date.now() + MANIFEST_CACHE_MS
601
+ });
602
+ return toReceipt(manifest, message.id, message.channel_id, attachment.id);
603
+ } catch (error) {
604
+ await Promise.allSettled([...createdMessageIds].map((messageId) => this.#transport.deleteMessage(messageId)));
605
+ throw error;
606
+ }
607
+ }
608
+ async abortMultipart(session, parts) {
609
+ parseMultipartState(session.state, this.#connectionId);
610
+ const messageIds = new Set(parts.map((part) => parseMultipartPartLocator(part.locator).messageId));
611
+ const results = await Promise.allSettled([...messageIds].map((messageId) => this.#transport.deleteMessage(messageId)));
612
+ const failures = results.filter((result) => result.status === "rejected");
613
+ if (failures.length > 0) {
614
+ throw new StorageError2("DELETE_FAILED", `Could not abort ${failures.length} Discord multipart messages`, { cause: failures });
615
+ }
616
+ }
617
+ async open(receipt, options = {}) {
618
+ const manifest = await this.#getManifest(receipt, options.signal);
619
+ let range;
620
+ try {
621
+ range = parseByteRange(options.range, manifest.size);
622
+ } catch (error) {
623
+ if (error instanceof RangeNotSatisfiableError) {
624
+ return new Response(null, {
625
+ status: 416,
626
+ headers: { "Content-Range": `bytes */${manifest.size}` }
627
+ });
628
+ }
629
+ throw error;
630
+ }
631
+ const start = range?.start ?? 0;
632
+ const requestedEnd = range?.end ?? manifest.size - 1;
633
+ const media = isStreamableMedia(manifest.contentType) && !options.download;
634
+ const end = media ? Math.min(requestedEnd, start + this.#mediaWindowBytes - 1) : requestedEnd;
635
+ const partial = Boolean(range) || end < manifest.size - 1;
636
+ const length = end - start + 1;
637
+ const headers = new Headers({
638
+ "Accept-Ranges": "bytes",
639
+ "Cache-Control": "public, max-age=31536000, immutable",
640
+ "Content-Length": String(length),
641
+ "Content-Type": manifest.contentType,
642
+ "Content-Disposition": contentDisposition(manifest.name, options.download ?? false),
643
+ ETag: `"sha256-${manifest.sha256}"`,
644
+ "X-Content-Type-Options": "nosniff",
645
+ "X-Storage-Provider": "discord",
646
+ "X-Storage-Parts": String(manifest.chunks.length)
647
+ });
648
+ if (partial)
649
+ headers.set("Content-Range", `bytes ${start}-${end}/${manifest.size}`);
650
+ if (options.method === "HEAD")
651
+ return new Response(null, { status: partial ? 206 : 200, headers });
652
+ return new Response(this.#streamRange(manifest, start, end, options.signal), {
653
+ status: partial ? 206 : 200,
654
+ headers
655
+ });
656
+ }
657
+ async preview(receipt, options = {}) {
658
+ const manifest = await this.#getManifest(receipt, options.signal);
659
+ if (!manifest.preview) {
660
+ throw new StorageError2("READ_FAILED", "This Discord resource has no preview", {
661
+ status: 404
662
+ });
663
+ }
664
+ const location = await this.previewUrl(receipt, options);
665
+ return new Response(null, {
666
+ status: 307,
667
+ headers: { Location: location, "Cache-Control": "no-store" }
668
+ });
669
+ }
670
+ async previewUrl(receipt, options = {}) {
671
+ const manifest = await this.#getManifest(receipt, options.signal);
672
+ if (!manifest.preview) {
673
+ throw new StorageError2("READ_FAILED", "This Discord resource has no preview", {
674
+ status: 404
675
+ });
676
+ }
677
+ const freshUrl = await this.#transport.getFreshAttachmentUrl(manifest.preview.messageId, manifest.preview.attachmentId, { proxy: true, signal: options.signal });
678
+ const url = new URL(freshUrl);
679
+ const dimensions = normalizedDimensions(manifest.preview, options.width, options.height);
680
+ if (dimensions) {
681
+ url.searchParams.set("width", String(dimensions.width));
682
+ url.searchParams.set("height", String(dimensions.height));
683
+ }
684
+ if (options.format)
685
+ url.searchParams.set("format", options.format);
686
+ if (options.quality)
687
+ url.searchParams.set("quality", options.quality);
688
+ return url.toString();
689
+ }
690
+ async refresh(receipt) {
691
+ const manifest = await this.#getManifest(receipt);
692
+ const parts = await Promise.all(manifest.chunks.map((chunk) => this.#transport.getFreshAttachmentUrl(chunk.messageId, chunk.attachmentId)));
693
+ const preview = manifest.preview ? await this.#transport.getFreshAttachmentUrl(manifest.preview.messageId, manifest.preview.attachmentId, { proxy: true }) : undefined;
694
+ return { expires: true, parts, preview };
695
+ }
696
+ async delete(receipt, options = {}) {
697
+ const manifest = await this.#getManifest(receipt, options.signal);
698
+ const contentMessageIds = new Set([
699
+ ...manifest.chunks.map((chunk) => chunk.messageId),
700
+ ...manifest.preview ? [manifest.preview.messageId] : []
701
+ ]);
702
+ const results = await Promise.allSettled([...contentMessageIds].map((messageId) => this.#transport.deleteMessage(messageId, { signal: options.signal })));
703
+ const failures = results.filter((result) => result.status === "rejected");
704
+ if (failures.length > 0) {
705
+ throw new StorageError2("DELETE_FAILED", `Could not delete ${failures.length} Discord content messages`, { status: 502, cause: failures });
706
+ }
707
+ await this.#transport.deleteMessage(receipt.locator.manifestMessageId, {
708
+ signal: options.signal
709
+ });
710
+ this.#manifestCache.delete(receipt.locator.manifestMessageId);
711
+ }
712
+ async#uploadChunkBatch(pending, source, totalParts, createdMessageIds, signal) {
713
+ const first = pending[0];
714
+ const last = pending[pending.length - 1];
715
+ if (!first || !last)
716
+ return [];
717
+ const directImage = totalParts === 1 && source.contentType.startsWith("image/");
718
+ const description = directImage ? `Storage original + preview · ${safeMessageName(source.name)}` : `Storage parts ${first.index + 1}-${last.index + 1}/${totalParts} · ${safeMessageName(source.name)}`;
719
+ const message = await this.#transport.uploadAttachments(pending.map((chunk) => chunk.file), description, { signal });
720
+ createdMessageIds.add(message.id);
721
+ return pending.map((chunk) => {
722
+ const attachment = message.attachments.find((candidate) => candidate.filename === chunk.filename);
723
+ if (!attachment || attachment.size !== chunk.bytes.byteLength) {
724
+ throw new StorageError2("UPLOAD_FAILED", `Discord returned an invalid attachment for part ${chunk.index + 1}`);
725
+ }
726
+ return {
727
+ index: chunk.index,
728
+ size: chunk.bytes.byteLength,
729
+ sha256: chunk.sha256,
730
+ messageId: message.id,
731
+ attachmentId: attachment.id,
732
+ filename: attachment.filename
733
+ };
734
+ });
735
+ }
736
+ async#uploadPreview(sourceName, generated, createdMessageIds, signal) {
737
+ const message = await this.#transport.uploadAttachments([generated.file], `Storage preview · ${safeMessageName(sourceName)} · ${generated.width}×${generated.height}`, { signal });
738
+ createdMessageIds.add(message.id);
739
+ const attachment = message.attachments[0];
740
+ if (!attachment || attachment.size !== generated.file.size) {
741
+ throw new StorageError2("UPLOAD_FAILED", "Discord returned an invalid preview attachment");
742
+ }
743
+ return {
744
+ size: attachment.size,
745
+ contentType: generated.file.type,
746
+ width: generated.width,
747
+ height: generated.height,
748
+ messageId: message.id,
749
+ attachmentId: attachment.id,
750
+ filename: attachment.filename
751
+ };
752
+ }
753
+ async#getManifest(receipt, signal) {
754
+ validateReceipt(receipt, this.#connectionId);
755
+ const messageId = receipt.locator.manifestMessageId;
756
+ const cached = this.#manifestCache.get(messageId);
757
+ if (cached && cached.expiresAt > Date.now()) {
758
+ validateManifest(cached.manifest, receipt);
759
+ this.#logger?.debug?.("manifest.cache.hit", { messageId });
760
+ return cached.manifest;
761
+ }
762
+ this.#logger?.debug?.("manifest.cache.miss", { messageId });
763
+ let url = await this.#transport.getFreshAttachmentUrl(messageId, receipt.locator.manifestAttachmentId, { signal });
764
+ let response = await this.#fetch(url, { signal });
765
+ if (!response.ok) {
766
+ this.#logger?.warn?.("manifest.cdn_url.refresh", { messageId, status: response.status });
767
+ this.#transport.invalidateMessage(messageId);
768
+ url = await this.#transport.getFreshAttachmentUrl(messageId, receipt.locator.manifestAttachmentId, { signal });
769
+ response = await this.#fetch(url, { signal });
770
+ }
771
+ if (!response.ok) {
772
+ throw new StorageError2("READ_FAILED", "Could not load the Discord storage manifest", {
773
+ status: 502
774
+ });
775
+ }
776
+ const envelope = discordManifestEnvelopeSchema.parse(await response.json());
777
+ if (!await verifyManifestSignature(envelope.manifest, envelope.signature, this.#secret)) {
778
+ throw new StorageError2("INTEGRITY_FAILED", "Discord manifest signature is invalid", {
779
+ status: 409
780
+ });
781
+ }
782
+ validateManifest(envelope.manifest, receipt);
783
+ this.#manifestCache.set(messageId, {
784
+ manifest: envelope.manifest,
785
+ expiresAt: Date.now() + MANIFEST_CACHE_MS
786
+ });
787
+ this.#logger?.debug?.("manifest.signature.verified", {
788
+ messageId,
789
+ parts: envelope.manifest.chunks.length
790
+ });
791
+ return envelope.manifest;
792
+ }
793
+ #streamRange(manifest, start, end, parentSignal) {
794
+ const abortController = new AbortController;
795
+ parentSignal?.addEventListener("abort", () => abortController.abort(parentSignal.reason), {
796
+ once: true
797
+ });
798
+ const iterator = this.#chunkIterator(manifest, start, end, abortController.signal);
799
+ return new ReadableStream({
800
+ async pull(controller) {
801
+ try {
802
+ const next = await iterator.next();
803
+ if (next.done)
804
+ controller.close();
805
+ else
806
+ controller.enqueue(next.value);
807
+ } catch (error) {
808
+ controller.error(error);
809
+ }
810
+ },
811
+ async cancel(reason) {
812
+ abortController.abort(reason);
813
+ await iterator.return?.(undefined);
814
+ }
815
+ });
816
+ }
817
+ async* #chunkIterator(manifest, start, end, signal) {
818
+ let chunkOffset = 0;
819
+ for (const chunk of manifest.chunks) {
820
+ const chunkEnd = chunkOffset + chunk.size - 1;
821
+ if (chunkEnd < start) {
822
+ chunkOffset += chunk.size;
823
+ continue;
824
+ }
825
+ if (chunkOffset > end)
826
+ break;
827
+ const localStart = Math.max(0, start - chunkOffset);
828
+ const localEnd = Math.min(chunk.size - 1, end - chunkOffset);
829
+ yield* this.#readChunk(chunk, localStart, localEnd, signal);
830
+ chunkOffset += chunk.size;
831
+ }
832
+ }
833
+ async* #readChunk(chunk, start, end, signal) {
834
+ const length = end - start + 1;
835
+ let response;
836
+ for (let attempt = 0;attempt < 2; attempt += 1) {
837
+ const url = await this.#transport.getFreshAttachmentUrl(chunk.messageId, chunk.attachmentId, {
838
+ signal
839
+ });
840
+ response = await this.#fetch(url, {
841
+ headers: start === 0 && end === chunk.size - 1 ? undefined : { Range: `bytes=${start}-${end}` },
842
+ signal
843
+ });
844
+ if (response.ok)
845
+ break;
846
+ this.#transport.invalidateMessage(chunk.messageId);
847
+ }
848
+ if (!response?.ok || !response.body) {
849
+ throw new StorageError2("READ_FAILED", `Discord CDN failed while reading part ${chunk.index}`);
850
+ }
851
+ const skip = response.status === 206 ? 0 : start;
852
+ if (response.status !== 206 && (start > 0 || end < chunk.size - 1)) {
853
+ this.#logger?.warn?.("cdn.range.fallback", {
854
+ messageId: chunk.messageId,
855
+ part: chunk.index,
856
+ upstreamStatus: response.status,
857
+ start,
858
+ end
859
+ });
860
+ }
861
+ const reader = response.body.getReader();
862
+ let skipped = 0;
863
+ let emitted = 0;
864
+ try {
865
+ while (emitted < length) {
866
+ const result = await reader.read();
867
+ if (result.done)
868
+ break;
869
+ let from = 0;
870
+ if (skipped < skip) {
871
+ const amount2 = Math.min(result.value.byteLength, skip - skipped);
872
+ skipped += amount2;
873
+ from += amount2;
874
+ }
875
+ if (from >= result.value.byteLength)
876
+ continue;
877
+ const amount = Math.min(result.value.byteLength - from, length - emitted);
878
+ emitted += amount;
879
+ yield result.value.subarray(from, from + amount);
880
+ }
881
+ } finally {
882
+ await reader.cancel().catch(() => {
883
+ return;
884
+ });
885
+ }
886
+ if (emitted !== length) {
887
+ throw new StorageError2("READ_FAILED", `Discord CDN returned a short part (${emitted}/${length})`);
888
+ }
889
+ }
890
+ }
891
+ function createDiscordProvider(options) {
892
+ return new DiscordStorageProvider(options);
893
+ }
894
+ function toReceipt(manifest, manifestMessageId, channelId, manifestAttachmentId) {
895
+ return {
896
+ version: 1,
897
+ provider: "discord",
898
+ connectionId: manifest.connectionId,
899
+ createdAt: manifest.createdAt,
900
+ file: {
901
+ name: manifest.name,
902
+ contentType: manifest.contentType,
903
+ size: manifest.size,
904
+ sha256: manifest.sha256
905
+ },
906
+ locator: { channelId, manifestMessageId, manifestAttachmentId },
907
+ preview: manifest.preview ? previewSummary(manifest.preview) : undefined
908
+ };
909
+ }
910
+ function previewSummary(preview) {
911
+ return {
912
+ contentType: preview.contentType,
913
+ size: preview.size,
914
+ width: preview.width,
915
+ height: preview.height
916
+ };
917
+ }
918
+ function validateReceipt(receipt, connectionId) {
919
+ const locator = discordReceiptLocatorSchema.safeParse(receipt.locator);
920
+ if (receipt.version !== 1 || receipt.provider !== "discord" || receipt.connectionId !== connectionId || !locator.success) {
921
+ throw new StorageError2("INVALID_RECEIPT", "Receipt does not belong to this Discord provider", {
922
+ status: 400,
923
+ cause: locator.success ? undefined : locator.error
924
+ });
925
+ }
926
+ }
927
+ function validateManifest(manifest, receipt) {
928
+ let size = 0;
929
+ for (const [index, chunk] of manifest.chunks.entries()) {
930
+ if (chunk.index !== index) {
931
+ throw new StorageError2("INTEGRITY_FAILED", "Discord manifest parts are out of order");
932
+ }
933
+ size += chunk.size;
934
+ }
935
+ if (size !== manifest.size || manifest.connectionId !== receipt.connectionId || manifest.name !== receipt.file.name || manifest.contentType !== receipt.file.contentType || manifest.size !== receipt.file.size || manifest.sha256 !== receipt.file.sha256) {
936
+ throw new StorageError2("INTEGRITY_FAILED", "Discord manifest does not match its receipt");
937
+ }
938
+ }
939
+ function validatePreview(preview, attachmentLimit) {
940
+ if (!preview)
941
+ return;
942
+ if (!preview.file.type.startsWith("image/")) {
943
+ throw new StorageError2("INVALID_INPUT", "Preview must be an image", { status: 400 });
944
+ }
945
+ if (preview.file.size <= 0 || preview.file.size > attachmentLimit) {
946
+ throw new StorageError2("FILE_TOO_LARGE", `Preview must be between 1 and ${attachmentLimit} bytes`, { status: 413 });
947
+ }
948
+ if (!Number.isInteger(preview.width) || preview.width <= 0) {
949
+ throw new StorageError2("INVALID_INPUT", "Preview width must be a positive integer");
950
+ }
951
+ if (!Number.isInteger(preview.height) || preview.height <= 0) {
952
+ throw new StorageError2("INVALID_INPUT", "Preview height must be a positive integer");
953
+ }
954
+ }
955
+ function normalizedDimensions(preview, width, height) {
956
+ if (width !== undefined && (!Number.isInteger(width) || width <= 0 || width > 1e4)) {
957
+ throw new StorageError2("INVALID_INPUT", "Preview width must be between 1 and 10000");
958
+ }
959
+ if (height !== undefined && (!Number.isInteger(height) || height <= 0 || height > 1e4)) {
960
+ throw new StorageError2("INVALID_INPUT", "Preview height must be between 1 and 10000");
961
+ }
962
+ if (width && height)
963
+ return { width, height };
964
+ if (width)
965
+ return { width, height: Math.max(1, Math.round(width * preview.height / preview.width)) };
966
+ if (height)
967
+ return { width: Math.max(1, Math.round(height * preview.width / preview.height)), height };
968
+ return;
969
+ }
970
+ function exactArrayBuffer(bytes) {
971
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
972
+ return bytes.buffer;
973
+ }
974
+ return bytes.slice().buffer;
975
+ }
976
+ function boundedInteger(value, minimum, maximum, name) {
977
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
978
+ throw new StorageError2("INVALID_INPUT", `${name} must be an integer between ${minimum} and ${maximum}`);
979
+ }
980
+ return value;
981
+ }
982
+ function isStreamableMedia(contentType) {
983
+ return contentType.startsWith("video/") || contentType.startsWith("audio/");
984
+ }
985
+ function contentDisposition(filename, download) {
986
+ const disposition = download ? "attachment" : "inline";
987
+ const fallback = filename.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_");
988
+ return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
989
+ }
990
+ function safeMessageName(filename) {
991
+ return filename.replace(/[@`]/g, "_").slice(0, 160);
992
+ }
993
+ function parseMultipartState(value, connectionId) {
994
+ if (!value || typeof value !== "object") {
995
+ throw new StorageError2("INVALID_INPUT", "Discord multipart session state is invalid");
996
+ }
997
+ const state = value;
998
+ if (state.version !== 1 || typeof state.uploadId !== "string" || typeof state.connectionId !== "string" || state.connectionId !== connectionId || typeof state.name !== "string" || typeof state.contentType !== "string" || !Number.isSafeInteger(state.size) || (state.size ?? 0) <= 0 || typeof state.createdAt !== "string") {
999
+ throw new StorageError2("INVALID_INPUT", "Discord multipart session state is invalid");
1000
+ }
1001
+ return state;
1002
+ }
1003
+ function parseMultipartPartLocator(value) {
1004
+ if (!value || typeof value !== "object") {
1005
+ throw new StorageError2("INVALID_INPUT", "Discord multipart part locator is invalid");
1006
+ }
1007
+ const locator = value;
1008
+ if (typeof locator.messageId !== "string" || typeof locator.attachmentId !== "string" || typeof locator.filename !== "string") {
1009
+ throw new StorageError2("INVALID_INPUT", "Discord multipart part locator is invalid");
1010
+ }
1011
+ if (locator.width !== undefined && (!Number.isInteger(locator.width) || locator.width <= 0)) {
1012
+ throw new StorageError2("INVALID_INPUT", "Discord multipart preview width is invalid");
1013
+ }
1014
+ if (locator.height !== undefined && (!Number.isInteger(locator.height) || locator.height <= 0)) {
1015
+ throw new StorageError2("INVALID_INPUT", "Discord multipart preview height is invalid");
1016
+ }
1017
+ return locator;
1018
+ }
1019
+ function previewFromMultipartPart(part, fallbackContentType) {
1020
+ if (!part)
1021
+ return;
1022
+ const locator = parseMultipartPartLocator(part.locator);
1023
+ if (!locator.width || !locator.height)
1024
+ return;
1025
+ return {
1026
+ size: part.size,
1027
+ contentType: locator.contentType ?? fallbackContentType,
1028
+ width: locator.width,
1029
+ height: locator.height,
1030
+ messageId: locator.messageId,
1031
+ attachmentId: locator.attachmentId,
1032
+ filename: locator.filename
1033
+ };
1034
+ }
1035
+ export {
1036
+ discordReceiptLocatorSchema,
1037
+ discordPreviewLocatorSchema,
1038
+ discordMessageSchema,
1039
+ discordManifestSchema,
1040
+ discordManifestEnvelopeSchema,
1041
+ discordChunkSchema,
1042
+ discordAttachmentSchema,
1043
+ createDiscordProvider,
1044
+ DiscordWebhookTransport,
1045
+ DiscordStorageProvider
1046
+ };
@@ -0,0 +1,60 @@
1
+ import { type ProviderMultipartPart, type ProviderMultipartSession, type ProviderCapabilities, type ProviderUploadOptions, type RefreshedProviderLinks, type StorageOpenOptions, type StoragePreviewOptions, type StorageProvider, type StorageUploadSource } from "@minhvuong/pirate-storage";
2
+ import { type DiscordTransport } from "./transport";
3
+ import { type DiscordLogger, type DiscordMultipartPartLocator, type DiscordMultipartState, type DiscordReceipt } from "./types";
4
+ export interface DiscordProviderOptions {
5
+ webhookUrl?: string;
6
+ transport?: DiscordTransport;
7
+ manifestSecret: string | Uint8Array;
8
+ connectionId?: string;
9
+ partSize?: number;
10
+ maxFileSize?: number;
11
+ attachmentsPerMessage?: number;
12
+ uploadConcurrency?: number;
13
+ mediaWindowBytes?: number;
14
+ fetch?: typeof globalThis.fetch;
15
+ logger?: DiscordLogger;
16
+ }
17
+ export declare class DiscordStorageProvider implements StorageProvider<DiscordReceipt, DiscordMultipartState, DiscordMultipartPartLocator> {
18
+ #private;
19
+ readonly id: "discord";
20
+ readonly capabilities: ProviderCapabilities;
21
+ constructor(options: DiscordProviderOptions);
22
+ upload(source: StorageUploadSource, options: ProviderUploadOptions): Promise<DiscordReceipt>;
23
+ stat(receipt: DiscordReceipt): Promise<{
24
+ name: string;
25
+ contentType: string;
26
+ size: number;
27
+ sha256: string;
28
+ preview: {
29
+ contentType: string;
30
+ size: number;
31
+ width: number;
32
+ height: number;
33
+ } | undefined;
34
+ }>;
35
+ beginMultipart(file: {
36
+ name: string;
37
+ contentType: string;
38
+ size: number;
39
+ }, _options: ProviderUploadOptions): Promise<ProviderMultipartSession<DiscordMultipartState>>;
40
+ uploadPart(session: ProviderMultipartSession<DiscordMultipartState>, part: {
41
+ index: number;
42
+ bytes: Uint8Array;
43
+ signal?: AbortSignal;
44
+ }): Promise<ProviderMultipartPart<DiscordMultipartPartLocator>>;
45
+ completeMultipart(session: ProviderMultipartSession<DiscordMultipartState>, parts: ProviderMultipartPart<DiscordMultipartPartLocator>[], file: {
46
+ name: string;
47
+ contentType: string;
48
+ size: number;
49
+ sha256: string;
50
+ }, options: ProviderUploadOptions): Promise<DiscordReceipt>;
51
+ abortMultipart(session: ProviderMultipartSession<DiscordMultipartState>, parts: ProviderMultipartPart<DiscordMultipartPartLocator>[]): Promise<void>;
52
+ open(receipt: DiscordReceipt, options?: StorageOpenOptions): Promise<Response>;
53
+ preview(receipt: DiscordReceipt, options?: StoragePreviewOptions): Promise<Response>;
54
+ previewUrl(receipt: DiscordReceipt, options?: StoragePreviewOptions): Promise<string>;
55
+ refresh(receipt: DiscordReceipt): Promise<RefreshedProviderLinks>;
56
+ delete(receipt: DiscordReceipt, options?: {
57
+ signal?: AbortSignal;
58
+ }): Promise<void>;
59
+ }
60
+ export declare function createDiscordProvider(options: DiscordProviderOptions): DiscordStorageProvider;
@@ -0,0 +1,37 @@
1
+ import { type DiscordLogger, type DiscordMessage } from "./types";
2
+ export interface DiscordTransport {
3
+ readonly connectionId: string;
4
+ uploadAttachments(files: readonly File[], description: string, options?: {
5
+ signal?: AbortSignal;
6
+ }): Promise<DiscordMessage>;
7
+ getFreshAttachmentUrl(messageId: string, attachmentId?: string, options?: {
8
+ proxy?: boolean;
9
+ signal?: AbortSignal;
10
+ }): Promise<string>;
11
+ getMessage(messageId: string, options?: {
12
+ signal?: AbortSignal;
13
+ }): Promise<DiscordMessage>;
14
+ invalidateMessage(messageId: string): void;
15
+ deleteMessage(messageId: string, options?: {
16
+ signal?: AbortSignal;
17
+ }): Promise<void>;
18
+ }
19
+ export declare class DiscordWebhookTransport implements DiscordTransport {
20
+ #private;
21
+ readonly connectionId: string;
22
+ constructor(webhookUrl: string, fetchImplementation?: typeof globalThis.fetch, logger?: DiscordLogger);
23
+ uploadAttachments(files: readonly File[], description: string, options?: {
24
+ signal?: AbortSignal;
25
+ }): Promise<DiscordMessage>;
26
+ getFreshAttachmentUrl(messageId: string, attachmentId?: string, options?: {
27
+ proxy?: boolean;
28
+ signal?: AbortSignal;
29
+ }): Promise<string>;
30
+ invalidateMessage(messageId: string): void;
31
+ deleteMessage(messageId: string, options?: {
32
+ signal?: AbortSignal;
33
+ }): Promise<void>;
34
+ getMessage(messageId: string, options?: {
35
+ signal?: AbortSignal;
36
+ }): Promise<DiscordMessage>;
37
+ }
@@ -0,0 +1,139 @@
1
+ import type { StorageReceipt } from "@minhvuong/pirate-storage";
2
+ import { z } from "zod";
3
+ export declare const discordAttachmentSchema: z.ZodObject<{
4
+ id: z.ZodString;
5
+ filename: z.ZodString;
6
+ size: z.ZodNumber;
7
+ url: z.ZodString;
8
+ proxy_url: z.ZodOptional<z.ZodString>;
9
+ content_type: z.ZodOptional<z.ZodString>;
10
+ width: z.ZodOptional<z.ZodNumber>;
11
+ height: z.ZodOptional<z.ZodNumber>;
12
+ }, z.core.$strip>;
13
+ export declare const discordMessageSchema: z.ZodObject<{
14
+ id: z.ZodString;
15
+ channel_id: z.ZodString;
16
+ attachments: z.ZodArray<z.ZodObject<{
17
+ id: z.ZodString;
18
+ filename: z.ZodString;
19
+ size: z.ZodNumber;
20
+ url: z.ZodString;
21
+ proxy_url: z.ZodOptional<z.ZodString>;
22
+ content_type: z.ZodOptional<z.ZodString>;
23
+ width: z.ZodOptional<z.ZodNumber>;
24
+ height: z.ZodOptional<z.ZodNumber>;
25
+ }, z.core.$strip>>;
26
+ }, z.core.$strip>;
27
+ export type DiscordAttachment = z.infer<typeof discordAttachmentSchema>;
28
+ export type DiscordMessage = z.infer<typeof discordMessageSchema>;
29
+ export declare const discordChunkSchema: z.ZodObject<{
30
+ index: z.ZodNumber;
31
+ size: z.ZodNumber;
32
+ sha256: z.ZodString;
33
+ messageId: z.ZodString;
34
+ attachmentId: z.ZodString;
35
+ filename: z.ZodString;
36
+ }, z.core.$strip>;
37
+ export declare const discordPreviewLocatorSchema: z.ZodObject<{
38
+ size: z.ZodNumber;
39
+ contentType: z.ZodString;
40
+ width: z.ZodNumber;
41
+ height: z.ZodNumber;
42
+ messageId: z.ZodString;
43
+ attachmentId: z.ZodString;
44
+ filename: z.ZodString;
45
+ }, z.core.$strip>;
46
+ export declare const discordManifestSchema: z.ZodObject<{
47
+ version: z.ZodLiteral<1>;
48
+ uploadId: z.ZodString;
49
+ connectionId: z.ZodString;
50
+ name: z.ZodString;
51
+ contentType: z.ZodString;
52
+ size: z.ZodNumber;
53
+ sha256: z.ZodString;
54
+ partSize: z.ZodNumber;
55
+ createdAt: z.ZodString;
56
+ chunks: z.ZodArray<z.ZodObject<{
57
+ index: z.ZodNumber;
58
+ size: z.ZodNumber;
59
+ sha256: z.ZodString;
60
+ messageId: z.ZodString;
61
+ attachmentId: z.ZodString;
62
+ filename: z.ZodString;
63
+ }, z.core.$strip>>;
64
+ preview: z.ZodOptional<z.ZodObject<{
65
+ size: z.ZodNumber;
66
+ contentType: z.ZodString;
67
+ width: z.ZodNumber;
68
+ height: z.ZodNumber;
69
+ messageId: z.ZodString;
70
+ attachmentId: z.ZodString;
71
+ filename: z.ZodString;
72
+ }, z.core.$strip>>;
73
+ }, z.core.$strip>;
74
+ export declare const discordManifestEnvelopeSchema: z.ZodObject<{
75
+ version: z.ZodLiteral<1>;
76
+ algorithm: z.ZodLiteral<"HMAC-SHA256">;
77
+ manifest: z.ZodObject<{
78
+ version: z.ZodLiteral<1>;
79
+ uploadId: z.ZodString;
80
+ connectionId: z.ZodString;
81
+ name: z.ZodString;
82
+ contentType: z.ZodString;
83
+ size: z.ZodNumber;
84
+ sha256: z.ZodString;
85
+ partSize: z.ZodNumber;
86
+ createdAt: z.ZodString;
87
+ chunks: z.ZodArray<z.ZodObject<{
88
+ index: z.ZodNumber;
89
+ size: z.ZodNumber;
90
+ sha256: z.ZodString;
91
+ messageId: z.ZodString;
92
+ attachmentId: z.ZodString;
93
+ filename: z.ZodString;
94
+ }, z.core.$strip>>;
95
+ preview: z.ZodOptional<z.ZodObject<{
96
+ size: z.ZodNumber;
97
+ contentType: z.ZodString;
98
+ width: z.ZodNumber;
99
+ height: z.ZodNumber;
100
+ messageId: z.ZodString;
101
+ attachmentId: z.ZodString;
102
+ filename: z.ZodString;
103
+ }, z.core.$strip>>;
104
+ }, z.core.$strip>;
105
+ signature: z.ZodString;
106
+ }, z.core.$strip>;
107
+ export declare const discordReceiptLocatorSchema: z.ZodObject<{
108
+ channelId: z.ZodString;
109
+ manifestMessageId: z.ZodString;
110
+ manifestAttachmentId: z.ZodString;
111
+ }, z.core.$strip>;
112
+ export type DiscordChunk = z.infer<typeof discordChunkSchema>;
113
+ export type DiscordPreviewLocator = z.infer<typeof discordPreviewLocatorSchema>;
114
+ export type DiscordManifest = z.infer<typeof discordManifestSchema>;
115
+ export type DiscordManifestEnvelope = z.infer<typeof discordManifestEnvelopeSchema>;
116
+ export type DiscordReceiptLocator = z.infer<typeof discordReceiptLocatorSchema>;
117
+ export type DiscordReceipt = StorageReceipt<"discord", DiscordReceiptLocator>;
118
+ export interface DiscordMultipartState {
119
+ version: 1;
120
+ uploadId: string;
121
+ connectionId: string;
122
+ name: string;
123
+ contentType: string;
124
+ size: number;
125
+ createdAt: string;
126
+ }
127
+ export interface DiscordMultipartPartLocator {
128
+ messageId: string;
129
+ attachmentId: string;
130
+ filename: string;
131
+ contentType?: string;
132
+ width?: number;
133
+ height?: number;
134
+ }
135
+ export interface DiscordLogger {
136
+ debug?(message: string, context?: Readonly<Record<string, unknown>>): void;
137
+ warn?(message: string, context?: Readonly<Record<string, unknown>>): void;
138
+ error?(message: string, context?: Readonly<Record<string, unknown>>): void;
139
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@minhvuong/pirate-storage-discord",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "bun build src/index.ts --outdir dist --target browser --packages external && tsc -p tsconfig.build.json",
21
+ "check-types": "tsc --noEmit",
22
+ "test": "bun test"
23
+ },
24
+ "dependencies": {
25
+ "@minhvuong/pirate-storage": "workspace:*",
26
+ "@noble/hashes": "^2.0.1",
27
+ "zod": "catalog:"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "^1.3.10",
31
+ "typescript": "catalog:"
32
+ }
33
+ }