openmates 0.27.0-alpha.32 → 0.27.0-alpha.34

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.
@@ -0,0 +1,692 @@
1
+ import {
2
+ stripImageMetadata
3
+ } from "./chunk-V2QAI5ET.js";
4
+
5
+ // src/uploadService.ts
6
+ import { readFileSync } from "fs";
7
+ import { basename, extname } from "path";
8
+
9
+ // ../upload-privacy/src/audio.ts
10
+ var ascii = (bytes, offset, length) => String.fromCharCode(...bytes.subarray(offset, offset + length));
11
+ var matches = (bytes, offset, value) => offset >= 0 && offset + value.length <= bytes.length && ascii(bytes, offset, value.length) === value;
12
+ var u32le = (bytes, offset) => (bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16 | bytes[offset + 3] << 24) >>> 0;
13
+ var u32be = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
14
+ var put32le = (bytes, offset, value) => {
15
+ bytes[offset] = value & 255;
16
+ bytes[offset + 1] = value >>> 8 & 255;
17
+ bytes[offset + 2] = value >>> 16 & 255;
18
+ bytes[offset + 3] = value >>> 24 & 255;
19
+ };
20
+ function stripWav(bytes) {
21
+ if (bytes.length < 44 || !matches(bytes, 0, "RIFF") || !matches(bytes, 8, "WAVE") || u32le(bytes, 4) !== bytes.length - 8) return void 0;
22
+ const chunks = [];
23
+ let offset = 12;
24
+ let hasFormat = false;
25
+ let hasData = false;
26
+ let changed = false;
27
+ while (offset < bytes.length) {
28
+ if (offset + 8 > bytes.length) return void 0;
29
+ const length2 = u32le(bytes, offset + 4);
30
+ const end = offset + 8 + length2 + (length2 & 1);
31
+ if (!Number.isSafeInteger(end) || end > bytes.length) return void 0;
32
+ const id = ascii(bytes, offset, 4);
33
+ if (id === "fmt " || id === "data" || id === "fact") {
34
+ if (id === "fmt ") hasFormat = true;
35
+ if (id === "data") hasData = true;
36
+ chunks.push(bytes.subarray(offset, end));
37
+ } else if (id === "LIST" && matches(bytes, offset + 8, "wavl")) {
38
+ return void 0;
39
+ } else changed = true;
40
+ offset = end;
41
+ }
42
+ if (!hasFormat || !hasData) return void 0;
43
+ if (!changed) return bytes;
44
+ const length = 12 + chunks.reduce((sum, chunk) => sum + chunk.length, 0);
45
+ if (length - 8 > 4294967295) return void 0;
46
+ const output = new Uint8Array(length);
47
+ output.set(bytes.subarray(0, 12));
48
+ put32le(output, 4, length - 8);
49
+ let cursor = 12;
50
+ for (const chunk of chunks) {
51
+ output.set(chunk, cursor);
52
+ cursor += chunk.length;
53
+ }
54
+ return output;
55
+ }
56
+ function synchsafe(bytes, offset) {
57
+ if (offset + 4 > bytes.length || bytes.subarray(offset, offset + 4).some((value) => value & 128)) return void 0;
58
+ return bytes[offset] << 21 | bytes[offset + 1] << 14 | bytes[offset + 2] << 7 | bytes[offset + 3];
59
+ }
60
+ function stripMp3OrAdts(bytes, adts) {
61
+ let start = 0;
62
+ let end = bytes.length;
63
+ while (matches(bytes, start, "ID3")) {
64
+ if (start + 10 > end || bytes[start + 3] < 2 || bytes[start + 3] > 4) return void 0;
65
+ const size = synchsafe(bytes, start + 6);
66
+ if (size === void 0) return void 0;
67
+ const footer = (bytes[start + 5] & 16) !== 0 ? 10 : 0;
68
+ const next = start + 10 + size + footer;
69
+ if (next > end || footer && !matches(bytes, next - 10, "3DI")) return void 0;
70
+ start = next;
71
+ }
72
+ let removed;
73
+ do {
74
+ removed = false;
75
+ if (end - start >= 128 && matches(bytes, end - 128, "TAG")) {
76
+ end -= 128;
77
+ removed = true;
78
+ }
79
+ if (end - start >= 32 && matches(bytes, end - 32, "APETAGEX")) {
80
+ const footer = end - 32;
81
+ const size = u32le(bytes, footer + 12);
82
+ if (size < 32 || size > end - start) return void 0;
83
+ let tagStart = end - size;
84
+ if (tagStart - 32 >= start && matches(bytes, tagStart - 32, "APETAGEX")) tagStart -= 32;
85
+ end = tagStart;
86
+ removed = true;
87
+ }
88
+ } while (removed);
89
+ if (end - start < (adts ? 7 : 4) || bytes[start] !== 255 || (bytes[start + 1] & 240) !== 240) return void 0;
90
+ if (adts) {
91
+ if ((bytes[start + 1] & 6) !== 0 || (bytes[start + 2] & 60) === 60) return void 0;
92
+ } else if ((bytes[start + 1] & 8) !== 8 || (bytes[start + 1] & 6) === 0) return void 0;
93
+ return start === 0 && end === bytes.length ? bytes : bytes.slice(start, end);
94
+ }
95
+ var MP4_METADATA = /* @__PURE__ */ new Set(["udta", "meta", "uuid", "ilst", "keys", "ID32", "XMP_", "\xA9nam", "\xA9ART", "\xA9alb", "\xA9day", "\xA9too", "\xA9cmt", "\xA9wrt"]);
96
+ var MP4_CONTAINERS = /* @__PURE__ */ new Set(["moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "mvex", "moof", "traf", "mfra", "tref", "ipro", "iprp", "ipco", "wave"]);
97
+ function stripMp4(bytes) {
98
+ if (bytes.length < 16 || !matches(bytes, 4, "ftyp")) return void 0;
99
+ const output = bytes.slice();
100
+ let changed = false;
101
+ let sawMedia = false;
102
+ let count = 0;
103
+ function walk(start, end, depth) {
104
+ if (depth > 16) return false;
105
+ let position = start;
106
+ while (position < end) {
107
+ if (++count > 1e5 || position + 8 > end) return false;
108
+ const size32 = u32be(bytes, position);
109
+ const type = ascii(bytes, position + 4, 4);
110
+ let header = 8;
111
+ let size = size32;
112
+ if (size32 === 1) {
113
+ if (position + 16 > end) return false;
114
+ const high = u32be(bytes, position + 8);
115
+ size = high * 4294967296 + u32be(bytes, position + 12);
116
+ header = 16;
117
+ } else if (size32 === 0) size = end - position;
118
+ if (!Number.isSafeInteger(size) || size < header || position + size > end) return false;
119
+ if (type === "mdat") sawMedia = true;
120
+ if (MP4_METADATA.has(type)) {
121
+ output.set([102, 114, 101, 101], position + 4);
122
+ output.fill(0, position + header, position + size);
123
+ changed = true;
124
+ } else if (type === "free" || type === "skip") {
125
+ if (bytes.subarray(position + header, position + size).some((value) => value !== 0)) changed = true;
126
+ output.fill(0, position + header, position + size);
127
+ } else if (MP4_CONTAINERS.has(type) && !walk(position + header, position + size, depth + 1)) return false;
128
+ position += size;
129
+ }
130
+ return position === end;
131
+ }
132
+ return walk(0, bytes.length, 0) && sawMedia ? changed ? output : bytes : void 0;
133
+ }
134
+ var EBML_MASTERS = /* @__PURE__ */ new Set([440786851, 408125543, 290298740, 357149030, 374648427, 174, 224, 225, 524531317, 160, 475249515, 187, 183, 423732329, 24999, 272869232, 17849, 182, 128]);
135
+ var EBML_METADATA = /* @__PURE__ */ new Set([307544935, 26568, 22337, 19840, 17505, 31657, 29604, 29572, 3979555, 4110627, 3965867, 4096955, 21358]);
136
+ function ebmlVint(bytes, offset, end, isSize) {
137
+ if (offset >= end) return void 0;
138
+ const first = bytes[offset];
139
+ let mask = 128;
140
+ let length = 1;
141
+ while (length <= (isSize ? 8 : 4) && !(first & mask)) {
142
+ mask >>= 1;
143
+ length++;
144
+ }
145
+ if (length > (isSize ? 8 : 4) || offset + length > end) return void 0;
146
+ let value = isSize ? first & mask - 1 : first;
147
+ let unknown = isSize && value === mask - 1;
148
+ for (let i = 1; i < length; i++) {
149
+ value = value * 256 + bytes[offset + i];
150
+ unknown = unknown && bytes[offset + i] === 255;
151
+ }
152
+ return { value, length, unknown };
153
+ }
154
+ function writeVoid(output, start, length) {
155
+ for (let sizeLength = 1; sizeLength <= 8; sizeLength++) {
156
+ const payload = length - 1 - sizeLength;
157
+ if (payload < 0 || payload >= 2 ** (7 * sizeLength) - 1) continue;
158
+ output[start] = 236;
159
+ for (let i = 0; i < sizeLength; i++) {
160
+ const shift = 8 * (sizeLength - 1 - i);
161
+ output[start + 1 + i] = Math.floor(payload / 2 ** shift) & 255;
162
+ }
163
+ output[start + 1] |= 1 << 8 - sizeLength;
164
+ output.fill(0, start + 1 + sizeLength, start + length);
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+ function stripWebm(bytes) {
170
+ if (!matches(bytes, 0, "E\xDF\xA3")) return void 0;
171
+ const output = bytes.slice();
172
+ let changed = false;
173
+ let sawSegment = false;
174
+ let count = 0;
175
+ const crcElements = [];
176
+ function walk(start, end, depth) {
177
+ if (depth > 16) return false;
178
+ let position = start;
179
+ while (position < end) {
180
+ if (++count > 1e5) return false;
181
+ const id = ebmlVint(bytes, position, end, false);
182
+ if (!id) return false;
183
+ const size = ebmlVint(bytes, position + id.length, end, true);
184
+ if (!size) return false;
185
+ const payload = position + id.length + size.length;
186
+ if (size.unknown && id.value !== 408125543) return false;
187
+ const next = size.unknown ? end : payload + size.value;
188
+ if (!Number.isSafeInteger(next) || next > end || next < payload) return false;
189
+ if (id.value === 408125543) sawSegment = true;
190
+ if (EBML_METADATA.has(id.value)) {
191
+ if (!writeVoid(output, position, next - position)) return false;
192
+ changed = true;
193
+ } else if (id.value === 191) {
194
+ if (size.value !== 4) return false;
195
+ crcElements.push({ start: position, length: next - position });
196
+ } else if (id.value === 236) {
197
+ if (bytes.subarray(payload, next).some((value) => value !== 0)) changed = true;
198
+ output.fill(0, payload, next);
199
+ } else if (EBML_MASTERS.has(id.value) && !walk(payload, next, depth + 1)) return false;
200
+ position = next;
201
+ }
202
+ return position === end;
203
+ }
204
+ if (!walk(0, bytes.length, 0) || !sawSegment) return void 0;
205
+ if (!changed) return bytes;
206
+ for (const element of crcElements) if (!writeVoid(output, element.start, element.length)) return void 0;
207
+ return output;
208
+ }
209
+ var OGG_CRC_TABLE = (() => {
210
+ const table = new Uint32Array(256);
211
+ for (let i = 0; i < 256; i++) {
212
+ let crc = i << 24;
213
+ for (let j = 0; j < 8; j++) crc = crc & 2147483648 ? (crc << 1 ^ 79764919) >>> 0 : crc << 1 >>> 0;
214
+ table[i] = crc;
215
+ }
216
+ return table;
217
+ })();
218
+ function pageCrc(bytes, start, end) {
219
+ let crc = 0;
220
+ for (let i = start; i < end; i++) crc = (crc << 8 ^ OGG_CRC_TABLE[(crc >>> 24 ^ (i >= start + 22 && i < start + 26 ? 0 : bytes[i])) & 255]) >>> 0;
221
+ return crc;
222
+ }
223
+ function stripOgg(bytes) {
224
+ if (!matches(bytes, 0, "OggS")) return void 0;
225
+ const pages = [];
226
+ const streams = /* @__PURE__ */ new Map();
227
+ let offset = 0;
228
+ while (offset < bytes.length) {
229
+ if (offset + 27 > bytes.length || !matches(bytes, offset, "OggS") || bytes[offset + 4] !== 0) return void 0;
230
+ const segmentCount = bytes[offset + 26];
231
+ if (offset + 27 + segmentCount > bytes.length) return void 0;
232
+ let dataLength = 0;
233
+ for (let i = 0; i < segmentCount; i++) dataLength += bytes[offset + 27 + i];
234
+ const dataStart = offset + 27 + segmentCount;
235
+ const end = dataStart + dataLength;
236
+ if (end > bytes.length) return void 0;
237
+ const serial = u32le(bytes, offset + 14);
238
+ const stream = streams.get(serial) ?? { packetIndex: 0, pendingOpen: false, pending: [], packets: [] };
239
+ if (Boolean(bytes[offset + 5] & 1) !== stream.pendingOpen) return void 0;
240
+ let cursor = dataStart;
241
+ for (let i = 0; i < segmentCount; i++) {
242
+ const length = bytes[offset + 27 + i];
243
+ if (stream.packetIndex < 2) for (let j = 0; j < length; j++) stream.pending.push(cursor + j);
244
+ cursor += length;
245
+ stream.pendingOpen = length === 255;
246
+ if (length < 255) {
247
+ if (stream.packetIndex < 2) stream.packets.push(stream.pending);
248
+ stream.pending = [];
249
+ stream.packetIndex++;
250
+ }
251
+ }
252
+ streams.set(serial, stream);
253
+ pages.push({ start: offset, end });
254
+ offset = end;
255
+ }
256
+ const output = bytes.slice();
257
+ let changed = false;
258
+ const patch = (positions, index, value) => {
259
+ output[positions[index]] = value;
260
+ };
261
+ const read = (positions, index) => bytes[positions[index]];
262
+ const read32 = (positions, index) => (read(positions, index) | read(positions, index + 1) << 8 | read(positions, index + 2) << 16 | read(positions, index + 3) << 24) >>> 0;
263
+ for (const stream of streams.values()) {
264
+ if (stream.pendingOpen || stream.packets.length < 2) return void 0;
265
+ const [first, second] = stream.packets;
266
+ const signature = (positions, value) => positions.length >= value.length && [...value].every((char, index) => read(positions, index) === char.charCodeAt(0));
267
+ const opus = signature(first, "OpusHead");
268
+ const vorbis = first.length >= 7 && read(first, 0) === 1 && signature(first.slice(1), "vorbis");
269
+ if (!opus && !vorbis) return void 0;
270
+ const prefix = opus ? 8 : 7;
271
+ if (second.length < prefix + 8 || (opus ? !signature(second, "OpusTags") : read(second, 0) !== 3 || !signature(second.slice(1), "vorbis"))) return void 0;
272
+ let cursor = prefix;
273
+ const vendorLength = read32(second, cursor);
274
+ cursor += 4;
275
+ if (vendorLength > second.length - cursor - 4) return void 0;
276
+ const vendorStart = cursor;
277
+ cursor += vendorLength;
278
+ const commentCount = read32(second, cursor);
279
+ cursor += 4;
280
+ if (commentCount > (second.length - cursor) / 4) return void 0;
281
+ const comments = [];
282
+ for (let i = 0; i < commentCount; i++) {
283
+ if (cursor + 4 > second.length) return void 0;
284
+ const length = read32(second, cursor);
285
+ cursor += 4;
286
+ if (length > second.length - cursor || vorbis && length < 2) return void 0;
287
+ comments.push({ start: cursor, length });
288
+ cursor += length;
289
+ }
290
+ if (vorbis && (cursor >= second.length || (read(second, cursor) & 1) === 0)) return void 0;
291
+ if (opus) {
292
+ for (let i = prefix; i < second.length; i++) patch(second, i, 0);
293
+ } else {
294
+ for (let i = vendorStart; i < vendorStart + vendorLength; i++) patch(second, i, 32);
295
+ for (const comment of comments) {
296
+ for (let i = comment.start; i < comment.start + comment.length; i++) patch(second, i, 32);
297
+ if (comment.length >= 2) {
298
+ patch(second, comment.start, 88);
299
+ patch(second, comment.start + 1, 61);
300
+ }
301
+ }
302
+ for (let i = cursor + 1; i < second.length; i++) patch(second, i, 0);
303
+ }
304
+ changed = true;
305
+ }
306
+ if (!changed) return void 0;
307
+ for (const page of pages) {
308
+ put32le(output, page.start + 22, pageCrc(output, page.start, page.end));
309
+ }
310
+ return output;
311
+ }
312
+ function stripAudioMetadata(bytes, mimeType) {
313
+ try {
314
+ const mime = mimeType.toLowerCase().split(";", 1)[0].trim();
315
+ if (mime === "audio/wav" || mime === "audio/wave" || mime === "audio/x-wav") return stripWav(bytes);
316
+ if (mime === "audio/mpeg" || mime === "audio/mp3") return stripMp3OrAdts(bytes, false);
317
+ if (mime === "audio/aac" || mime === "audio/aacp") return stripMp3OrAdts(bytes, true);
318
+ if (mime === "audio/mp4" || mime === "audio/x-m4a" || mime === "audio/m4a") return stripMp4(bytes);
319
+ if (mime === "audio/webm" || mime === "audio/x-matroska") return stripWebm(bytes);
320
+ if (mime === "audio/ogg" || mime === "application/ogg" || mime === "audio/opus") return stripOgg(bytes);
321
+ } catch {
322
+ }
323
+ return void 0;
324
+ }
325
+
326
+ // ../upload-privacy/src/index.ts
327
+ var EXTENSIONS = {
328
+ "image/jpeg": "jpg",
329
+ "image/png": "png",
330
+ "image/webp": "webp",
331
+ "image/gif": "gif",
332
+ "image/svg+xml": "svg",
333
+ "image/bmp": "bmp",
334
+ "image/tiff": "tiff",
335
+ "image/heic": "heic",
336
+ "image/heif": "heif",
337
+ "application/pdf": "pdf",
338
+ "audio/mpeg": "mp3",
339
+ "audio/aac": "aac",
340
+ "audio/wav": "wav",
341
+ "audio/x-wav": "wav",
342
+ "audio/mp4": "m4a",
343
+ "audio/webm": "webm",
344
+ "audio/ogg": "ogg"
345
+ };
346
+ function detectMimeType(bytes, declaredType, filename) {
347
+ const startsWith = (...values) => values.every((value, index) => bytes[index] === value);
348
+ const ascii2 = (start, end) => String.fromCharCode(...bytes.subarray(start, end));
349
+ if (startsWith(255, 216)) return "image/jpeg";
350
+ if (startsWith(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
351
+ if (ascii2(0, 4) === "RIFF") {
352
+ if (ascii2(8, 12) === "WEBP") return "image/webp";
353
+ if (ascii2(8, 12) === "WAVE") return "audio/wav";
354
+ }
355
+ if (/^GIF8[79]a$/.test(ascii2(0, 6))) return "image/gif";
356
+ if (ascii2(0, 2) === "BM") return "image/bmp";
357
+ if (startsWith(73, 73, 42, 0) || startsWith(77, 77, 0, 42)) return "image/tiff";
358
+ if (ascii2(0, 5) === "%PDF-") return "application/pdf";
359
+ if (ascii2(0, 4) === "OggS") return "audio/ogg";
360
+ if (ascii2(0, 3) === "ID3") return declaredType === "audio/aac" ? "audio/aac" : "audio/mpeg";
361
+ if (startsWith(26, 69, 223, 163)) return "audio/webm";
362
+ if (ascii2(4, 8) === "ftyp") {
363
+ const brand = ascii2(8, 12);
364
+ if (/^(hei[cfx]|mif1|msf1)$/.test(brand)) return "image/heic";
365
+ return "audio/mp4";
366
+ }
367
+ const extension = filename.match(/\.([a-zA-Z0-9]+)$/)?.[1].toLowerCase();
368
+ if (!declaredType || declaredType === "application/octet-stream") {
369
+ const type = Object.entries(EXTENSIONS).find(([, value]) => value === extension)?.[0];
370
+ if (type) return type;
371
+ if (extension === "jpeg") return "image/jpeg";
372
+ if (extension === "tif") return "image/tiff";
373
+ if (extension === "mp4") return "audio/mp4";
374
+ if (extension === "oga") return "audio/ogg";
375
+ }
376
+ return declaredType;
377
+ }
378
+ async function sanitizeUploadBytes(bytes, mimeType, filename) {
379
+ const baseType = detectMimeType(bytes, mimeType.split(";", 1)[0].toLowerCase(), filename);
380
+ const result = {
381
+ bytes,
382
+ mimeType: baseType || mimeType,
383
+ filename,
384
+ status: "unsupported"
385
+ };
386
+ try {
387
+ const input = new Uint8Array(bytes);
388
+ let cleaned = stripImageMetadata(input, baseType) ?? stripAudioMetadata(input, baseType);
389
+ if (!cleaned && !baseType.startsWith("image/") && !baseType.startsWith("audio/")) {
390
+ const { stripDocumentMetadata } = await import("./documents-HKLTU4HQ.js");
391
+ cleaned = await stripDocumentMetadata(input, baseType, filename);
392
+ }
393
+ if (cleaned) {
394
+ if (!cleaned.byteLength) throw new Error("Empty sanitized upload");
395
+ if (cleaned.byteLength > 100 * 1024 * 1024 && cleaned.byteLength > bytes.byteLength) {
396
+ throw new Error("Sanitized upload exceeds the upload size limit");
397
+ }
398
+ result.bytes = cleaned;
399
+ result.status = "sanitized";
400
+ }
401
+ } catch {
402
+ result.status = "failed";
403
+ }
404
+ return result;
405
+ }
406
+
407
+ // src/uploadService.ts
408
+ var UPLOAD_MAX_ATTEMPTS = 3;
409
+ var UPLOAD_RETRY_DELAY_MS = 2e3;
410
+ var PROFILE_IMAGE_MAX_SIZE_BYTES = 300 * 1024;
411
+ function adoptUploadEmbedId(embed, authoritativeEmbedId) {
412
+ embed.embedId = authoritativeEmbedId;
413
+ }
414
+ function getUploadUrl(apiUrl) {
415
+ try {
416
+ const url = new URL(apiUrl);
417
+ if (url.hostname === "localhost") return "http://localhost:8001";
418
+ } catch {
419
+ }
420
+ return "https://upload.openmates.org";
421
+ }
422
+ function getUploadOrigin(apiUrl) {
423
+ try {
424
+ const url = new URL(apiUrl);
425
+ if (url.hostname === "localhost") return "http://localhost:5173";
426
+ if (url.hostname.startsWith("api.")) {
427
+ return `${url.protocol}//app.${url.hostname.slice(4)}`;
428
+ }
429
+ } catch {
430
+ }
431
+ return "https://app.openmates.org";
432
+ }
433
+ function getUploadMimeType(filename) {
434
+ const ext = extname(filename).toLowerCase();
435
+ switch (ext) {
436
+ case ".jpg":
437
+ case ".jpeg":
438
+ return "image/jpeg";
439
+ case ".png":
440
+ return "image/png";
441
+ case ".webp":
442
+ return "image/webp";
443
+ case ".gif":
444
+ return "image/gif";
445
+ case ".heic":
446
+ return "image/heic";
447
+ case ".heif":
448
+ return "image/heif";
449
+ case ".bmp":
450
+ return "image/bmp";
451
+ case ".tif":
452
+ case ".tiff":
453
+ return "image/tiff";
454
+ case ".svg":
455
+ return "image/svg+xml";
456
+ case ".pdf":
457
+ return "application/pdf";
458
+ case ".mp3":
459
+ return "audio/mpeg";
460
+ case ".m4a":
461
+ case ".mp4":
462
+ return "audio/mp4";
463
+ case ".wav":
464
+ return "audio/wav";
465
+ case ".webm":
466
+ return "audio/webm";
467
+ case ".ogg":
468
+ case ".oga":
469
+ return "audio/ogg";
470
+ case ".aac":
471
+ return "audio/aac";
472
+ default:
473
+ return "application/octet-stream";
474
+ }
475
+ }
476
+ async function prepareUpload(fileBytes, filename, mimeType) {
477
+ try {
478
+ const result = await sanitizeUploadBytes(fileBytes, mimeType, filename);
479
+ if (result.status === "sanitized") {
480
+ return { bytes: result.bytes, filename, mimeType: result.mimeType };
481
+ }
482
+ if (result.status === "unsupported" && ["image/tiff", "image/heic", "image/heif", "image/bmp"].includes(result.mimeType)) {
483
+ try {
484
+ const { default: sharp } = await import("sharp");
485
+ const image = sharp(fileBytes);
486
+ const metadata = await image.metadata();
487
+ if ((metadata.pages ?? 1) !== 1) throw new Error("Multiple image pages");
488
+ const bytes = await image.rotate().png().toBuffer();
489
+ if (bytes.byteLength && bytes.byteLength <= 100 * 1024 * 1024) {
490
+ return { bytes, filename, mimeType: "image/png" };
491
+ }
492
+ } catch {
493
+ }
494
+ }
495
+ console.error("Warning: Could not remove embedded file metadata; uploading the original file.");
496
+ return { bytes: fileBytes, filename, mimeType: result.mimeType };
497
+ } catch {
498
+ }
499
+ console.error("Warning: Could not remove embedded file metadata; uploading the original file.");
500
+ return { bytes: fileBytes, filename, mimeType };
501
+ }
502
+ async function uploadFile(filePath, session) {
503
+ const filename = basename(filePath);
504
+ const fileBytes = readFileSync(filePath);
505
+ const prepared = await prepareUpload(fileBytes, filename, getUploadMimeType(filename));
506
+ const uploadUrl = `${getUploadUrl(session.apiUrl)}/v1/upload/file`;
507
+ const origin = getUploadOrigin(session.apiUrl);
508
+ const cookies = [];
509
+ if (session.cookies?.auth_refresh_token) {
510
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
511
+ }
512
+ let response;
513
+ let lastError;
514
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
515
+ try {
516
+ const blob = new Blob([Buffer.from(prepared.bytes)], { type: prepared.mimeType });
517
+ const formData = new FormData();
518
+ formData.append("file", blob, prepared.filename);
519
+ response = await fetch(uploadUrl, {
520
+ method: "POST",
521
+ body: formData,
522
+ headers: {
523
+ Origin: origin,
524
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
525
+ },
526
+ signal: AbortSignal.timeout(10 * 60 * 1e3)
527
+ // 10-minute timeout
528
+ });
529
+ break;
530
+ } catch (error) {
531
+ lastError = error;
532
+ if (attempt === UPLOAD_MAX_ATTEMPTS) break;
533
+ await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAY_MS));
534
+ }
535
+ }
536
+ if (!response) {
537
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
538
+ throw new Error(message || "Upload request failed.");
539
+ }
540
+ if (!response.ok) {
541
+ const status = response.status;
542
+ let errorMessage;
543
+ switch (status) {
544
+ case 401:
545
+ errorMessage = "Authentication failed. Run `openmates login` to re-authenticate.";
546
+ break;
547
+ case 413:
548
+ errorMessage = "File too large (maximum 100 MB).";
549
+ break;
550
+ case 415:
551
+ errorMessage = "Unsupported file type.";
552
+ break;
553
+ case 422: {
554
+ const body = await response.text().catch(() => "");
555
+ errorMessage = body.includes("malware") ? "File rejected: malware detected." : body.includes("content_safety") ? "File rejected: content safety violation." : `Upload validation failed: ${body}`;
556
+ break;
557
+ }
558
+ case 429:
559
+ errorMessage = "Upload rate limit exceeded. Try again in a minute.";
560
+ break;
561
+ default:
562
+ errorMessage = `Upload failed (HTTP ${status}).`;
563
+ }
564
+ throw new Error(errorMessage);
565
+ }
566
+ const data = await response.json();
567
+ return data;
568
+ }
569
+ async function transcribeUploadedAudio(uploadResult, filename, session, options = {}) {
570
+ const s3Key = uploadResult.files?.original?.s3_key ?? Object.values(uploadResult.files ?? {})[0]?.s3_key;
571
+ if (!s3Key) {
572
+ throw new Error("Upload succeeded but no audio file key was returned.");
573
+ }
574
+ const cookies = [];
575
+ if (session.cookies?.auth_refresh_token) {
576
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
577
+ }
578
+ const requestItem = {
579
+ id: options.requestId ?? uploadResult.embed_id,
580
+ embed_id: uploadResult.embed_id,
581
+ s3_key: s3Key,
582
+ s3_base_url: uploadResult.s3_base_url,
583
+ aes_key: uploadResult.aes_key,
584
+ aes_nonce: uploadResult.aes_nonce,
585
+ vault_wrapped_aes_key: uploadResult.vault_wrapped_aes_key,
586
+ filename,
587
+ mime_type: uploadResult.content_type
588
+ };
589
+ if (options.chatId) {
590
+ requestItem.chat_id = options.chatId;
591
+ }
592
+ const response = await fetch(
593
+ `${session.apiUrl.replace(/\/$/, "")}/v1/apps/audio/skills/transcribe`,
594
+ {
595
+ method: "POST",
596
+ headers: {
597
+ Accept: "application/json",
598
+ "Content-Type": "application/json",
599
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
600
+ },
601
+ body: JSON.stringify({ requests: [requestItem] }),
602
+ signal: AbortSignal.timeout(10 * 60 * 1e3)
603
+ }
604
+ );
605
+ if (!response.ok) {
606
+ let detail = `Transcription failed (HTTP ${response.status}).`;
607
+ try {
608
+ const data2 = await response.json();
609
+ detail = data2.detail ?? data2.error ?? detail;
610
+ } catch {
611
+ }
612
+ throw new Error(detail);
613
+ }
614
+ const data = await response.json();
615
+ if (data.success === false) {
616
+ throw new Error(data.error ?? "Transcription failed.");
617
+ }
618
+ const requestId = options.requestId ?? uploadResult.embed_id;
619
+ const group = data.data?.results?.find((item) => item.id === requestId) ?? data.data?.results?.[0];
620
+ const result = group?.results?.[0];
621
+ if (!result) {
622
+ throw new Error(group?.error ?? "Transcription response did not include a result.");
623
+ }
624
+ if (result.error) {
625
+ throw new Error(result.error);
626
+ }
627
+ return {
628
+ transcript: result.transcript ?? null,
629
+ transcript_original: result.transcript_original ?? null,
630
+ transcript_corrected: result.transcript_corrected ?? null,
631
+ use_corrected: result.use_corrected ?? null,
632
+ correction_model: result.correction_model ?? null,
633
+ model: result.model ?? null,
634
+ waveform: result.waveform ?? null
635
+ };
636
+ }
637
+ function getProfileImageMime(filename) {
638
+ const ext = extname(filename).toLowerCase();
639
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
640
+ if (ext === ".png") return "image/png";
641
+ throw new Error("Profile images must be JPEG or PNG files.");
642
+ }
643
+ async function uploadProfileImageForm(filePath, session, uploadPath, fields = {}) {
644
+ const filename = basename(filePath);
645
+ const fileBytes = readFileSync(filePath);
646
+ const contentType = getProfileImageMime(filename);
647
+ if (fileBytes.byteLength > PROFILE_IMAGE_MAX_SIZE_BYTES) {
648
+ throw new Error("Profile image must be 300 KB or smaller. Resize/compress the image and try again.");
649
+ }
650
+ const prepared = await prepareUpload(fileBytes, filename, contentType);
651
+ const uploadUrl = `${getUploadUrl(session.apiUrl)}${uploadPath}`;
652
+ const origin = getUploadOrigin(session.apiUrl);
653
+ const cookies = [];
654
+ if (session.cookies?.auth_refresh_token) {
655
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
656
+ }
657
+ const formData = new FormData();
658
+ for (const [key, value] of Object.entries(fields)) {
659
+ formData.append(key, value);
660
+ }
661
+ formData.append("file", new Blob([Buffer.from(prepared.bytes)], { type: prepared.mimeType }), prepared.filename);
662
+ const response = await fetch(uploadUrl, {
663
+ method: "POST",
664
+ body: formData,
665
+ headers: {
666
+ Origin: origin,
667
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
668
+ }
669
+ });
670
+ const data = await response.json().catch(() => ({}));
671
+ if (!response.ok && !data.status) {
672
+ throw new Error(data.detail ?? `Profile image upload failed (HTTP ${response.status}).`);
673
+ }
674
+ return data;
675
+ }
676
+ async function uploadProfileImage(filePath, session) {
677
+ return uploadProfileImageForm(filePath, session, "/v1/upload/profile-image");
678
+ }
679
+ async function uploadTeamProfileImage(filePath, session, teamId, encryptedProfileImageMetadata) {
680
+ return uploadProfileImageForm(filePath, session, "/v1/upload/team-profile-image", {
681
+ team_id: teamId,
682
+ encrypted_profile_image_metadata: encryptedProfileImageMetadata
683
+ });
684
+ }
685
+
686
+ export {
687
+ adoptUploadEmbedId,
688
+ uploadFile,
689
+ transcribeUploadedAudio,
690
+ uploadProfileImage,
691
+ uploadTeamProfileImage
692
+ };