@lacrous/omnicloud 0.2.2 → 0.2.4

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,4912 @@
1
+ import { createRequire as __omnicloudCreateRequire } from "node:module";
2
+ const require = __omnicloudCreateRequire(import.meta.url);
3
+
4
+ // ../shared/src/mime.ts
5
+ var MIME_BY_EXTENSION = {
6
+ // Documents
7
+ pdf: "application/pdf",
8
+ doc: "application/msword",
9
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
10
+ xls: "application/vnd.ms-excel",
11
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
12
+ ppt: "application/vnd.ms-powerpoint",
13
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
14
+ odt: "application/vnd.oasis.opendocument.text",
15
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
16
+ txt: "text/plain",
17
+ md: "text/markdown",
18
+ csv: "text/csv",
19
+ rtf: "application/rtf",
20
+ html: "text/html",
21
+ htm: "text/html",
22
+ css: "text/css",
23
+ xml: "application/xml",
24
+ yaml: "application/yaml",
25
+ yml: "application/yaml",
26
+ json: "application/json",
27
+ epub: "application/epub+zip",
28
+ // Images
29
+ png: "image/png",
30
+ jpg: "image/jpeg",
31
+ jpeg: "image/jpeg",
32
+ gif: "image/gif",
33
+ webp: "image/webp",
34
+ svg: "image/svg+xml",
35
+ bmp: "image/bmp",
36
+ ico: "image/x-icon",
37
+ tiff: "image/tiff",
38
+ tif: "image/tiff",
39
+ avif: "image/avif",
40
+ heic: "image/heic",
41
+ // Audio
42
+ mp3: "audio/mpeg",
43
+ wav: "audio/wav",
44
+ ogg: "audio/ogg",
45
+ flac: "audio/flac",
46
+ m4a: "audio/mp4",
47
+ aac: "audio/aac",
48
+ opus: "audio/opus",
49
+ // Video
50
+ mp4: "video/mp4",
51
+ mkv: "video/x-matroska",
52
+ webm: "video/webm",
53
+ avi: "video/x-msvideo",
54
+ mov: "video/quicktime",
55
+ wmv: "video/x-ms-wmv",
56
+ flv: "video/x-flv",
57
+ // Archives
58
+ zip: "application/zip",
59
+ rar: "application/vnd.rar",
60
+ "7z": "application/x-7z-compressed",
61
+ tar: "application/x-tar",
62
+ gz: "application/gzip",
63
+ bz2: "application/x-bzip2",
64
+ xz: "application/x-xz",
65
+ // Other
66
+ apk: "application/vnd.android.package-archive",
67
+ exe: "application/x-msdownload",
68
+ dmg: "application/x-apple-diskimage",
69
+ iso: "application/x-iso9660-image",
70
+ bin: "application/octet-stream"
71
+ };
72
+ var DEFAULT_MIME_TYPE = "application/octet-stream";
73
+ function fileExtension(filename) {
74
+ const base = filename.split(/[/\\]/).pop() ?? "";
75
+ const dot = base.lastIndexOf(".");
76
+ if (dot <= 0 || dot === base.length - 1) return "";
77
+ return base.slice(dot + 1).toLowerCase();
78
+ }
79
+ function mimeFromFilename(filename) {
80
+ return MIME_BY_EXTENSION[fileExtension(filename)] ?? DEFAULT_MIME_TYPE;
81
+ }
82
+ var PREFIXED_CATEGORIES = [
83
+ ["image/", "image"],
84
+ ["video/", "video"],
85
+ ["audio/", "audio"],
86
+ ["application/pdf", "pdf"],
87
+ ["application/zip", "archive"],
88
+ ["application/x-tar", "archive"],
89
+ ["application/gzip", "archive"],
90
+ ["application/x-7z-compressed", "archive"],
91
+ ["application/vnd.rar", "archive"],
92
+ ["application/x-bzip2", "archive"],
93
+ ["application/x-xz", "archive"],
94
+ ["text/", "text"],
95
+ ["application/json", "text"],
96
+ ["application/xml", "text"],
97
+ ["application/yaml", "text"],
98
+ ["application/javascript", "text"],
99
+ ["text/markdown", "text"]
100
+ ];
101
+ var OFFICE_MIMES = /* @__PURE__ */ new Set([
102
+ "application/msword",
103
+ "application/vnd.ms-excel",
104
+ "application/vnd.ms-powerpoint",
105
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
106
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
107
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
108
+ "application/vnd.oasis.opendocument.text",
109
+ "application/vnd.oasis.opendocument.spreadsheet",
110
+ "application/rtf",
111
+ "application/epub+zip"
112
+ ]);
113
+ function fileCategory(mimeType) {
114
+ for (const [prefix, category] of PREFIXED_CATEGORIES) {
115
+ if (mimeType.startsWith(prefix)) return category;
116
+ }
117
+ if (OFFICE_MIMES.has(mimeType)) return "document";
118
+ return "other";
119
+ }
120
+ var FILE_CATEGORIES = [
121
+ "image",
122
+ "video",
123
+ "audio",
124
+ "pdf",
125
+ "archive",
126
+ "document",
127
+ "text",
128
+ "other"
129
+ ];
130
+ var CATEGORY_PREFIXES = {
131
+ image: ["image/"],
132
+ video: ["video/"],
133
+ audio: ["audio/"],
134
+ pdf: ["application/pdf"],
135
+ archive: [
136
+ "application/zip",
137
+ "application/x-tar",
138
+ "application/gzip",
139
+ "application/x-7z-compressed",
140
+ "application/vnd.rar",
141
+ "application/x-bzip2",
142
+ "application/x-xz"
143
+ ],
144
+ text: [
145
+ "text/",
146
+ "application/json",
147
+ "application/xml",
148
+ "application/yaml",
149
+ "application/javascript"
150
+ ],
151
+ document: [...OFFICE_MIMES],
152
+ other: []
153
+ };
154
+ function isFileCategory(value) {
155
+ return FILE_CATEGORIES.includes(value);
156
+ }
157
+ function mimeMatchersForType(value) {
158
+ const normalized = value.toLowerCase().trim();
159
+ if (!normalized) return null;
160
+ if (isFileCategory(normalized)) {
161
+ const prefixes = CATEGORY_PREFIXES[normalized];
162
+ return prefixes.length > 0 ? prefixes : null;
163
+ }
164
+ return [normalized];
165
+ }
166
+
167
+ // ../shared/src/query.ts
168
+ var SORT_FIELDS = ["name", "size", "createdAt", "updatedAt", "type"];
169
+ var SORT_ORDERS = ["asc", "desc"];
170
+ var ITEM_STATUSES = ["active", "trashed", "all"];
171
+ var DEFAULT_PAGE_SIZE = 50;
172
+ var MAX_PAGE_SIZE = 200;
173
+ var SIZE_UNITS = {
174
+ b: 1,
175
+ kb: 1024,
176
+ mb: 1024 ** 2,
177
+ gb: 1024 ** 3,
178
+ tb: 1024 ** 4
179
+ };
180
+ function parseSize(value) {
181
+ const match = /^\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]*)\s*$/.exec(value);
182
+ if (!match) return null;
183
+ const amount = Number(match[1]);
184
+ if (!Number.isFinite(amount) || amount < 0) return null;
185
+ const unit = (match[2] ?? "").toLowerCase() || "b";
186
+ const factor = SIZE_UNITS[unit];
187
+ if (factor === void 0) return null;
188
+ return Math.round(amount * factor);
189
+ }
190
+ function parseSizeComparison(value) {
191
+ const match = /^\s*(>=|<=|>|<|=)?\s*(.+)$/.exec(value);
192
+ if (!match) return null;
193
+ const operator = match[1] ?? "=";
194
+ const bytes = parseSize(match[2]);
195
+ if (bytes === null) return null;
196
+ switch (operator) {
197
+ case ">":
198
+ return { min: bytes + 1 };
199
+ case ">=":
200
+ return { min: bytes };
201
+ case "<":
202
+ return { max: bytes - 1 };
203
+ case "<=":
204
+ return { max: bytes };
205
+ default:
206
+ return { min: bytes, max: bytes };
207
+ }
208
+ }
209
+ function parseBoolean(value) {
210
+ const normalized = value.toLowerCase();
211
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
212
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
213
+ return null;
214
+ }
215
+ function parseDate(value, endOfDay) {
216
+ const trimmed = value.trim();
217
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
218
+ const date2 = /* @__PURE__ */ new Date(`${trimmed}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`);
219
+ return Number.isNaN(date2.getTime()) ? null : date2;
220
+ }
221
+ const date = new Date(trimmed);
222
+ return Number.isNaN(date.getTime()) ? null : date;
223
+ }
224
+ function parseSearchQuery(input, base = {}) {
225
+ const query = { ...base };
226
+ const terms = [];
227
+ for (const rawToken of input.trim().split(/\s+/)) {
228
+ if (!rawToken) continue;
229
+ const separator = rawToken.indexOf(":");
230
+ if (separator <= 0) {
231
+ terms.push(rawToken);
232
+ continue;
233
+ }
234
+ const field = rawToken.slice(0, separator).toLowerCase();
235
+ const value = rawToken.slice(separator + 1);
236
+ if (!value) {
237
+ terms.push(rawToken);
238
+ continue;
239
+ }
240
+ switch (field) {
241
+ case "name":
242
+ case "filename":
243
+ terms.push(value);
244
+ break;
245
+ case "type":
246
+ query.type = value.toLowerCase();
247
+ break;
248
+ case "ext":
249
+ case "extension":
250
+ query.ext = value.replace(/^\./, "").toLowerCase();
251
+ break;
252
+ case "size": {
253
+ const comparison = parseSizeComparison(value);
254
+ if (!comparison) {
255
+ terms.push(rawToken);
256
+ break;
257
+ }
258
+ if (comparison.min !== void 0) query.minSize = comparison.min;
259
+ if (comparison.max !== void 0) query.maxSize = comparison.max;
260
+ break;
261
+ }
262
+ case "folder":
263
+ query.folderName = value;
264
+ break;
265
+ case "starred": {
266
+ const parsed = parseBoolean(value);
267
+ if (parsed === null) terms.push(rawToken);
268
+ else query.starred = parsed;
269
+ break;
270
+ }
271
+ case "trashed":
272
+ case "deleted": {
273
+ const parsed = parseBoolean(value);
274
+ if (parsed === null) terms.push(rawToken);
275
+ else query.status = parsed ? "trashed" : "active";
276
+ break;
277
+ }
278
+ case "after":
279
+ case "since": {
280
+ const date = parseDate(value, false);
281
+ if (!date) terms.push(rawToken);
282
+ else query.from = date.toISOString();
283
+ break;
284
+ }
285
+ case "before": {
286
+ const date = parseDate(value, true);
287
+ if (!date) terms.push(rawToken);
288
+ else query.to = date.toISOString();
289
+ break;
290
+ }
291
+ default:
292
+ terms.push(rawToken);
293
+ break;
294
+ }
295
+ }
296
+ const freeText = terms.join(" ").trim();
297
+ if (freeText) query.q = freeText;
298
+ return { query, freeText };
299
+ }
300
+ function normalizePage(value) {
301
+ const parsed = Number(value);
302
+ if (!Number.isInteger(parsed) || parsed < 1) return 1;
303
+ return parsed;
304
+ }
305
+ function normalizeLimit(value) {
306
+ const parsed = Number(value);
307
+ if (!Number.isInteger(parsed) || parsed < 1) return DEFAULT_PAGE_SIZE;
308
+ return Math.min(parsed, MAX_PAGE_SIZE);
309
+ }
310
+ function normalizeSort(value) {
311
+ if (typeof value !== "string") return void 0;
312
+ return SORT_FIELDS.includes(value) ? value : void 0;
313
+ }
314
+ function normalizeOrder(value) {
315
+ if (typeof value !== "string") return void 0;
316
+ return SORT_ORDERS.includes(value) ? value : void 0;
317
+ }
318
+ function paginationMeta(page, limit, total) {
319
+ return { page, limit, total, hasMore: page * limit < total };
320
+ }
321
+
322
+ // ../shared/src/index.ts
323
+ var BATCH_FILE_OPERATIONS = [
324
+ "trash",
325
+ "restore",
326
+ "delete",
327
+ "star",
328
+ "unstar",
329
+ "move"
330
+ ];
331
+ var BATCH_FOLDER_OPERATIONS = [
332
+ "trash",
333
+ "restore",
334
+ "delete",
335
+ "star",
336
+ "unstar",
337
+ "move"
338
+ ];
339
+ var ERROR_CODES = {
340
+ AUTH_REQUIRED: "AUTH_REQUIRED",
341
+ AUTH_INVALID: "AUTH_INVALID",
342
+ PERMISSION_DENIED: "PERMISSION_DENIED",
343
+ INVALID_REQUEST: "INVALID_REQUEST",
344
+ NOT_FOUND: "NOT_FOUND",
345
+ FILE_NOT_FOUND: "FILE_NOT_FOUND",
346
+ FOLDER_NOT_FOUND: "FOLDER_NOT_FOUND",
347
+ CONFLICT: "CONFLICT",
348
+ PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
349
+ RATE_LIMITED: "RATE_LIMITED",
350
+ STORAGE_NOT_INITIALIZED: "STORAGE_NOT_INITIALIZED",
351
+ STORAGE_UNAVAILABLE: "STORAGE_UNAVAILABLE",
352
+ TELEGRAM_AUTH_REQUIRED: "TELEGRAM_AUTH_REQUIRED",
353
+ TELEGRAM_CONNECTION_FAILED: "TELEGRAM_CONNECTION_FAILED",
354
+ TELEGRAM_FILE_NOT_FOUND: "TELEGRAM_FILE_NOT_FOUND",
355
+ UPLOAD_FAILED: "UPLOAD_FAILED",
356
+ DOWNLOAD_FAILED: "DOWNLOAD_FAILED",
357
+ INTEGRITY_CHECK_FAILED: "INTEGRITY_CHECK_FAILED",
358
+ INTERNAL: "INTERNAL_ERROR"
359
+ };
360
+ var SESSION_COOKIE = "omnicloud_session";
361
+ var REQUEST_ID_HEADER = "x-request-id";
362
+
363
+ // ../core/src/errors.ts
364
+ var DomainError = class extends Error {
365
+ code;
366
+ status;
367
+ details;
368
+ /** Whether repeating the same operation may succeed. Defaults to false. */
369
+ retryable = false;
370
+ /** Server-requested delay before a retry, in seconds, when the provider gave one. */
371
+ retryAfterSeconds;
372
+ constructor(code, status, message, details, retry) {
373
+ super(message);
374
+ this.name = new.target.name;
375
+ this.code = code;
376
+ this.status = status;
377
+ this.details = details;
378
+ this.retryable = retry?.retryable ?? false;
379
+ this.retryAfterSeconds = retry?.retryAfterSeconds;
380
+ }
381
+ };
382
+ var ValidationError = class extends DomainError {
383
+ constructor(message, details) {
384
+ super(ERROR_CODES.INVALID_REQUEST, 400, message, details);
385
+ }
386
+ };
387
+ var UnauthorizedError = class extends DomainError {
388
+ constructor(message = "Authentication required") {
389
+ super(ERROR_CODES.AUTH_REQUIRED, 401, message);
390
+ }
391
+ };
392
+ var AuthInvalidError = class extends DomainError {
393
+ constructor(message = "Your session is no longer valid \u2014 please sign in again") {
394
+ super(ERROR_CODES.AUTH_INVALID, 401, message);
395
+ }
396
+ };
397
+ var ForbiddenError = class extends DomainError {
398
+ constructor(message = "You do not have access to this resource") {
399
+ super(ERROR_CODES.PERMISSION_DENIED, 403, message);
400
+ }
401
+ };
402
+ var NotFoundError = class extends DomainError {
403
+ constructor(message = "Resource not found", resource) {
404
+ super(
405
+ resource === "file" ? ERROR_CODES.FILE_NOT_FOUND : resource === "folder" ? ERROR_CODES.FOLDER_NOT_FOUND : ERROR_CODES.NOT_FOUND,
406
+ 404,
407
+ message
408
+ );
409
+ }
410
+ };
411
+ var ConflictError = class extends DomainError {
412
+ constructor(message) {
413
+ super(ERROR_CODES.CONFLICT, 409, message);
414
+ }
415
+ };
416
+ var PayloadTooLargeError = class extends DomainError {
417
+ constructor(message = "File is too large") {
418
+ super(ERROR_CODES.PAYLOAD_TOO_LARGE, 413, message);
419
+ }
420
+ };
421
+ var RateLimitedError = class extends DomainError {
422
+ constructor(message = "Too many requests, please retry later", options = {}) {
423
+ super(ERROR_CODES.RATE_LIMITED, 429, message, void 0, {
424
+ retryable: true,
425
+ retryAfterSeconds: options.retryAfterSeconds
426
+ });
427
+ }
428
+ };
429
+ var StorageNotInitializedError = class extends DomainError {
430
+ constructor() {
431
+ super(ERROR_CODES.STORAGE_NOT_INITIALIZED, 409, "Storage is not initialized for this account");
432
+ }
433
+ };
434
+ var TelegramAuthRequiredError = class extends DomainError {
435
+ constructor(message = "Your Telegram session has expired \u2014 please reconnect") {
436
+ super(ERROR_CODES.TELEGRAM_AUTH_REQUIRED, 401, message);
437
+ }
438
+ };
439
+ var TelegramConnectionError = class extends DomainError {
440
+ constructor(message = "Could not reach Telegram", details) {
441
+ super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details, { retryable: true });
442
+ }
443
+ };
444
+ var TelegramFileNotFoundError = class extends DomainError {
445
+ constructor(message = "The stored file is missing from Telegram") {
446
+ super(ERROR_CODES.TELEGRAM_FILE_NOT_FOUND, 404, message);
447
+ }
448
+ };
449
+ var UploadFailedError = class extends DomainError {
450
+ constructor(message = "Upload failed", details) {
451
+ super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
452
+ }
453
+ };
454
+ var IntegrityCheckError = class extends DomainError {
455
+ constructor(message = "Integrity check failed", details) {
456
+ super(ERROR_CODES.INTEGRITY_CHECK_FAILED, 502, message, details);
457
+ }
458
+ };
459
+ var OperationCancelledError = class extends DomainError {
460
+ constructor(message = "Operation cancelled") {
461
+ super(ERROR_CODES.INVALID_REQUEST, 499, message);
462
+ }
463
+ };
464
+ function mapProviderError(message, error) {
465
+ if (error instanceof DomainError) return error;
466
+ const detail = error instanceof Error ? error.message : String(error);
467
+ return new UploadFailedError(detail ? `${message}: ${detail}` : message, error);
468
+ }
469
+
470
+ // ../core/src/storage/engine.ts
471
+ import { createHash as createHash2 } from "node:crypto";
472
+ import { Readable, Transform } from "node:stream";
473
+
474
+ // ../core/src/storage/retry.ts
475
+ function isRetryableError(error) {
476
+ if (error instanceof OperationCancelledError) return false;
477
+ if (error instanceof DomainError) return error.retryable;
478
+ return false;
479
+ }
480
+ function retryDelayMs(error, attempt, baseDelayMs, random = Math.random) {
481
+ if (error instanceof DomainError && error.retryAfterSeconds !== void 0) {
482
+ return error.retryAfterSeconds * 1e3;
483
+ }
484
+ const ceiling = baseDelayMs * 2 ** (attempt - 1);
485
+ return Math.floor(random() * ceiling);
486
+ }
487
+
488
+ // ../core/src/utils/hash.ts
489
+ import { createHash } from "node:crypto";
490
+ function sha256Hex(data) {
491
+ return createHash("sha256").update(data).digest("hex");
492
+ }
493
+
494
+ // ../core/src/storage/engine.ts
495
+ var DEFAULT_RETRY_POLICY = { attempts: 3, baseDelayMs: 400 };
496
+ function delay(ms, signal) {
497
+ return new Promise((resolve2, reject) => {
498
+ if (signal?.aborted) {
499
+ reject(new OperationCancelledError());
500
+ return;
501
+ }
502
+ const timer = setTimeout(() => {
503
+ signal?.removeEventListener("abort", onAbort);
504
+ resolve2();
505
+ }, ms);
506
+ function onAbort() {
507
+ clearTimeout(timer);
508
+ reject(new OperationCancelledError());
509
+ }
510
+ signal?.addEventListener("abort", onAbort, { once: true });
511
+ });
512
+ }
513
+ function throwIfAborted(signal) {
514
+ if (signal?.aborted) throw new OperationCancelledError();
515
+ }
516
+ var StorageEngine = class {
517
+ constructor(provider, retryPolicy = DEFAULT_RETRY_POLICY) {
518
+ this.provider = provider;
519
+ this.retryPolicy = retryPolicy;
520
+ }
521
+ get providerName() {
522
+ return this.provider.name;
523
+ }
524
+ /**
525
+ * Uploads an object, computing its SHA-256 as it goes and reporting
526
+ * progress. Retries transient failures with exponential backoff.
527
+ */
528
+ async upload(input, control = {}) {
529
+ if (input.data) {
530
+ const sha2562 = sha256Hex(input.data);
531
+ const size2 = input.data.byteLength;
532
+ control.onProgress?.({ transferred: 0, total: size2, percent: 0 });
533
+ const stored2 = await this.withRetry(
534
+ () => this.provider.put(input, control),
535
+ control,
536
+ (error) => new UploadFailedError("Upload failed", error)
537
+ );
538
+ control.onProgress?.({ transferred: size2, total: size2, percent: 100 });
539
+ return { stored: stored2, sha256: sha2562, size: size2 };
540
+ }
541
+ const { path, size, sha256 } = input;
542
+ if (!path || size === void 0 || !sha256) {
543
+ throw new UploadFailedError("Upload input has neither data nor a complete spooled file");
544
+ }
545
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
546
+ const stored = await this.withRetry(
547
+ () => this.provider.put(input, control),
548
+ control,
549
+ (error) => new UploadFailedError("Upload failed", error)
550
+ );
551
+ control.onProgress?.({ transferred: size, total: size, percent: 100 });
552
+ return { stored, sha256, size };
553
+ }
554
+ async download(ref, control = {}) {
555
+ return this.withRetry(
556
+ () => this.provider.get(ref, control),
557
+ control,
558
+ (error) => mapProviderError("Download failed", error)
559
+ );
560
+ }
561
+ /**
562
+ * Streams the object and verifies its SHA-256 against `expectedSha256`.
563
+ *
564
+ * The last chunk is held back until the digest of everything before it has
565
+ * been checked, so a corrupt object errors the stream before its final bytes
566
+ * are released. A consumer therefore never sees a complete-looking body that
567
+ * fails the checksum. Memory stays bounded to about two provider chunks.
568
+ */
569
+ async downloadStream(ref, expectedSha256, control = {}) {
570
+ const source = await this.withRetry(
571
+ () => this.provider.getStream(ref, control),
572
+ control,
573
+ (error) => mapProviderError("Download failed", error)
574
+ );
575
+ const hash = createHash2("sha256");
576
+ let held = null;
577
+ const gate = new Transform({
578
+ transform(chunk, _encoding, callback) {
579
+ hash.update(chunk);
580
+ if (held) this.push(held);
581
+ held = chunk;
582
+ callback();
583
+ },
584
+ flush(callback) {
585
+ if (hash.digest("hex") !== expectedSha256) {
586
+ callback(new IntegrityCheckError("Downloaded content does not match its checksum"));
587
+ return;
588
+ }
589
+ if (held) this.push(held);
590
+ callback();
591
+ }
592
+ });
593
+ source.once("error", (error) => gate.destroy(error));
594
+ source.pipe(gate);
595
+ return gate;
596
+ }
597
+ async remove(ref) {
598
+ await this.withRetry(
599
+ () => this.provider.delete(ref),
600
+ {},
601
+ (error) => mapProviderError("Delete failed", error)
602
+ );
603
+ }
604
+ async exists(ref) {
605
+ try {
606
+ return await this.provider.exists(ref);
607
+ } catch (error) {
608
+ throw mapProviderError("Storage lookup failed", error);
609
+ }
610
+ }
611
+ /** Read-only listing of stored objects, for reconciliation. Never modifies anything. */
612
+ async list(limit) {
613
+ try {
614
+ return await this.provider.listObjects(limit);
615
+ } catch (error) {
616
+ throw mapProviderError("Storage listing failed", error);
617
+ }
618
+ }
619
+ async stat(ref) {
620
+ try {
621
+ return await this.provider.stat(ref);
622
+ } catch (error) {
623
+ throw mapProviderError("Storage lookup failed", error);
624
+ }
625
+ }
626
+ async health() {
627
+ try {
628
+ return await this.provider.healthCheck();
629
+ } catch (error) {
630
+ return {
631
+ healthy: false,
632
+ latencyMs: null,
633
+ message: error instanceof Error ? error.message : "Storage health check failed",
634
+ targetTitle: null
635
+ };
636
+ }
637
+ }
638
+ async verifyIntegrity(data, expectedSha256) {
639
+ return sha256Hex(data) === expectedSha256;
640
+ }
641
+ // ── internals ────────────────────────────────────────────────────────────
642
+ async withRetry(operation, control, wrapError) {
643
+ const { attempts, baseDelayMs } = this.retryPolicy;
644
+ let lastError;
645
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
646
+ throwIfAborted(control.signal);
647
+ try {
648
+ return await operation();
649
+ } catch (error) {
650
+ lastError = error;
651
+ if (attempt >= attempts || !isRetryableError(error)) break;
652
+ await delay(retryDelayMs(error, attempt, baseDelayMs), control.signal);
653
+ }
654
+ }
655
+ throw wrapError(lastError);
656
+ }
657
+ };
658
+
659
+ // ../core/src/services/query-resolver.ts
660
+ async function resolveItemQuery(folders, userId, input) {
661
+ const query = {};
662
+ if (input.folderId !== void 0) query.folderId = input.folderId;
663
+ if (input.q && input.q.trim()) query.nameContains = input.q.trim();
664
+ if (input.ext) query.ext = input.ext.replace(/^\./, "").toLowerCase();
665
+ if (input.starred !== void 0) query.starred = input.starred;
666
+ if (input.minSize !== void 0) query.minSize = input.minSize;
667
+ if (input.maxSize !== void 0) query.maxSize = input.maxSize;
668
+ if (input.sort) query.sort = input.sort;
669
+ if (input.order) query.order = input.order;
670
+ switch (input.status) {
671
+ case "active":
672
+ query.trashed = false;
673
+ break;
674
+ case "trashed":
675
+ query.trashed = true;
676
+ break;
677
+ default:
678
+ query.trashed = void 0;
679
+ break;
680
+ }
681
+ if (input.type) {
682
+ const matchers = mimeMatchersForType(input.type);
683
+ if (matchers) query.mimeMatchers = matchers;
684
+ }
685
+ if (input.from) {
686
+ const from = new Date(input.from);
687
+ if (!Number.isNaN(from.getTime())) query.from = from;
688
+ }
689
+ if (input.to) {
690
+ const to = new Date(input.to);
691
+ if (!Number.isNaN(to.getTime())) query.to = to;
692
+ }
693
+ if (input.folderName) {
694
+ query.folderIds = await folders.findIdsByName(userId, input.folderName);
695
+ }
696
+ return query;
697
+ }
698
+
699
+ // ../core/src/services/auth-session-service.ts
700
+ import { createHash as createHash3, randomBytes } from "node:crypto";
701
+ var DEFAULT_SESSION_TTL_DAYS = 30;
702
+ function hashToken(token) {
703
+ return createHash3("sha256").update(token, "utf8").digest("hex");
704
+ }
705
+ var AuthSessionService = class {
706
+ constructor(repo, options = {}) {
707
+ this.repo = repo;
708
+ this.ttlMs = (options.ttlDays ?? DEFAULT_SESSION_TTL_DAYS) * 24 * 60 * 60 * 1e3;
709
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
710
+ }
711
+ ttlMs;
712
+ now;
713
+ async issue(userId, context = {}) {
714
+ const token = randomBytes(32).toString("base64url");
715
+ const session = await this.repo.create({
716
+ userId,
717
+ tokenHash: hashToken(token),
718
+ expiresAt: new Date(this.now().getTime() + this.ttlMs),
719
+ userAgent: truncate(context.userAgent ?? null, 256),
720
+ ip: context.ip ?? null
721
+ });
722
+ return { token, session };
723
+ }
724
+ /**
725
+ * Resolves a raw token to its active session. Returns null for an unknown,
726
+ * expired, or revoked token. Successful lookups refresh `lastUsedAt`.
727
+ */
728
+ async resolve(token) {
729
+ if (!token || token.length < 16 || token.length > 128) return null;
730
+ const session = await this.repo.findByTokenHash(hashToken(token));
731
+ if (!session) return null;
732
+ const now = this.now();
733
+ if (session.revokedAt !== null || session.expiresAt.getTime() <= now.getTime()) {
734
+ return null;
735
+ }
736
+ await this.repo.touch(session.id, now);
737
+ return session;
738
+ }
739
+ /** Revokes the session behind a token. Unknown tokens are a no-op. */
740
+ async revoke(token) {
741
+ if (!token) return;
742
+ const session = await this.repo.findByTokenHash(hashToken(token));
743
+ if (session && session.revokedAt === null) {
744
+ await this.repo.revoke(session.id, this.now());
745
+ }
746
+ }
747
+ /** Revokes every session for a user, for "sign out everywhere". */
748
+ async revokeAll(userId) {
749
+ return this.repo.revokeAllForUser(userId, this.now());
750
+ }
751
+ listActive(userId) {
752
+ return this.repo.listActiveForUser(userId, this.now());
753
+ }
754
+ /** Housekeeping: removes sessions that expired more than `graceDays` ago. */
755
+ async pruneExpired(graceDays = 7) {
756
+ const cutoff = new Date(this.now().getTime() - graceDays * 24 * 60 * 60 * 1e3);
757
+ return this.repo.deleteExpiredBefore(cutoff);
758
+ }
759
+ };
760
+ function truncate(value, max) {
761
+ return value === null ? null : value.slice(0, max);
762
+ }
763
+
764
+ // ../core/src/services/upload-operation.ts
765
+ function isValidOperationId(id) {
766
+ return /^[A-Za-z0-9_-]{8,64}$/.test(id);
767
+ }
768
+ var OperationAlreadyExistsError = class extends Error {
769
+ constructor() {
770
+ super("An upload with this operation id already exists");
771
+ this.name = "OperationAlreadyExistsError";
772
+ }
773
+ };
774
+
775
+ // ../core/src/services/reconciliation.ts
776
+ function reconcile(channel, references) {
777
+ const inChannel = new Set(channel.map((message) => message.messageId));
778
+ const unknown = [];
779
+ let referenced = 0;
780
+ for (const message of channel) {
781
+ const isReferenced = references.fileMessageIds.has(message.messageId) || references.pendingUploadMessageIds.has(message.messageId);
782
+ if (isReferenced) {
783
+ referenced += 1;
784
+ } else {
785
+ unknown.push({ kind: "unknown", messageId: message.messageId, sizeBytes: message.sizeBytes });
786
+ }
787
+ }
788
+ const dangling = [];
789
+ for (const messageId of references.fileMessageIds) {
790
+ if (!inChannel.has(messageId))
791
+ dangling.push({ kind: "dangling", messageId, recordedBy: "file" });
792
+ }
793
+ for (const messageId of references.pendingUploadMessageIds) {
794
+ if (!inChannel.has(messageId)) {
795
+ dangling.push({ kind: "dangling", messageId, recordedBy: "upload" });
796
+ }
797
+ }
798
+ return {
799
+ scannedMessages: channel.length,
800
+ referencedMessages: referenced,
801
+ unknown,
802
+ dangling
803
+ };
804
+ }
805
+
806
+ // ../core/src/services/reconciliation-service.ts
807
+ var SCAN_LIMIT = 5e3;
808
+ var ReconciliationService = class {
809
+ constructor(repos, engineFor) {
810
+ this.repos = repos;
811
+ this.engineFor = engineFor;
812
+ }
813
+ async run(userId) {
814
+ const engine = await this.engineFor(userId);
815
+ const listed = await engine.list(SCAN_LIMIT);
816
+ const fileMessageIds = /* @__PURE__ */ new Set();
817
+ for (const file of await this.repos.files.listByUser(userId)) {
818
+ fileMessageIds.add(file.telegramMessageId);
819
+ }
820
+ for (const version of await this.versionMessageIds(userId)) {
821
+ fileMessageIds.add(version);
822
+ }
823
+ const pendingUploadMessageIds = await this.pendingUploadMessageIds(userId);
824
+ return reconcile(
825
+ listed.map((object) => ({
826
+ messageId: Number(object.messageId),
827
+ sizeBytes: object.size
828
+ })),
829
+ { fileMessageIds, pendingUploadMessageIds }
830
+ );
831
+ }
832
+ /**
833
+ * Objects stored by an upload that has not yet completed. They are in-flight,
834
+ * not orphaned, so they count as referenced until the operation settles.
835
+ */
836
+ async pendingUploadMessageIds(userId) {
837
+ const ids = /* @__PURE__ */ new Set();
838
+ for (const status of ["UPLOADING", "PENDING"]) {
839
+ for (const operation of await this.repos.uploadOperations.listByStatus(userId, status)) {
840
+ if (operation.telegramMessageId !== null) ids.add(operation.telegramMessageId);
841
+ }
842
+ }
843
+ return ids;
844
+ }
845
+ /** Every version's message id for this user's files, including trashed ones. */
846
+ async versionMessageIds(userId) {
847
+ const files = await this.repos.files.listByUser(userId);
848
+ const byFile = await this.repos.files.listVersionsForFiles(files.map((file) => file.id));
849
+ return [...byFile.values()].flat().map((version) => version.telegramMessageId);
850
+ }
851
+ };
852
+
853
+ // ../core/src/services/reconciliation-repair.ts
854
+ function planRepairs(report) {
855
+ const actions = [];
856
+ for (const finding of report.dangling) {
857
+ actions.push({
858
+ id: `detach-${finding.recordedBy}-${finding.messageId}`,
859
+ kind: "detach-dangling",
860
+ messageId: finding.messageId,
861
+ recordedBy: finding.recordedBy,
862
+ reason: "A record points at a Telegram message that is no longer in the channel. Detaching marks the record as missing; it does not delete it.",
863
+ destructive: false
864
+ });
865
+ }
866
+ for (const finding of report.unknown) {
867
+ actions.push({
868
+ id: `adopt-${finding.messageId}`,
869
+ kind: "adopt-unknown",
870
+ messageId: finding.messageId,
871
+ sizeBytes: finding.sizeBytes,
872
+ reason: "A channel message has no record. Adopting creates a file record for it. The message is not removed.",
873
+ destructive: false
874
+ });
875
+ }
876
+ return { actions, deletions: [] };
877
+ }
878
+ function selectApproved(plan, approvedIds) {
879
+ const offered = new Map(plan.actions.map((action) => [action.id, action]));
880
+ const selected = [];
881
+ for (const id of new Set(approvedIds)) {
882
+ const action = offered.get(id);
883
+ if (!action) throw new ValidationError(`Repair '${id}' was not offered by the plan`);
884
+ selected.push(action);
885
+ }
886
+ return selected;
887
+ }
888
+
889
+ // ../core/src/services/reconciliation-apply.ts
890
+ var ReconciliationApplier = class {
891
+ constructor(repos, userId, engine) {
892
+ this.repos = repos;
893
+ this.userId = userId;
894
+ this.engine = engine;
895
+ }
896
+ async apply(actions) {
897
+ const applied = [];
898
+ const failed = [];
899
+ for (const action of actions) {
900
+ try {
901
+ if (action.kind === "detach-dangling") {
902
+ await this.detach(action);
903
+ } else {
904
+ await this.adopt(action);
905
+ }
906
+ applied.push({ id: action.id, kind: action.kind });
907
+ } catch (error) {
908
+ failed.push({
909
+ id: action.id,
910
+ message: error instanceof Error ? error.message : "repair failed"
911
+ });
912
+ }
913
+ }
914
+ return { applied, failed };
915
+ }
916
+ async detach(action) {
917
+ if (action.recordedBy !== "file") return;
918
+ const file = (await this.repos.files.listByUser(this.userId)).find(
919
+ (candidate) => candidate.telegramMessageId === action.messageId
920
+ );
921
+ if (!file) return;
922
+ await this.repos.files.update(file.id, { deletedAt: /* @__PURE__ */ new Date(), trashBatchId: null });
923
+ }
924
+ async adopt(action) {
925
+ const ref = { messageId: String(action.messageId) };
926
+ const object = await this.engine.stat(ref);
927
+ if (!object) throw new Error("The channel message is no longer available");
928
+ const data = await this.engine.download(ref);
929
+ if (data.byteLength !== object.size) {
930
+ throw new Error("The stored bytes do not match the object's reported size");
931
+ }
932
+ const record = await this.repos.files.create({
933
+ userId: this.userId,
934
+ folderId: null,
935
+ name: `recovered-${action.messageId}-${object.name}`,
936
+ size: object.size,
937
+ mimeType: object.mimeType,
938
+ sha256: sha256Hex(data),
939
+ telegramMessageId: action.messageId
940
+ });
941
+ await this.repos.files.createVersion({
942
+ fileId: record.id,
943
+ userId: this.userId,
944
+ size: object.size,
945
+ mimeType: object.mimeType,
946
+ sha256: record.sha256,
947
+ telegramMessageId: action.messageId
948
+ });
949
+ }
950
+ };
951
+
952
+ // ../core/src/services/reconciliation-flow.ts
953
+ var ReconciliationFlow = class {
954
+ constructor(repos, engineFor) {
955
+ this.repos = repos;
956
+ this.engineFor = engineFor;
957
+ this.reconcile = new ReconciliationService(repos, engineFor);
958
+ }
959
+ reconcile;
960
+ async plan(userId) {
961
+ return planRepairs(await this.reconcile.run(userId));
962
+ }
963
+ async apply(userId, approvedIds) {
964
+ const fresh = await this.plan(userId);
965
+ const selected = selectApproved(fresh, approvedIds);
966
+ const engine = await this.engineFor(userId);
967
+ const applier = new ReconciliationApplier(this.repos, userId, engine);
968
+ return applier.apply(selected);
969
+ }
970
+ };
971
+
972
+ // ../core/src/utils/operation-log.ts
973
+ async function timedOperation(sink, base, run, now = Date.now) {
974
+ const started = now();
975
+ const emit = (status, errorCode) => {
976
+ if (!sink) return;
977
+ const record = {
978
+ operation: base.operation,
979
+ userId: base.userId,
980
+ resourceId: base.resourceId ?? null,
981
+ sizeBytes: base.sizeBytes ?? null,
982
+ durationMs: now() - started,
983
+ status,
984
+ errorCode
985
+ };
986
+ try {
987
+ if (status === "ok") sink.info({ ...record }, `${base.operation}.completed`);
988
+ else sink.warn({ ...record }, `${base.operation}.failed`);
989
+ } catch {
990
+ }
991
+ };
992
+ try {
993
+ const result = await run();
994
+ emit("ok", null);
995
+ return result;
996
+ } catch (error) {
997
+ const code = error.code ?? "INTERNAL_ERROR";
998
+ emit("error", code);
999
+ throw error;
1000
+ }
1001
+ }
1002
+
1003
+ // ../core/src/services/activity-service.ts
1004
+ var ActivityService = class {
1005
+ constructor(activity, retentionDays = 90) {
1006
+ this.activity = activity;
1007
+ this.retentionDays = retentionDays;
1008
+ }
1009
+ /**
1010
+ * Records an event. Never throws — a logging failure must not fail the
1011
+ * user-facing operation that produced it.
1012
+ */
1013
+ async record(input) {
1014
+ try {
1015
+ await this.activity.record({
1016
+ userId: input.userId,
1017
+ action: input.action,
1018
+ resourceType: input.resourceType,
1019
+ resourceId: input.resourceId,
1020
+ resourceName: input.resourceName ?? null,
1021
+ metadata: sanitizeMetadata(input.metadata)
1022
+ });
1023
+ } catch {
1024
+ }
1025
+ }
1026
+ async list(userId, page) {
1027
+ return this.activity.list(userId, page);
1028
+ }
1029
+ /** Most recent distinct files for the given actions, for the Recent view. */
1030
+ async recentFiles(userId, actions, limit) {
1031
+ return this.activity.recentFiles(userId, actions, limit);
1032
+ }
1033
+ async prune() {
1034
+ const cutoff = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3);
1035
+ return this.activity.pruneOlderThan(cutoff);
1036
+ }
1037
+ };
1038
+ var SENSITIVE_KEY = /(password|secret|token|session|hash|credential)/i;
1039
+ function sanitizeMetadata(metadata) {
1040
+ if (!metadata) return null;
1041
+ const clean = {};
1042
+ for (const [key, value] of Object.entries(metadata)) {
1043
+ if (SENSITIVE_KEY.test(key)) continue;
1044
+ if (value === void 0) continue;
1045
+ clean[key] = value;
1046
+ }
1047
+ return Object.keys(clean).length > 0 ? clean : null;
1048
+ }
1049
+ var noopActivityRecorder = {
1050
+ async record() {
1051
+ }
1052
+ };
1053
+
1054
+ // ../core/src/utils/filename.ts
1055
+ function sanitizeFileName(name) {
1056
+ const base = name.split(/[/\\]/).pop() ?? name;
1057
+ const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "").replace(/[<>:"|?*]/g, "_").trim();
1058
+ if (!cleaned || cleaned === "." || cleaned === "..") return "";
1059
+ return cleaned.slice(0, 255);
1060
+ }
1061
+
1062
+ // ../core/src/services/file-service.ts
1063
+ var FileService = class {
1064
+ constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
1065
+ this.files = files;
1066
+ this.folders = folders;
1067
+ this.engineFor = engineFor;
1068
+ this.activity = activity;
1069
+ this.operations = operations;
1070
+ this.log = log;
1071
+ }
1072
+ // ── Upload ───────────────────────────────────────────────────────────────
1073
+ async upload(userId, input) {
1074
+ return timedOperation(
1075
+ this.log,
1076
+ {
1077
+ operation: "upload",
1078
+ userId,
1079
+ resourceId: input.replaceFileId ?? null,
1080
+ sizeBytes: input.spooled?.size ?? input.data?.byteLength ?? null
1081
+ },
1082
+ () => input.operationId && this.operations ? this.uploadOnce(userId, input, input.operationId, this.operations) : this.performUpload(userId, input)
1083
+ );
1084
+ }
1085
+ /**
1086
+ * Idempotent upload keyed by a client-supplied operation id. A retry never
1087
+ * writes a second Telegram object: a completed operation returns its file, and
1088
+ * an operation whose object was already stored is committed without uploading.
1089
+ */
1090
+ async uploadOnce(userId, input, operationId, operations) {
1091
+ if (!isValidOperationId(operationId)) {
1092
+ throw new ValidationError("Invalid upload operation id");
1093
+ }
1094
+ let op = await operations.findByOperationId(userId, operationId);
1095
+ if (op?.status === "COMPLETED" && op.fileId) {
1096
+ return this.get(userId, op.fileId);
1097
+ }
1098
+ if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
1099
+ return this.commitStoredObject(userId, input, operations, op);
1100
+ }
1101
+ if (!op) {
1102
+ try {
1103
+ op = await operations.create({ userId, operationId });
1104
+ } catch (error) {
1105
+ if (!(error instanceof OperationAlreadyExistsError)) throw error;
1106
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1107
+ if (outcome) return outcome;
1108
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1109
+ }
1110
+ }
1111
+ const claimed = await operations.claim(op.id, "PENDING", "UPLOADING") || await operations.claim(op.id, "FAILED", "UPLOADING");
1112
+ if (!claimed) {
1113
+ const outcome = await this.followWinner(userId, input, operationId, operations);
1114
+ if (outcome) return outcome;
1115
+ throw new ConflictError("A concurrent upload with this operation id is still in progress");
1116
+ }
1117
+ try {
1118
+ const record = await this.performUpload(userId, input, {
1119
+ onStored: async (stored) => {
1120
+ await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
1121
+ }
1122
+ });
1123
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1124
+ return record;
1125
+ } catch (error) {
1126
+ await operations.update(op.id, {
1127
+ status: "FAILED",
1128
+ error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
1129
+ });
1130
+ throw error;
1131
+ }
1132
+ }
1133
+ /**
1134
+ * Follows an operation another request is driving, for a bounded time. Returns
1135
+ * the finished file if the winner completes, or the stored object's commit if
1136
+ * it has stored one. Returns null when the winner is still in progress after the
1137
+ * budget, so the caller can report a conflict instead of waiting forever.
1138
+ */
1139
+ async followWinner(userId, input, operationId, operations) {
1140
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1141
+ const current = await operations.findByOperationId(userId, operationId);
1142
+ if (current?.status === "COMPLETED" && current.fileId) {
1143
+ return this.get(userId, current.fileId);
1144
+ }
1145
+ if (current?.status === "FAILED") {
1146
+ return null;
1147
+ }
1148
+ if (current?.status === "UPLOADING" && current.telegramMessageId !== null) {
1149
+ return this.commitStoredObject(userId, input, operations, current);
1150
+ }
1151
+ await new Promise((resolve2) => setTimeout(resolve2, 10));
1152
+ }
1153
+ return null;
1154
+ }
1155
+ /** Commits metadata for an object that is already in Telegram. */
1156
+ async commitStoredObject(userId, input, operations, op) {
1157
+ const name = sanitizeFileName(input.name);
1158
+ const mimeType = mimeFromFilename(name);
1159
+ const size = op.size ?? input.spooled?.size ?? input.data?.byteLength ?? 0;
1160
+ const sha256 = op.sha256 ?? input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
1161
+ const record = await this.commitRecord(userId, input, {
1162
+ name,
1163
+ mimeType,
1164
+ size,
1165
+ sha256,
1166
+ telegramMessageId: op.telegramMessageId
1167
+ });
1168
+ await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
1169
+ return record;
1170
+ }
1171
+ async performUpload(userId, input, hooks = {}) {
1172
+ const name = sanitizeFileName(input.name);
1173
+ if (!name) throw new ValidationError("Invalid file name");
1174
+ let folderId = input.folderId;
1175
+ if (input.replaceFileId) {
1176
+ const existing = await this.get(userId, input.replaceFileId);
1177
+ folderId = existing.folderId;
1178
+ } else if (folderId !== null) {
1179
+ await this.assertFolder(userId, folderId);
1180
+ }
1181
+ const mimeType = mimeFromFilename(name);
1182
+ const engine = await this.engineFor(userId);
1183
+ const control = {
1184
+ onProgress: input.onProgress,
1185
+ signal: input.signal
1186
+ };
1187
+ if (!input.data && !input.spooled) {
1188
+ throw new ValidationError("An upload needs content");
1189
+ }
1190
+ const { stored, sha256, size } = await engine.upload(
1191
+ input.spooled ? {
1192
+ name,
1193
+ mimeType,
1194
+ path: input.spooled.path,
1195
+ size: input.spooled.size,
1196
+ sha256: input.spooled.sha256
1197
+ } : { name, mimeType, data: input.data },
1198
+ control
1199
+ );
1200
+ await hooks.onStored?.(stored);
1201
+ try {
1202
+ return await this.commitRecord(userId, input, {
1203
+ name,
1204
+ mimeType,
1205
+ size,
1206
+ sha256,
1207
+ telegramMessageId: Number(stored.messageId)
1208
+ });
1209
+ } catch (error) {
1210
+ await engine.remove({ messageId: String(stored.messageId) }).catch(() => void 0);
1211
+ throw error;
1212
+ }
1213
+ }
1214
+ /** Persists the file (or the new version of a file) for an object already stored. */
1215
+ async commitRecord(userId, input, next) {
1216
+ const { name, mimeType, size, sha256, telegramMessageId } = next;
1217
+ if (input.replaceFileId) {
1218
+ return this.replaceVersion(userId, input.replaceFileId, {
1219
+ name,
1220
+ size,
1221
+ mimeType,
1222
+ sha256,
1223
+ telegramMessageId
1224
+ });
1225
+ }
1226
+ const folderId = input.folderId;
1227
+ if (folderId !== null) {
1228
+ await this.assertFolder(userId, folderId);
1229
+ }
1230
+ const record = await this.files.create({
1231
+ userId,
1232
+ folderId,
1233
+ name,
1234
+ size,
1235
+ mimeType,
1236
+ sha256,
1237
+ telegramMessageId
1238
+ });
1239
+ const version = await this.files.createVersion({
1240
+ fileId: record.id,
1241
+ userId,
1242
+ size,
1243
+ mimeType,
1244
+ sha256,
1245
+ telegramMessageId
1246
+ });
1247
+ const withVersion = await this.files.update(record.id, {
1248
+ currentVersionId: version.id,
1249
+ versionCount: 1
1250
+ });
1251
+ await this.activity.record({
1252
+ userId,
1253
+ action: "upload",
1254
+ resourceType: "file",
1255
+ resourceId: withVersion.id,
1256
+ resourceName: withVersion.name,
1257
+ metadata: { size, mimeType }
1258
+ });
1259
+ return withVersion;
1260
+ }
1261
+ /** Uploads a new version of an existing file and makes it current. */
1262
+ async replaceVersion(userId, fileId, next) {
1263
+ const existing = await this.get(userId, fileId);
1264
+ const version = await this.files.createVersion({
1265
+ fileId,
1266
+ userId,
1267
+ size: next.size,
1268
+ mimeType: next.mimeType,
1269
+ sha256: next.sha256,
1270
+ telegramMessageId: next.telegramMessageId
1271
+ });
1272
+ const count = await this.files.countVersions(fileId);
1273
+ const updated = await this.files.update(fileId, {
1274
+ name: next.name,
1275
+ size: next.size,
1276
+ mimeType: next.mimeType,
1277
+ sha256: next.sha256,
1278
+ telegramMessageId: next.telegramMessageId,
1279
+ currentVersionId: version.id,
1280
+ versionCount: count
1281
+ });
1282
+ await this.activity.record({
1283
+ userId,
1284
+ action: "replace",
1285
+ resourceType: "file",
1286
+ resourceId: fileId,
1287
+ resourceName: updated.name,
1288
+ metadata: { previousSha256: existing.sha256, size: next.size }
1289
+ });
1290
+ return updated;
1291
+ }
1292
+ // ── Reads ────────────────────────────────────────────────────────────────
1293
+ async list(userId, folderId) {
1294
+ if (folderId !== null) await this.assertFolder(userId, folderId);
1295
+ return this.files.listByFolder(userId, folderId);
1296
+ }
1297
+ async query(userId, input, page) {
1298
+ if (input.folderId != null) await this.assertFolder(userId, input.folderId);
1299
+ const query = await resolveItemQuery(this.folders, userId, input);
1300
+ return this.files.query(userId, query, page);
1301
+ }
1302
+ async get(userId, id) {
1303
+ const record = await this.files.findById(id);
1304
+ if (!record || record.userId !== userId) throw new NotFoundError("File not found", "file");
1305
+ return record;
1306
+ }
1307
+ /** Like get(), but rejects trashed files (for download/open). */
1308
+ async getActive(userId, id) {
1309
+ const record = await this.get(userId, id);
1310
+ if (record.deletedAt) throw new NotFoundError("File not found", "file");
1311
+ return record;
1312
+ }
1313
+ async listVersions(userId, id) {
1314
+ await this.get(userId, id);
1315
+ return this.files.listVersions(id);
1316
+ }
1317
+ // ── Mutations ────────────────────────────────────────────────────────────
1318
+ async rename(userId, id, name) {
1319
+ const existing = await this.get(userId, id);
1320
+ const clean = sanitizeFileName(name);
1321
+ if (!clean) throw new ValidationError("Invalid file name");
1322
+ const updated = await this.files.update(id, { name: clean });
1323
+ await this.activity.record({
1324
+ userId,
1325
+ action: "rename",
1326
+ resourceType: "file",
1327
+ resourceId: id,
1328
+ resourceName: clean,
1329
+ metadata: { previousName: existing.name }
1330
+ });
1331
+ return updated;
1332
+ }
1333
+ async move(userId, id, folderId) {
1334
+ const existing = await this.get(userId, id);
1335
+ if (folderId !== null) await this.assertFolder(userId, folderId);
1336
+ const updated = await this.files.update(id, { folderId });
1337
+ await this.activity.record({
1338
+ userId,
1339
+ action: "move",
1340
+ resourceType: "file",
1341
+ resourceId: id,
1342
+ resourceName: updated.name,
1343
+ metadata: { from: existing.folderId, to: folderId }
1344
+ });
1345
+ return updated;
1346
+ }
1347
+ async setStarred(userId, id, starred) {
1348
+ await this.get(userId, id);
1349
+ const updated = await this.files.update(id, { starred });
1350
+ await this.activity.record({
1351
+ userId,
1352
+ action: starred ? "star" : "unstar",
1353
+ resourceType: "file",
1354
+ resourceId: id,
1355
+ resourceName: updated.name
1356
+ });
1357
+ return updated;
1358
+ }
1359
+ /**
1360
+ * Moves a file to Trash (soft delete). The remote object is untouched, so
1361
+ * restore is lossless.
1362
+ */
1363
+ async trash(userId, id) {
1364
+ const record = await this.get(userId, id);
1365
+ if (record.deletedAt) return record;
1366
+ const updated = await this.files.update(id, {
1367
+ deletedAt: /* @__PURE__ */ new Date(),
1368
+ trashBatchId: `solo-${id}`
1369
+ });
1370
+ await this.activity.record({
1371
+ userId,
1372
+ action: "trash",
1373
+ resourceType: "file",
1374
+ resourceId: id,
1375
+ resourceName: updated.name
1376
+ });
1377
+ return updated;
1378
+ }
1379
+ /** Restores a trashed file to its previous folder (or root). */
1380
+ async restore(userId, id) {
1381
+ const record = await this.get(userId, id);
1382
+ let folderId = record.folderId;
1383
+ if (folderId !== null) {
1384
+ const folder = await this.folders.findById(folderId);
1385
+ if (!folder || folder.userId !== userId || folder.deletedAt) folderId = null;
1386
+ }
1387
+ const updated = await this.files.update(id, { deletedAt: null, trashBatchId: null, folderId });
1388
+ await this.activity.record({
1389
+ userId,
1390
+ action: "restore",
1391
+ resourceType: "file",
1392
+ resourceId: id,
1393
+ resourceName: updated.name
1394
+ });
1395
+ return updated;
1396
+ }
1397
+ /**
1398
+ * Permanently deletes a file: the Telegram object is removed first, then the
1399
+ * metadata. If the remote delete fails we keep the metadata so the user can
1400
+ * retry, rather than silently orphaning the storage object.
1401
+ */
1402
+ async deletePermanently(userId, id) {
1403
+ return timedOperation(
1404
+ this.log,
1405
+ { operation: "delete", userId, resourceId: id },
1406
+ () => this.performPermanentDelete(userId, id)
1407
+ );
1408
+ }
1409
+ async performPermanentDelete(userId, id) {
1410
+ const record = await this.get(userId, id);
1411
+ const engine = await this.engineFor(userId);
1412
+ const versions = await this.files.listVersions(id);
1413
+ const messageIds = /* @__PURE__ */ new Set([String(record.telegramMessageId)]);
1414
+ for (const version of versions) messageIds.add(String(version.telegramMessageId));
1415
+ for (const messageId of messageIds) {
1416
+ await engine.remove({ messageId });
1417
+ }
1418
+ await this.files.deleteVersionsByFileIds([id]);
1419
+ await this.files.deleteMany([id]);
1420
+ await this.activity.record({
1421
+ userId,
1422
+ action: "delete",
1423
+ resourceType: "file",
1424
+ resourceId: id,
1425
+ resourceName: record.name
1426
+ });
1427
+ }
1428
+ /**
1429
+ * v0.1-compatible hard delete used by the current API: permanently removes
1430
+ * the file. Retained so existing clients keep working.
1431
+ */
1432
+ async delete(userId, id) {
1433
+ await this.deletePermanently(userId, id);
1434
+ }
1435
+ // ── Download ─────────────────────────────────────────────────────────────
1436
+ async download(userId, id, control = {}) {
1437
+ const record = await this.getActive(userId, id);
1438
+ const engine = await this.engineFor(userId);
1439
+ const data = await engine.download({ messageId: String(record.telegramMessageId) }, control);
1440
+ const integrityVerified = await engine.verifyIntegrity(data, record.sha256);
1441
+ await this.activity.record({
1442
+ userId,
1443
+ action: "download",
1444
+ resourceType: "file",
1445
+ resourceId: id,
1446
+ resourceName: record.name,
1447
+ metadata: { size: record.size, integrityVerified }
1448
+ });
1449
+ return { record, data, integrityVerified };
1450
+ }
1451
+ /**
1452
+ * Streaming download for large files. The caller must consume `stream` to
1453
+ * the end and then await `verified` to learn whether the bytes match the
1454
+ * stored checksum. The download activity is recorded when the stream opens.
1455
+ */
1456
+ async downloadStreamed(userId, id, control = {}) {
1457
+ return timedOperation(
1458
+ this.log,
1459
+ { operation: "download", userId, resourceId: id },
1460
+ () => this.performStreamedDownload(userId, id, control)
1461
+ );
1462
+ }
1463
+ async performStreamedDownload(userId, id, control) {
1464
+ const record = await this.getActive(userId, id);
1465
+ const engine = await this.engineFor(userId);
1466
+ const stream = await engine.downloadStream(
1467
+ { messageId: String(record.telegramMessageId) },
1468
+ record.sha256,
1469
+ control
1470
+ );
1471
+ await this.activity.record({
1472
+ userId,
1473
+ action: "download",
1474
+ resourceType: "file",
1475
+ resourceId: id,
1476
+ resourceName: record.name,
1477
+ metadata: { size: record.size, streamed: true }
1478
+ });
1479
+ return { record, stream };
1480
+ }
1481
+ /** Records an "open" event (the UI opening the details panel, say). */
1482
+ async touch(userId, id, action = "open") {
1483
+ const record = await this.get(userId, id);
1484
+ await this.activity.record({
1485
+ userId,
1486
+ action,
1487
+ resourceType: "file",
1488
+ resourceId: id,
1489
+ resourceName: record.name
1490
+ });
1491
+ }
1492
+ // ── Batch operations ─────────────────────────────────────────────────────
1493
+ /**
1494
+ * Applies an operation to many files, collecting per-item failures instead
1495
+ * of aborting the whole batch.
1496
+ */
1497
+ async batch(userId, ids, operation, options = {}) {
1498
+ const errors = [];
1499
+ let succeeded = 0;
1500
+ if (ids.length === 0) throw new ValidationError("No items selected");
1501
+ if (operation === "move") {
1502
+ const target = options.folderId ?? null;
1503
+ if (target !== null) await this.assertFolder(userId, target);
1504
+ }
1505
+ for (const id of ids) {
1506
+ try {
1507
+ switch (operation) {
1508
+ case "trash":
1509
+ await this.trash(userId, id);
1510
+ break;
1511
+ case "restore":
1512
+ await this.restore(userId, id);
1513
+ break;
1514
+ case "delete":
1515
+ await this.deletePermanently(userId, id);
1516
+ break;
1517
+ case "star":
1518
+ await this.setStarred(userId, id, true);
1519
+ break;
1520
+ case "unstar":
1521
+ await this.setStarred(userId, id, false);
1522
+ break;
1523
+ case "move":
1524
+ await this.move(userId, id, options.folderId ?? null);
1525
+ break;
1526
+ }
1527
+ succeeded += 1;
1528
+ } catch (error) {
1529
+ errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
1530
+ }
1531
+ }
1532
+ return {
1533
+ ...options.operationId ? { operationId: options.operationId } : {},
1534
+ requested: ids.length,
1535
+ succeeded,
1536
+ failed: errors.length,
1537
+ errors
1538
+ };
1539
+ }
1540
+ // ── internals ────────────────────────────────────────────────────────────
1541
+ async assertFolder(userId, folderId) {
1542
+ const folder = await this.folders.findById(folderId);
1543
+ if (!folder || folder.userId !== userId) {
1544
+ throw new NotFoundError("Folder not found", "folder");
1545
+ }
1546
+ if (folder.deletedAt) throw new ConflictError("The target folder is in the Trash");
1547
+ }
1548
+ };
1549
+
1550
+ // ../core/src/services/folder-service.ts
1551
+ var FolderService = class {
1552
+ constructor(folders, files, engineFor, activity = noopActivityRecorder) {
1553
+ this.folders = folders;
1554
+ this.files = files;
1555
+ this.engineFor = engineFor;
1556
+ this.activity = activity;
1557
+ }
1558
+ // ── Create / read ────────────────────────────────────────────────────────
1559
+ async create(userId, input) {
1560
+ const name = sanitizeFileName(input.name);
1561
+ if (!name) throw new ValidationError("Invalid folder name");
1562
+ if (input.parentId !== null) {
1563
+ const parent = await this.get(userId, input.parentId);
1564
+ if (parent.deletedAt) throw new ConflictError("The parent folder is in the Trash");
1565
+ }
1566
+ const folder = await this.folders.create({ userId, parentId: input.parentId, name });
1567
+ await this.activity.record({
1568
+ userId,
1569
+ action: "create_folder",
1570
+ resourceType: "folder",
1571
+ resourceId: folder.id,
1572
+ resourceName: folder.name
1573
+ });
1574
+ return folder;
1575
+ }
1576
+ /** Active folders directly inside a folder (null = root). */
1577
+ async listChildren(userId, parentId) {
1578
+ if (parentId !== null) await this.get(userId, parentId);
1579
+ return this.folders.listChildren(userId, parentId);
1580
+ }
1581
+ async query(userId, input, page) {
1582
+ const query = await resolveItemQuery(this.folders, userId, input);
1583
+ return this.folders.query(userId, query, page);
1584
+ }
1585
+ /** Every active folder for the user (used to build trees). */
1586
+ async tree(userId) {
1587
+ const all = await this.folders.listByUser(userId);
1588
+ return all.filter((folder) => folder.deletedAt === null);
1589
+ }
1590
+ async get(userId, id) {
1591
+ const record = await this.folders.findById(id);
1592
+ if (!record || record.userId !== userId) throw new NotFoundError("Folder not found", "folder");
1593
+ return record;
1594
+ }
1595
+ // ── Mutations ────────────────────────────────────────────────────────────
1596
+ async rename(userId, id, name) {
1597
+ const existing = await this.get(userId, id);
1598
+ const clean = sanitizeFileName(name);
1599
+ if (!clean) throw new ValidationError("Invalid folder name");
1600
+ const updated = await this.folders.update(id, { name: clean });
1601
+ await this.activity.record({
1602
+ userId,
1603
+ action: "rename",
1604
+ resourceType: "folder",
1605
+ resourceId: id,
1606
+ resourceName: clean,
1607
+ metadata: { previousName: existing.name }
1608
+ });
1609
+ return updated;
1610
+ }
1611
+ async move(userId, id, newParentId) {
1612
+ const existing = await this.get(userId, id);
1613
+ if (newParentId === id) throw new ValidationError("Cannot move a folder into itself");
1614
+ if (newParentId !== null) {
1615
+ const target = await this.get(userId, newParentId);
1616
+ if (target.deletedAt) throw new ConflictError("The target folder is in the Trash");
1617
+ let cursor = newParentId;
1618
+ while (cursor !== null) {
1619
+ if (cursor === id) {
1620
+ throw new ValidationError("Cannot move a folder into one of its subfolders");
1621
+ }
1622
+ const parent = await this.folders.findById(cursor);
1623
+ if (!parent || parent.userId !== userId)
1624
+ throw new NotFoundError("Folder not found", "folder");
1625
+ cursor = parent.parentId;
1626
+ }
1627
+ }
1628
+ const updated = await this.folders.moveSafely(id, newParentId);
1629
+ await this.activity.record({
1630
+ userId,
1631
+ action: "move",
1632
+ resourceType: "folder",
1633
+ resourceId: id,
1634
+ resourceName: updated.name,
1635
+ metadata: { from: existing.parentId, to: newParentId }
1636
+ });
1637
+ return updated;
1638
+ }
1639
+ async setStarred(userId, id, starred) {
1640
+ await this.get(userId, id);
1641
+ const updated = await this.folders.update(id, { starred });
1642
+ await this.activity.record({
1643
+ userId,
1644
+ action: starred ? "star" : "unstar",
1645
+ resourceType: "folder",
1646
+ resourceId: id,
1647
+ resourceName: updated.name
1648
+ });
1649
+ return updated;
1650
+ }
1651
+ // ── Trash lifecycle ──────────────────────────────────────────────────────
1652
+ /**
1653
+ * Moves a folder and its entire subtree to the Trash. Nothing is deleted
1654
+ * from Telegram, so restore is lossless.
1655
+ */
1656
+ async trash(userId, id) {
1657
+ const record = await this.get(userId, id);
1658
+ if (record.deletedAt) {
1659
+ return { affectedFolders: 1, affectedFiles: 0 };
1660
+ }
1661
+ const subtree = await this.collectSubtree(userId, id);
1662
+ const fileIds = await this.collectFileIds(userId, subtree);
1663
+ const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1664
+ const stampedAt = /* @__PURE__ */ new Date();
1665
+ await this.folders.updateMany(
1666
+ subtree.map((folder) => folder.id),
1667
+ { deletedAt: stampedAt, trashBatchId: batchId }
1668
+ );
1669
+ await this.files.updateMany(fileIds, { deletedAt: stampedAt, trashBatchId: batchId });
1670
+ await this.activity.record({
1671
+ userId,
1672
+ action: "trash",
1673
+ resourceType: "folder",
1674
+ resourceId: id,
1675
+ resourceName: record.name,
1676
+ metadata: { folders: subtree.length, files: fileIds.length }
1677
+ });
1678
+ return { affectedFolders: subtree.length, affectedFiles: fileIds.length };
1679
+ }
1680
+ /**
1681
+ * Restores a folder subtree. Only nodes trashed at the same instant as the
1682
+ * folder itself are restored, so items the user trashed separately stay put.
1683
+ */
1684
+ async restore(userId, id) {
1685
+ const record = await this.get(userId, id);
1686
+ let parentId = record.parentId;
1687
+ if (parentId !== null) {
1688
+ const parent = await this.folders.findById(parentId);
1689
+ if (!parent || parent.userId !== userId || parent.deletedAt) parentId = null;
1690
+ }
1691
+ const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1692
+ const batchId = record.trashBatchId;
1693
+ const belongsToBatch = (deletedAt, marker) => deletedAt !== null && (batchId !== null ? marker === batchId : record.deletedAt !== null && deletedAt.getTime() === record.deletedAt.getTime());
1694
+ const restorableFolders = subtree.filter(
1695
+ (folder) => belongsToBatch(folder.deletedAt, folder.trashBatchId)
1696
+ );
1697
+ const restorableFolderIds = new Set(restorableFolders.map((folder) => folder.id));
1698
+ const allFiles = await this.files.listByUser(userId);
1699
+ const restorableFiles = allFiles.filter(
1700
+ (file) => file.folderId !== null && restorableFolderIds.has(file.folderId) && belongsToBatch(file.deletedAt, file.trashBatchId)
1701
+ );
1702
+ const restorableFileIds = restorableFiles.map((file) => file.id);
1703
+ if (restorableFolders.length > 0) {
1704
+ await this.folders.updateMany(
1705
+ restorableFolders.map((folder) => folder.id),
1706
+ { deletedAt: null, trashBatchId: null }
1707
+ );
1708
+ await this.folders.update(id, { parentId });
1709
+ }
1710
+ if (restorableFileIds.length > 0) {
1711
+ await this.files.updateMany(restorableFileIds, { deletedAt: null, trashBatchId: null });
1712
+ }
1713
+ await this.activity.record({
1714
+ userId,
1715
+ action: "restore",
1716
+ resourceType: "folder",
1717
+ resourceId: id,
1718
+ resourceName: record.name,
1719
+ metadata: { folders: restorableFolders.length, files: restorableFiles.length }
1720
+ });
1721
+ return { affectedFolders: restorableFolders.length, affectedFiles: restorableFiles.length };
1722
+ }
1723
+ /**
1724
+ * Permanently deletes a folder subtree: remote objects first, then
1725
+ * metadata. Files whose remote delete fails are left in the Trash so the
1726
+ * user can retry rather than losing data silently.
1727
+ */
1728
+ async deletePermanently(userId, id) {
1729
+ await this.get(userId, id);
1730
+ const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
1731
+ const folderIds = subtree.map((folder) => folder.id);
1732
+ const allFiles = await this.files.listByUser(userId);
1733
+ const affected = allFiles.filter(
1734
+ (file) => file.folderId !== null && folderIds.includes(file.folderId)
1735
+ );
1736
+ const engine = await this.engineFor(userId);
1737
+ const removedFileIds = [];
1738
+ for (const file of affected) {
1739
+ try {
1740
+ await engine.remove({ messageId: String(file.telegramMessageId) });
1741
+ removedFileIds.push(file.id);
1742
+ } catch {
1743
+ }
1744
+ }
1745
+ if (removedFileIds.length > 0) {
1746
+ await this.files.deleteVersionsByFileIds(removedFileIds);
1747
+ await this.files.deleteMany(removedFileIds);
1748
+ }
1749
+ const remainingFolderIds = new Set(
1750
+ affected.filter((file) => !removedFileIds.includes(file.id)).map((file) => file.folderId)
1751
+ );
1752
+ const deletable = folderIds.filter((folderId) => !remainingFolderIds.has(folderId));
1753
+ if (deletable.length > 0) {
1754
+ await this.folders.deleteMany(deletable);
1755
+ }
1756
+ await this.activity.record({
1757
+ userId,
1758
+ action: "delete",
1759
+ resourceType: "folder",
1760
+ resourceId: id,
1761
+ resourceName: subtree[0]?.name ?? "folder",
1762
+ metadata: { folders: deletable.length, files: removedFileIds.length }
1763
+ });
1764
+ return { affectedFolders: deletable.length, affectedFiles: removedFileIds.length };
1765
+ }
1766
+ /**
1767
+ * v0.1-compatible recursive delete. Now routes through the v0.2 permanent
1768
+ * delete so remote cleanup and orphan prevention are consistent.
1769
+ */
1770
+ async delete(userId, id) {
1771
+ return this.deletePermanently(userId, id);
1772
+ }
1773
+ // ── internals ────────────────────────────────────────────────────────────
1774
+ /** The folder plus every descendant (BFS), optionally including trashed. */
1775
+ async collectSubtree(userId, rootId, options = {}) {
1776
+ const all = await this.folders.listByUser(userId);
1777
+ const childrenOf = /* @__PURE__ */ new Map();
1778
+ for (const folder of all) {
1779
+ const list = childrenOf.get(folder.parentId) ?? [];
1780
+ list.push(folder);
1781
+ childrenOf.set(folder.parentId, list);
1782
+ }
1783
+ const root = all.find((folder) => folder.id === rootId);
1784
+ if (!root) throw new NotFoundError("Folder not found", "folder");
1785
+ const collected = [root];
1786
+ const queue = [rootId];
1787
+ while (queue.length > 0) {
1788
+ const current = queue.shift();
1789
+ for (const child of childrenOf.get(current) ?? []) {
1790
+ if (!options.includeTrashed && child.deletedAt) continue;
1791
+ collected.push(child);
1792
+ queue.push(child.id);
1793
+ }
1794
+ }
1795
+ return collected;
1796
+ }
1797
+ /** Ids of files inside the given folders that are not already trashed. */
1798
+ async collectFileIds(userId, folders) {
1799
+ const ids = new Set(folders.map((folder) => folder.id));
1800
+ const allFiles = await this.files.listByUser(userId);
1801
+ return allFiles.filter((file) => file.deletedAt === null && file.folderId !== null && ids.has(file.folderId)).map((file) => file.id);
1802
+ }
1803
+ };
1804
+
1805
+ // ../core/src/services/search-service.ts
1806
+ var MAX_QUERY_LENGTH = 200;
1807
+ var SearchService = class {
1808
+ constructor(files, folders) {
1809
+ this.files = files;
1810
+ this.folders = folders;
1811
+ }
1812
+ async search(userId, rawQuery, page, overrides = {}) {
1813
+ const trimmed = rawQuery.trim();
1814
+ if (!trimmed) throw new ValidationError("Search query must not be empty");
1815
+ if (trimmed.length > MAX_QUERY_LENGTH) {
1816
+ throw new ValidationError(`Search query must be at most ${MAX_QUERY_LENGTH} characters`);
1817
+ }
1818
+ const { query } = parseSearchQuery(trimmed, overrides);
1819
+ if (query.status === void 0) query.status = "all";
1820
+ const itemQuery = await resolveItemQuery(this.folders, userId, query);
1821
+ const [filePage, folderPage] = await Promise.all([
1822
+ this.files.query(userId, itemQuery, page),
1823
+ this.folders.query(userId, itemQuery, page)
1824
+ ]);
1825
+ const total = filePage.total + folderPage.total;
1826
+ return {
1827
+ files: filePage.items,
1828
+ folders: folderPage.items,
1829
+ pagination: paginationMeta(page.page, page.limit, total)
1830
+ };
1831
+ }
1832
+ /** Convenience wrapper returning the shared DTO shape. */
1833
+ async searchDTO(userId, rawQuery, page) {
1834
+ return this.search(userId, rawQuery, page);
1835
+ }
1836
+ };
1837
+
1838
+ // ../core/src/services/stats-service.ts
1839
+ var LARGEST_FILES = 10;
1840
+ var StatsService = class {
1841
+ constructor(files, folders) {
1842
+ this.files = files;
1843
+ this.folders = folders;
1844
+ }
1845
+ async stats(userId, quotaBytes = null) {
1846
+ const [counters, allFiles] = await Promise.all([
1847
+ this.files.statsByUser(userId),
1848
+ // Metadata-only aggregation; v0.2 datasets fit comfortably in memory.
1849
+ this.files.listByUser(userId)
1850
+ ]);
1851
+ const active = allFiles.filter((file) => file.deletedAt === null);
1852
+ const categoryBytes = /* @__PURE__ */ new Map();
1853
+ for (const file of active) {
1854
+ const category = fileCategory(file.mimeType);
1855
+ const bucket = categoryBytes.get(category) ?? { bytes: 0, count: 0 };
1856
+ bucket.bytes += file.size;
1857
+ bucket.count += 1;
1858
+ categoryBytes.set(category, bucket);
1859
+ }
1860
+ const byType = [...categoryBytes.entries()].map(([category, bucket]) => ({ category, bytes: bucket.bytes, count: bucket.count })).sort((a, b) => b.bytes - a.bytes);
1861
+ const largestFiles = [...active].sort((a, b) => b.size - a.size).slice(0, LARGEST_FILES);
1862
+ return {
1863
+ fileCount: counters.fileCount,
1864
+ folderCount: counters.folderCount,
1865
+ totalBytes: counters.totalBytes,
1866
+ trashBytes: counters.trashBytes,
1867
+ trashFileCount: counters.trashFileCount,
1868
+ starredCount: counters.starredCount,
1869
+ byType,
1870
+ largestFiles,
1871
+ quotaBytes
1872
+ };
1873
+ }
1874
+ };
1875
+
1876
+ // ../core/src/services/trash-service.ts
1877
+ var TrashService = class {
1878
+ constructor(files, folders, engineFor) {
1879
+ this.files = files;
1880
+ this.folders = folders;
1881
+ this.engineFor = engineFor;
1882
+ }
1883
+ async list(userId, page, extra = {}) {
1884
+ const itemQuery = await resolveItemQuery(this.folders, userId, {
1885
+ ...extra,
1886
+ status: "trashed",
1887
+ folderId: void 0
1888
+ });
1889
+ const [filePage, folderPage] = await Promise.all([
1890
+ this.files.query(userId, itemQuery, page),
1891
+ this.folders.query(userId, itemQuery, page)
1892
+ ]);
1893
+ const total = filePage.total + folderPage.total;
1894
+ return {
1895
+ files: filePage.items,
1896
+ folders: folderPage.items,
1897
+ pagination: paginationMeta(page.page, page.limit, total)
1898
+ };
1899
+ }
1900
+ /**
1901
+ * Permanently removes everything in the Trash for a user. Folders are
1902
+ * processed root-first so removing a parent also removes its contents.
1903
+ */
1904
+ async empty(userId) {
1905
+ const engine = await this.engineFor(userId);
1906
+ const allFiles = await this.files.listByUser(userId);
1907
+ const trashedFiles = allFiles.filter((file) => file.deletedAt !== null);
1908
+ let deletedFiles = 0;
1909
+ let failedFiles = 0;
1910
+ const deletedFileIds = [];
1911
+ for (const file of trashedFiles) {
1912
+ try {
1913
+ await engine.remove({ messageId: String(file.telegramMessageId) });
1914
+ deletedFileIds.push(file.id);
1915
+ deletedFiles += 1;
1916
+ } catch {
1917
+ failedFiles += 1;
1918
+ }
1919
+ }
1920
+ if (deletedFileIds.length > 0) {
1921
+ await this.files.deleteVersionsByFileIds(deletedFileIds);
1922
+ await this.files.deleteMany(deletedFileIds);
1923
+ }
1924
+ const allFolders = await this.folders.listByUser(userId);
1925
+ const trashedFolders = allFolders.filter((folder) => folder.deletedAt !== null);
1926
+ const depth = (folder) => {
1927
+ let level = 0;
1928
+ let cursor = folder.parentId;
1929
+ const seen = /* @__PURE__ */ new Set();
1930
+ while (cursor !== null && !seen.has(cursor)) {
1931
+ seen.add(cursor);
1932
+ const parent = allFolders.find((candidate) => candidate.id === cursor);
1933
+ if (!parent) break;
1934
+ level += 1;
1935
+ cursor = parent.parentId;
1936
+ }
1937
+ return level;
1938
+ };
1939
+ trashedFolders.sort((a, b) => depth(a) - depth(b));
1940
+ let deletedFolders = 0;
1941
+ for (const folder of trashedFolders) {
1942
+ const stillHasFiles = await this.files.query(
1943
+ userId,
1944
+ { folderId: folder.id, trashed: true },
1945
+ { page: 1, limit: 1 }
1946
+ );
1947
+ if (stillHasFiles.total > 0) continue;
1948
+ await this.folders.deleteMany([folder.id]);
1949
+ deletedFolders += 1;
1950
+ }
1951
+ return { deletedFiles, deletedFolders, failedFiles };
1952
+ }
1953
+ /** Restores a single trashed item (used by the batch restore endpoint). */
1954
+ async verifyTrashedFile(userId, id) {
1955
+ const file = await this.files.findById(id);
1956
+ if (!file || file.userId !== userId || file.deletedAt === null) {
1957
+ throw new NotFoundError("File is not in the Trash", "file");
1958
+ }
1959
+ return file;
1960
+ }
1961
+ };
1962
+
1963
+ // ../core/src/services/integrity-service.ts
1964
+ var IntegrityService = class {
1965
+ constructor(files, engineFor) {
1966
+ this.files = files;
1967
+ this.engineFor = engineFor;
1968
+ }
1969
+ async check(userId, options = {}) {
1970
+ const startedAt = Date.now();
1971
+ const all = await this.files.listByUser(userId);
1972
+ const active = all.filter((file) => file.deletedAt === null);
1973
+ const targets = options.limit ? active.slice(0, options.limit) : active;
1974
+ const engine = await this.engineFor(userId);
1975
+ const issues = [];
1976
+ let healthy = 0;
1977
+ const versionsByFile = await this.files.listVersionsForFiles(targets.map((file) => file.id));
1978
+ for (const file of targets) {
1979
+ const issue = await this.inspect(file, options.deep ?? false, engine);
1980
+ if (issue) issues.push(issue);
1981
+ else healthy += 1;
1982
+ for (const version of versionsByFile.get(file.id) ?? []) {
1983
+ if (version.telegramMessageId === file.telegramMessageId) continue;
1984
+ const versionIssue = await this.inspectObject(
1985
+ {
1986
+ id: file.id,
1987
+ name: file.name,
1988
+ telegramMessageId: version.telegramMessageId,
1989
+ size: version.size,
1990
+ sha256: version.sha256
1991
+ },
1992
+ options.deep ?? false,
1993
+ engine,
1994
+ version.id
1995
+ );
1996
+ if (versionIssue) issues.push(versionIssue);
1997
+ else healthy += 1;
1998
+ }
1999
+ }
2000
+ const missing = issues.filter((issue) => issue.kind === "missing").length;
2001
+ const inconsistent = issues.filter(
2002
+ (issue) => issue.kind === "size_mismatch" || issue.kind === "hash_mismatch"
2003
+ ).length;
2004
+ const unreadable = issues.filter((issue) => issue.kind === "unreadable").length;
2005
+ return {
2006
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2007
+ filesChecked: targets.length,
2008
+ healthy,
2009
+ missing,
2010
+ inconsistent,
2011
+ unreadable,
2012
+ issues,
2013
+ durationMs: Date.now() - startedAt
2014
+ };
2015
+ }
2016
+ async inspect(file, deep, engine) {
2017
+ return this.inspectObject(
2018
+ {
2019
+ id: file.id,
2020
+ name: file.name,
2021
+ telegramMessageId: file.telegramMessageId,
2022
+ size: file.size,
2023
+ sha256: file.sha256
2024
+ },
2025
+ deep,
2026
+ engine,
2027
+ void 0
2028
+ );
2029
+ }
2030
+ async inspectObject(target, deep, engine, versionId) {
2031
+ const ref = { messageId: String(target.telegramMessageId) };
2032
+ const withVersion = (issue) => versionId ? { ...issue, versionId } : issue;
2033
+ let stat;
2034
+ try {
2035
+ stat = await engine.stat(ref);
2036
+ } catch (error) {
2037
+ return withVersion({
2038
+ fileId: target.id,
2039
+ name: target.name,
2040
+ kind: "unreadable",
2041
+ detail: error instanceof Error ? error.message : "Storage lookup failed"
2042
+ });
2043
+ }
2044
+ if (!stat) {
2045
+ return withVersion({
2046
+ fileId: target.id,
2047
+ name: target.name,
2048
+ kind: "missing",
2049
+ detail: "No message with this id exists in the storage channel"
2050
+ });
2051
+ }
2052
+ if (stat.size !== target.size) {
2053
+ return withVersion({
2054
+ fileId: target.id,
2055
+ name: target.name,
2056
+ kind: "size_mismatch",
2057
+ detail: `metadata=${target.size} storage=${stat.size}`
2058
+ });
2059
+ }
2060
+ if (deep) {
2061
+ try {
2062
+ const stream = await engine.downloadStream(ref, target.sha256);
2063
+ for await (const _chunk of stream) {
2064
+ }
2065
+ } catch (error) {
2066
+ if (error instanceof IntegrityCheckError) {
2067
+ return withVersion({
2068
+ fileId: target.id,
2069
+ name: target.name,
2070
+ kind: "hash_mismatch",
2071
+ detail: "Downloaded bytes do not match the stored SHA-256"
2072
+ });
2073
+ }
2074
+ return withVersion({
2075
+ fileId: target.id,
2076
+ name: target.name,
2077
+ kind: "unreadable",
2078
+ detail: error instanceof Error ? error.message : "Download failed"
2079
+ });
2080
+ }
2081
+ }
2082
+ return null;
2083
+ }
2084
+ };
2085
+
2086
+ // ../core/src/services/recent-service.ts
2087
+ var RECENT_ACTIONS = [
2088
+ "upload",
2089
+ "download",
2090
+ "open",
2091
+ "rename",
2092
+ "move",
2093
+ "replace"
2094
+ ];
2095
+ var RecentService = class {
2096
+ constructor(activity, files) {
2097
+ this.activity = activity;
2098
+ this.files = files;
2099
+ }
2100
+ async list(userId, page) {
2101
+ const window = Math.min(page.page * page.limit * 3, 500);
2102
+ const events = await this.activity.recentFiles(userId, RECENT_ACTIONS, window);
2103
+ const seen = /* @__PURE__ */ new Set();
2104
+ for (const event of events) {
2105
+ seen.add(event.resourceId);
2106
+ }
2107
+ const fileIds = [...seen];
2108
+ const records = fileIds.length > 0 ? await this.files.listByIds(userId, fileIds) : [];
2109
+ const byId = new Map(records.map((record) => [record.id, record]));
2110
+ const items = [];
2111
+ for (const fileId of fileIds) {
2112
+ const record = byId.get(fileId);
2113
+ if (!record || record.deletedAt !== null) continue;
2114
+ const event = events.find((candidate) => candidate.resourceId === fileId);
2115
+ items.push({
2116
+ file: {
2117
+ id: record.id,
2118
+ name: record.name,
2119
+ size: record.size,
2120
+ mimeType: record.mimeType,
2121
+ sha256: record.sha256,
2122
+ folderId: record.folderId,
2123
+ starred: record.starred,
2124
+ trashed: false,
2125
+ deletedAt: null,
2126
+ versionCount: record.versionCount,
2127
+ currentVersionId: record.currentVersionId,
2128
+ createdAt: record.createdAt.toISOString(),
2129
+ updatedAt: record.updatedAt.toISOString()
2130
+ },
2131
+ lastAction: event.action,
2132
+ lastActionAt: event.createdAt.toISOString()
2133
+ });
2134
+ }
2135
+ const start = (page.page - 1) * page.limit;
2136
+ const pageItems = items.slice(start, start + page.limit);
2137
+ return {
2138
+ items: pageItems,
2139
+ pagination: paginationMeta(page.page, page.limit, items.length)
2140
+ };
2141
+ }
2142
+ };
2143
+
2144
+ // ../core/src/utils/spool.ts
2145
+ import { createHash as createHash4 } from "node:crypto";
2146
+ import { createWriteStream } from "node:fs";
2147
+ import { mkdtemp, rm } from "node:fs/promises";
2148
+ import { tmpdir } from "node:os";
2149
+ import { join } from "node:path";
2150
+ import { Transform as Transform2 } from "node:stream";
2151
+ import { pipeline } from "node:stream/promises";
2152
+ var SPOOL_PREFIX = "omnicloud-upload-";
2153
+ async function spoolToFile(source, maxBytes) {
2154
+ const dir = await mkdtemp(join(tmpdir(), `${SPOOL_PREFIX}${process.pid}-`));
2155
+ const path = join(dir, "payload");
2156
+ const discard = () => rm(dir, { recursive: true, force: true });
2157
+ const hash = createHash4("sha256");
2158
+ let size = 0;
2159
+ const meter = new Transform2({
2160
+ transform(chunk, _encoding, callback) {
2161
+ size += chunk.byteLength;
2162
+ if (size > maxBytes) {
2163
+ callback(
2164
+ new PayloadTooLargeError(
2165
+ `Files larger than ${Math.floor(maxBytes / (1024 * 1024))} MB are not supported`
2166
+ )
2167
+ );
2168
+ return;
2169
+ }
2170
+ hash.update(chunk);
2171
+ callback(null, chunk);
2172
+ }
2173
+ });
2174
+ try {
2175
+ await pipeline(source, meter, createWriteStream(path));
2176
+ } catch (error) {
2177
+ await discard();
2178
+ throw error;
2179
+ }
2180
+ return { path, size, sha256: hash.digest("hex"), discard };
2181
+ }
2182
+ function isAlive(pid) {
2183
+ try {
2184
+ process.kill(pid, 0);
2185
+ return true;
2186
+ } catch (error) {
2187
+ return error.code === "EPERM";
2188
+ }
2189
+ }
2190
+ async function sweepStaleSpools() {
2191
+ const { readdir } = await import("node:fs/promises");
2192
+ const entries = await readdir(tmpdir()).catch(() => []);
2193
+ let removed = 0;
2194
+ for (const name of entries) {
2195
+ if (!name.startsWith(SPOOL_PREFIX)) continue;
2196
+ const pid = Number(name.slice(SPOOL_PREFIX.length).split("-")[0]);
2197
+ if (!Number.isInteger(pid) || pid <= 0 || isAlive(pid)) continue;
2198
+ await rm(join(tmpdir(), name), { recursive: true, force: true });
2199
+ removed += 1;
2200
+ }
2201
+ return removed;
2202
+ }
2203
+
2204
+ // ../core/src/utils/secret-box.ts
2205
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2, scryptSync } from "node:crypto";
2206
+ var ALGORITHM = "aes-256-gcm";
2207
+ var IV_BYTES = 12;
2208
+ var KEY_BYTES = 32;
2209
+ var SecretBox = class {
2210
+ keys;
2211
+ currentVersion;
2212
+ /**
2213
+ * @param keys key versions available for decryption; the highest version is
2214
+ * used for new seals.
2215
+ */
2216
+ constructor(keys) {
2217
+ if (keys.length === 0) {
2218
+ throw new ValidationError("At least one encryption key is required");
2219
+ }
2220
+ this.keys = /* @__PURE__ */ new Map();
2221
+ for (const { version, key } of keys) {
2222
+ if (key.byteLength !== KEY_BYTES) {
2223
+ throw new ValidationError(`Encryption key version ${version} must be 32 bytes`);
2224
+ }
2225
+ this.keys.set(version, key);
2226
+ }
2227
+ this.currentVersion = Math.max(...this.keys.keys());
2228
+ }
2229
+ seal(plaintext) {
2230
+ const key = this.keys.get(this.currentVersion);
2231
+ const iv = randomBytes2(IV_BYTES);
2232
+ const cipher = createCipheriv(ALGORITHM, key, iv);
2233
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
2234
+ const tag = cipher.getAuthTag();
2235
+ return [
2236
+ `v${this.currentVersion}`,
2237
+ iv.toString("base64url"),
2238
+ tag.toString("base64url"),
2239
+ ciphertext.toString("base64url")
2240
+ ].join(":");
2241
+ }
2242
+ open(envelope) {
2243
+ const parts = envelope.split(":");
2244
+ if (parts.length !== 4 || !parts[0].startsWith("v")) {
2245
+ throw new SecretBoxError("Malformed sealed value");
2246
+ }
2247
+ const version = Number(parts[0].slice(1));
2248
+ const key = this.keys.get(version);
2249
+ if (!key) throw new SecretBoxError(`No encryption key for version ${version}`);
2250
+ try {
2251
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(parts[1], "base64url"));
2252
+ decipher.setAuthTag(Buffer.from(parts[2], "base64url"));
2253
+ const plaintext = Buffer.concat([
2254
+ decipher.update(Buffer.from(parts[3], "base64url")),
2255
+ decipher.final()
2256
+ ]);
2257
+ return plaintext.toString("utf8");
2258
+ } catch {
2259
+ throw new SecretBoxError("Sealed value failed authentication");
2260
+ }
2261
+ }
2262
+ get currentKeyVersion() {
2263
+ return this.currentVersion;
2264
+ }
2265
+ };
2266
+ var SecretBoxError = class extends Error {
2267
+ constructor(message) {
2268
+ super(message);
2269
+ this.name = "SecretBoxError";
2270
+ }
2271
+ };
2272
+ var KDF_SALT = "omnicloud-secret-box-v1";
2273
+ function deriveKey(secret) {
2274
+ if (/^[0-9a-f]{64}$/i.test(secret)) return Buffer.from(secret, "hex");
2275
+ if (secret.length < 32) {
2276
+ throw new ValidationError(
2277
+ "The encryption key must be 64 hex characters or at least 32 characters"
2278
+ );
2279
+ }
2280
+ return scryptSync(secret, KDF_SALT, KEY_BYTES);
2281
+ }
2282
+
2283
+ // ../../apps/api/src/config.ts
2284
+ function optional(env, name, fallback) {
2285
+ const value = env[name];
2286
+ return value && value.trim() !== "" ? value.trim() : fallback;
2287
+ }
2288
+ function int(env, name, fallback) {
2289
+ const raw = env[name];
2290
+ if (!raw || raw.trim() === "") return fallback;
2291
+ const value = Number(raw);
2292
+ if (!Number.isInteger(value) || value <= 0) {
2293
+ throw new Error(`${name} must be a positive integer`);
2294
+ }
2295
+ return value;
2296
+ }
2297
+ function bool(env, name, fallback) {
2298
+ const raw = env[name];
2299
+ if (!raw || raw.trim() === "") return fallback;
2300
+ return raw.trim().toLowerCase() === "true";
2301
+ }
2302
+ function loadConfig(env = process.env) {
2303
+ const telegramApiId = int(env, "TELEGRAM_API_ID", 0);
2304
+ const telegramApiHash = optional(env, "TELEGRAM_API_HASH", "");
2305
+ if (telegramApiId === 0 || !telegramApiHash) {
2306
+ throw new Error(
2307
+ "TELEGRAM_API_ID and TELEGRAM_API_HASH are required. Create an application at https://my.telegram.org (see .env.example)."
2308
+ );
2309
+ }
2310
+ const nodeEnv = optional(env, "NODE_ENV", "development");
2311
+ const sessionSecret = optional(env, "SESSION_SECRET", "");
2312
+ const encryptionKey = optional(env, "OMNICLOUD_ENCRYPTION_KEY", "");
2313
+ if (!encryptionKey && nodeEnv === "production") {
2314
+ throw new Error("OMNICLOUD_ENCRYPTION_KEY must be set when NODE_ENV=production");
2315
+ }
2316
+ const quotaGb = Number(optional(env, "STORAGE_QUOTA_GB", "0"));
2317
+ return {
2318
+ encryptionKey: encryptionKey || null,
2319
+ port: int(env, "PORT", 4e3),
2320
+ host: optional(env, "HOST", "0.0.0.0"),
2321
+ logLevel: optional(env, "LOG_LEVEL", "info"),
2322
+ nodeEnv,
2323
+ sessionSecret: sessionSecret || "development-only-secret-change-me",
2324
+ cookieSecure: bool(env, "COOKIE_SECURE", false),
2325
+ telegramApiId,
2326
+ telegramApiHash,
2327
+ maxUploadBytes: int(env, "MAX_UPLOAD_MB", 256) * 1024 * 1024,
2328
+ webDistDir: optional(env, "WEB_DIST_DIR", "") || null,
2329
+ allowedOrigins: optional(env, "ALLOWED_ORIGINS", "").split(",").map((origin) => origin.trim()).filter(Boolean),
2330
+ quotaBytes: Number.isFinite(quotaGb) && quotaGb > 0 ? Math.round(quotaGb * 1024 ** 3) : null,
2331
+ trustProxy: bool(env, "TRUST_PROXY", false)
2332
+ };
2333
+ }
2334
+
2335
+ // ../telegram/src/client.ts
2336
+ import { Api, TelegramClient } from "telegram";
2337
+ import { StringSession } from "telegram/sessions/index.js";
2338
+
2339
+ // ../telegram/src/errors.ts
2340
+ import { RPCError } from "telegram/errors/index.js";
2341
+ function isTelegramRpcError(error) {
2342
+ return error instanceof RPCError;
2343
+ }
2344
+ function isPasswordRequiredError(error) {
2345
+ return isTelegramRpcError(error) && error.errorMessage === "SESSION_PASSWORD_NEEDED";
2346
+ }
2347
+ function mapTelegramError(error, fallbackMessage) {
2348
+ if (error instanceof DomainError) return error;
2349
+ if (isTelegramRpcError(error)) {
2350
+ const msg = error.errorMessage ?? "";
2351
+ if (msg.startsWith("FLOOD_WAIT_")) {
2352
+ const seconds = Number(msg.slice("FLOOD_WAIT_".length)) || 60;
2353
+ return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`, {
2354
+ retryAfterSeconds: seconds
2355
+ });
2356
+ }
2357
+ switch (msg) {
2358
+ case "PHONE_NUMBER_INVALID":
2359
+ return new ValidationError("This phone number is not valid");
2360
+ case "PHONE_NUMBER_UNOCCUPIED":
2361
+ return new ValidationError("No Telegram account exists for this phone number");
2362
+ case "PHONE_CODE_INVALID":
2363
+ return new ValidationError("Invalid confirmation code");
2364
+ case "PHONE_CODE_EXPIRED":
2365
+ return new ValidationError("The confirmation code has expired \u2014 request a new one");
2366
+ case "PHONE_CODE_EMPTY":
2367
+ return new ValidationError("Please enter the confirmation code");
2368
+ case "SESSION_PASSWORD_NEEDED":
2369
+ return new UnauthorizedError("Two-factor authentication is enabled on this account");
2370
+ case "PASSWORD_HASH_INVALID":
2371
+ return new ValidationError("Incorrect two-factor password");
2372
+ case "PHONE_NUMBER_BANNED":
2373
+ return new ForbiddenError("This phone number is banned from Telegram");
2374
+ case "AUTH_KEY_UNREGISTERED":
2375
+ case "AUTH_KEY_DUPLICATED":
2376
+ case "SESSION_REVOKED":
2377
+ case "SESSION_EXPIRED":
2378
+ case "USER_DEACTIVATED":
2379
+ case "USER_DEACTIVATED_BAN":
2380
+ return new TelegramAuthRequiredError();
2381
+ case "CHANNEL_PRIVATE":
2382
+ case "CHANNEL_INVALID":
2383
+ case "CHAT_ID_INVALID":
2384
+ return new StorageNotInitializedError();
2385
+ case "MSG_ID_INVALID":
2386
+ case "MESSAGE_ID_INVALID":
2387
+ return new TelegramFileNotFoundError();
2388
+ case "FILE_REFERENCE_EXPIRED":
2389
+ case "FILE_REFERENCE_INVALID":
2390
+ return new TelegramFileNotFoundError(
2391
+ "The Telegram file reference expired \u2014 re-upload the file to refresh it"
2392
+ );
2393
+ default:
2394
+ return new TelegramConnectionError(fallbackMessage, {
2395
+ code: error.code,
2396
+ telegramError: msg
2397
+ });
2398
+ }
2399
+ }
2400
+ const detail = error instanceof Error ? error.message : String(error);
2401
+ if (/timeout|ECONNRESET|ENOTFOUND|socket|network|connect/i.test(detail)) {
2402
+ return new TelegramConnectionError(`${fallbackMessage} (network error)`, error);
2403
+ }
2404
+ return new TelegramConnectionError(
2405
+ detail ? `${fallbackMessage}: ${detail}` : fallbackMessage,
2406
+ error
2407
+ );
2408
+ }
2409
+
2410
+ // ../telegram/src/client.ts
2411
+ var HEALTH_TTL_MS = 3e4;
2412
+ var RECONNECT_COOLDOWN_MS = 5e3;
2413
+ var TelegramClientManager = class {
2414
+ constructor(credentials, sessions) {
2415
+ this.credentials = credentials;
2416
+ this.sessions = sessions;
2417
+ }
2418
+ connections = /* @__PURE__ */ new Map();
2419
+ lastReconnectAt = /* @__PURE__ */ new Map();
2420
+ /** Returns a connected client, (re)connecting when needed. */
2421
+ async getForUser(userId) {
2422
+ const existing = this.connections.get(userId);
2423
+ if (existing) {
2424
+ if (existing.state === "CONNECTED" && this.isConnected(existing.client)) {
2425
+ return existing.client;
2426
+ }
2427
+ if (existing.pending) return existing.pending;
2428
+ if (existing.state === "AUTH_REQUIRED") {
2429
+ throw new TelegramAuthRequiredError();
2430
+ }
2431
+ return this.reconnect(userId, existing);
2432
+ }
2433
+ return this.connectUser(userId);
2434
+ }
2435
+ /** Connection status for health reporting and the storage dashboard. */
2436
+ statusForUser(userId) {
2437
+ const connection = this.connections.get(userId);
2438
+ if (!connection) {
2439
+ return { state: "DISCONNECTED", lastError: null, lastCheckAt: null, healthy: null };
2440
+ }
2441
+ return {
2442
+ state: connection.state,
2443
+ lastError: connection.lastError,
2444
+ lastCheckAt: connection.lastCheckAt ? new Date(connection.lastCheckAt) : null,
2445
+ healthy: connection.lastCheckOk
2446
+ };
2447
+ }
2448
+ /**
2449
+ * Probes a user's connection. Cheap by default (connection state only);
2450
+ * `deep` performs a real Telegram round-trip to validate the session.
2451
+ */
2452
+ async healthCheck(userId, deep = false) {
2453
+ const connection = this.connections.get(userId);
2454
+ if (!connection) {
2455
+ try {
2456
+ await this.getForUser(userId);
2457
+ } catch (error) {
2458
+ const state = error instanceof TelegramAuthRequiredError ? "AUTH_REQUIRED" : "ERROR";
2459
+ const current = this.connections.get(userId);
2460
+ if (current) current.state = state;
2461
+ return {
2462
+ healthy: false,
2463
+ latencyMs: null,
2464
+ message: error instanceof Error ? error.message : "Telegram is not connected",
2465
+ targetTitle: null
2466
+ };
2467
+ }
2468
+ }
2469
+ const active = this.connections.get(userId);
2470
+ if (!deep && active.lastCheckAt && Date.now() - active.lastCheckAt < HEALTH_TTL_MS) {
2471
+ return {
2472
+ healthy: active.lastCheckOk ?? false,
2473
+ latencyMs: null,
2474
+ message: active.lastError,
2475
+ targetTitle: null
2476
+ };
2477
+ }
2478
+ const startedAt = Date.now();
2479
+ try {
2480
+ await active.client.getMe();
2481
+ active.lastCheckAt = Date.now();
2482
+ active.lastCheckOk = true;
2483
+ active.lastError = null;
2484
+ active.state = "CONNECTED";
2485
+ return {
2486
+ healthy: true,
2487
+ latencyMs: Date.now() - startedAt,
2488
+ message: null,
2489
+ targetTitle: null
2490
+ };
2491
+ } catch (error) {
2492
+ const mapped = mapTelegramError(error, "Telegram health check failed");
2493
+ active.lastCheckAt = Date.now();
2494
+ active.lastCheckOk = false;
2495
+ active.lastError = mapped.message;
2496
+ active.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2497
+ return {
2498
+ healthy: false,
2499
+ latencyMs: Date.now() - startedAt,
2500
+ message: mapped.message,
2501
+ targetTitle: null
2502
+ };
2503
+ }
2504
+ }
2505
+ /** Registers an already-authenticated client (from the login flow). */
2506
+ cacheClient(userId, client) {
2507
+ this.connections.set(userId, {
2508
+ client,
2509
+ state: "CONNECTED",
2510
+ pending: null,
2511
+ lastError: null,
2512
+ lastCheckAt: Date.now(),
2513
+ lastCheckOk: true
2514
+ });
2515
+ }
2516
+ /** Drops a user's connection (e.g. after logout) without touching storage. */
2517
+ async disconnectUser(userId) {
2518
+ const connection = this.connections.get(userId);
2519
+ if (!connection) return;
2520
+ this.connections.delete(userId);
2521
+ try {
2522
+ await connection.client.disconnect();
2523
+ } catch {
2524
+ }
2525
+ }
2526
+ async disconnectAll() {
2527
+ const ids = [...this.connections.keys()];
2528
+ await Promise.all(ids.map((userId) => this.disconnectUser(userId)));
2529
+ }
2530
+ // ── internals ────────────────────────────────────────────────────────────
2531
+ isConnected(client) {
2532
+ try {
2533
+ return client.connected === true;
2534
+ } catch {
2535
+ return false;
2536
+ }
2537
+ }
2538
+ async connectUser(userId) {
2539
+ const session = await this.sessions.get(userId);
2540
+ if (!session) {
2541
+ throw new TelegramAuthRequiredError("Telegram account is not connected");
2542
+ }
2543
+ const connection = {
2544
+ client: null,
2545
+ state: "CONNECTING",
2546
+ pending: null,
2547
+ lastError: null,
2548
+ lastCheckAt: null,
2549
+ lastCheckOk: null
2550
+ };
2551
+ const attempt = this.establish(session.stringSession).then((client) => {
2552
+ connection.client = client;
2553
+ connection.state = "CONNECTED";
2554
+ connection.pending = null;
2555
+ connection.lastError = null;
2556
+ return client;
2557
+ });
2558
+ connection.pending = attempt;
2559
+ this.connections.set(userId, connection);
2560
+ try {
2561
+ return await attempt;
2562
+ } catch (error) {
2563
+ connection.pending = null;
2564
+ const mapped = mapTelegramError(error, "Could not connect to Telegram");
2565
+ connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2566
+ connection.lastError = mapped.message;
2567
+ throw mapped;
2568
+ }
2569
+ }
2570
+ async reconnect(userId, connection) {
2571
+ const lastAttempt = this.lastReconnectAt.get(userId) ?? 0;
2572
+ if (Date.now() - lastAttempt < RECONNECT_COOLDOWN_MS && connection.pending) {
2573
+ return connection.pending;
2574
+ }
2575
+ this.lastReconnectAt.set(userId, Date.now());
2576
+ connection.state = "RECONNECTING";
2577
+ const session = await this.sessions.get(userId);
2578
+ if (!session) {
2579
+ connection.state = "AUTH_REQUIRED";
2580
+ throw new TelegramAuthRequiredError("Telegram account is not connected");
2581
+ }
2582
+ const attempt = (async () => {
2583
+ try {
2584
+ await connection.client.disconnect().catch(() => void 0);
2585
+ } catch {
2586
+ }
2587
+ return this.establish(session.stringSession);
2588
+ })().then((client) => {
2589
+ connection.client = client;
2590
+ connection.state = "CONNECTED";
2591
+ connection.pending = null;
2592
+ connection.lastError = null;
2593
+ return client;
2594
+ }).catch((error) => {
2595
+ connection.pending = null;
2596
+ const mapped = mapTelegramError(error, "Could not reconnect to Telegram");
2597
+ connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
2598
+ connection.lastError = mapped.message;
2599
+ throw mapped;
2600
+ });
2601
+ connection.pending = attempt;
2602
+ return attempt;
2603
+ }
2604
+ async establish(sessionString) {
2605
+ const client = new TelegramClient(
2606
+ new StringSession(sessionString),
2607
+ this.credentials.apiId,
2608
+ this.credentials.apiHash,
2609
+ { connectionRetries: 3 }
2610
+ );
2611
+ try {
2612
+ await client.connect();
2613
+ if (!client.connected) {
2614
+ throw new TelegramConnectionError("Telegram client did not connect");
2615
+ }
2616
+ return client;
2617
+ } catch (error) {
2618
+ throw mapTelegramError(error, "Could not connect to Telegram");
2619
+ }
2620
+ }
2621
+ };
2622
+ function newTelegramClient(credentials) {
2623
+ return new TelegramClient(new StringSession(""), credentials.apiId, credentials.apiHash, {
2624
+ connectionRetries: 3
2625
+ });
2626
+ }
2627
+ function extractCreatedChannel(result) {
2628
+ const chats = result instanceof Api.Updates || result instanceof Api.UpdatesCombined ? result.chats : [];
2629
+ for (const chat of chats) {
2630
+ if (chat instanceof Api.Channel) return chat;
2631
+ }
2632
+ throw new TelegramConnectionError("Telegram did not return the created storage channel");
2633
+ }
2634
+
2635
+ // ../telegram/src/provider.ts
2636
+ import { Readable as Readable2 } from "node:stream";
2637
+ import { Api as Api2 } from "telegram";
2638
+ import bigInt from "big-integer";
2639
+ import { CustomFile } from "telegram/client/uploads.js";
2640
+ var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
2641
+ var DOWNLOAD_CHUNK_BYTES = 512 * 1024;
2642
+ var TelegramStorageProvider = class {
2643
+ constructor(client, options) {
2644
+ this.client = client;
2645
+ this.peer = new Api2.InputPeerChannel({
2646
+ channelId: bigInt(options.chatId),
2647
+ accessHash: bigInt(options.accessHash)
2648
+ });
2649
+ this.title = options.title ?? null;
2650
+ }
2651
+ name = "telegram";
2652
+ peer;
2653
+ title;
2654
+ // ── Upload ───────────────────────────────────────────────────────────────
2655
+ async put(input, control = {}) {
2656
+ const total = input.data ? input.data.byteLength : input.size;
2657
+ if (total === void 0 || !input.data && !input.path) {
2658
+ throw new UploadFailedError("Upload input has neither data nor a spooled file");
2659
+ }
2660
+ if (total > MAX_DOCUMENT_BYTES) {
2661
+ throw new UploadFailedError(
2662
+ `Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
2663
+ );
2664
+ }
2665
+ this.throwIfAborted(control.signal);
2666
+ control.onProgress?.({ transferred: 0, total, percent: 0 });
2667
+ try {
2668
+ const file = input.data ? new CustomFile(input.name, total, "", input.data) : new CustomFile(input.name, total, input.path);
2669
+ const message = await this.client.sendFile(this.peer, {
2670
+ file,
2671
+ forceDocument: true,
2672
+ workers: 1,
2673
+ // Surface transport-level progress to the caller. GramJS reports a
2674
+ // 0..1 fraction. `isCanceled` lets us abort an in-flight upload.
2675
+ progressCallback: Object.assign(
2676
+ (fraction) => {
2677
+ const clamped = Math.max(0, Math.min(1, fraction));
2678
+ control.onProgress?.({
2679
+ transferred: Math.round(clamped * total),
2680
+ total,
2681
+ percent: Math.round(clamped * 100)
2682
+ });
2683
+ },
2684
+ {
2685
+ get isCanceled() {
2686
+ return control.signal?.aborted === true;
2687
+ }
2688
+ }
2689
+ )
2690
+ });
2691
+ if (!(message instanceof Api2.Message)) {
2692
+ throw new UploadFailedError("Telegram returned an unexpected upload response");
2693
+ }
2694
+ control.onProgress?.({ transferred: total, total, percent: 100 });
2695
+ return {
2696
+ messageId: message.id.toString(),
2697
+ name: input.name,
2698
+ size: total,
2699
+ mimeType: input.mimeType
2700
+ };
2701
+ } catch (error) {
2702
+ if (error instanceof OperationCancelledError) throw error;
2703
+ throw mapTelegramError(error, "Telegram upload failed");
2704
+ }
2705
+ }
2706
+ // ── Download ─────────────────────────────────────────────────────────────
2707
+ async get(ref, control = {}) {
2708
+ const message = await this.requireMessage(ref);
2709
+ this.throwIfAborted(control.signal);
2710
+ const size = this.documentSize(message);
2711
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
2712
+ let data;
2713
+ try {
2714
+ data = await this.client.downloadMedia(message, {
2715
+ progressCallback: (downloaded, total) => {
2716
+ const transferred = Number(downloaded.toString());
2717
+ const totalBytes = Number(total.toString());
2718
+ control.onProgress?.({
2719
+ transferred,
2720
+ total: totalBytes,
2721
+ percent: totalBytes > 0 ? Math.round(transferred / totalBytes * 100) : null
2722
+ });
2723
+ }
2724
+ });
2725
+ } catch (error) {
2726
+ throw mapTelegramError(error, "Telegram download failed");
2727
+ }
2728
+ if (!data || typeof data === "string") {
2729
+ throw new TelegramFileNotFoundError("Telegram did not return file content");
2730
+ }
2731
+ const buffer = Buffer.from(data);
2732
+ control.onProgress?.({
2733
+ transferred: buffer.byteLength,
2734
+ total: buffer.byteLength,
2735
+ percent: 100
2736
+ });
2737
+ return buffer;
2738
+ }
2739
+ /**
2740
+ * Streams the object in Telegram-sized chunks, so memory use stays bounded
2741
+ * by one chunk regardless of file size. The first request happens here so
2742
+ * that a missing message fails before the caller starts consuming bytes.
2743
+ */
2744
+ async getStream(ref, control = {}) {
2745
+ const message = await this.requireMessage(ref);
2746
+ this.throwIfAborted(control.signal);
2747
+ const size = this.documentSize(message);
2748
+ const media = message.media;
2749
+ if (size === null || !media) {
2750
+ throw new TelegramFileNotFoundError("Telegram did not return file content");
2751
+ }
2752
+ const iterator = this.client.iterDownload({
2753
+ file: media,
2754
+ fileSize: bigInt(size),
2755
+ requestSize: DOWNLOAD_CHUNK_BYTES
2756
+ });
2757
+ let transferred = 0;
2758
+ control.onProgress?.({ transferred: 0, total: size, percent: 0 });
2759
+ const signal = control.signal;
2760
+ return Readable2.from(
2761
+ (async function* () {
2762
+ try {
2763
+ for await (const chunk of iterator) {
2764
+ if (signal?.aborted) throw new OperationCancelledError();
2765
+ const buffer = Buffer.from(chunk);
2766
+ transferred += buffer.byteLength;
2767
+ control.onProgress?.({
2768
+ transferred,
2769
+ total: size,
2770
+ percent: size > 0 ? Math.round(transferred / size * 100) : null
2771
+ });
2772
+ yield buffer;
2773
+ }
2774
+ } catch (error) {
2775
+ if (error instanceof OperationCancelledError) throw error;
2776
+ throw mapTelegramError(error, "Telegram download failed");
2777
+ }
2778
+ })()
2779
+ );
2780
+ }
2781
+ // ── Lifecycle ────────────────────────────────────────────────────────────
2782
+ async delete(ref) {
2783
+ try {
2784
+ await this.client.deleteMessages(this.peer, [Number(ref.messageId)], { revoke: true });
2785
+ } catch (error) {
2786
+ throw mapTelegramError(error, "Telegram delete failed");
2787
+ }
2788
+ }
2789
+ async exists(ref) {
2790
+ try {
2791
+ return await this.stat(ref) !== null;
2792
+ } catch (error) {
2793
+ throw mapTelegramError(error, "Telegram lookup failed");
2794
+ }
2795
+ }
2796
+ /**
2797
+ * Read-only listing of stored documents in the channel, newest first, up to
2798
+ * `limit`. Only messages that carry a document are returned; text messages and
2799
+ * service messages are skipped. Nothing is modified.
2800
+ */
2801
+ async listObjects(limit) {
2802
+ const objects = [];
2803
+ try {
2804
+ for await (const message of this.client.iterMessages(this.peer, { limit: limit * 2 })) {
2805
+ if (objects.length >= limit) break;
2806
+ if (!(message instanceof Api2.Message)) continue;
2807
+ const document = message.document;
2808
+ if (!(document instanceof Api2.Document)) continue;
2809
+ let fileName;
2810
+ for (const attr of document.attributes) {
2811
+ if (attr instanceof Api2.DocumentAttributeFilename) {
2812
+ fileName = attr.fileName;
2813
+ break;
2814
+ }
2815
+ }
2816
+ objects.push({
2817
+ messageId: message.id.toString(),
2818
+ name: fileName ?? "file",
2819
+ size: Number(document.size.toString()),
2820
+ mimeType: document.mimeType
2821
+ });
2822
+ }
2823
+ } catch (error) {
2824
+ throw mapTelegramError(error, "Telegram listing failed");
2825
+ }
2826
+ return objects;
2827
+ }
2828
+ async stat(ref) {
2829
+ const message = await this.getMessage(ref.messageId);
2830
+ if (!message?.media) return null;
2831
+ const document = message.document;
2832
+ if (!(document instanceof Api2.Document)) return null;
2833
+ let fileName;
2834
+ for (const attr of document.attributes) {
2835
+ if (attr instanceof Api2.DocumentAttributeFilename) {
2836
+ fileName = attr.fileName;
2837
+ break;
2838
+ }
2839
+ }
2840
+ return {
2841
+ messageId: ref.messageId,
2842
+ name: fileName ?? "file",
2843
+ size: Number(document.size.toString()),
2844
+ mimeType: document.mimeType
2845
+ };
2846
+ }
2847
+ // ── Health ───────────────────────────────────────────────────────────────
2848
+ /**
2849
+ * Validates that the client is connected and the storage channel is
2850
+ * reachable (a real Telegram round-trip on the channel peer).
2851
+ */
2852
+ async healthCheck() {
2853
+ const startedAt = Date.now();
2854
+ try {
2855
+ if (!this.client.connected) {
2856
+ return {
2857
+ healthy: false,
2858
+ latencyMs: null,
2859
+ message: "Telegram client is not connected",
2860
+ targetTitle: this.title
2861
+ };
2862
+ }
2863
+ const result = await this.client.invoke(
2864
+ new Api2.channels.GetFullChannel({ channel: this.peer })
2865
+ );
2866
+ const title = result instanceof Api2.messages.ChatFull && result.chats[0] instanceof Api2.Channel ? result.chats[0].title : this.title;
2867
+ return {
2868
+ healthy: true,
2869
+ latencyMs: Date.now() - startedAt,
2870
+ message: null,
2871
+ targetTitle: title ?? null
2872
+ };
2873
+ } catch (error) {
2874
+ const mapped = mapTelegramError(error, "Telegram storage health check failed");
2875
+ return {
2876
+ healthy: false,
2877
+ latencyMs: Date.now() - startedAt,
2878
+ message: mapped.message,
2879
+ targetTitle: this.title
2880
+ };
2881
+ }
2882
+ }
2883
+ // ── internals ────────────────────────────────────────────────────────────
2884
+ throwIfAborted(signal) {
2885
+ if (signal?.aborted) throw new OperationCancelledError();
2886
+ if (!this.client.connected) {
2887
+ throw new TelegramConnectionError("Telegram client is not connected");
2888
+ }
2889
+ }
2890
+ documentSize(message) {
2891
+ const document = message.document;
2892
+ if (document instanceof Api2.Document) return Number(document.size.toString());
2893
+ return null;
2894
+ }
2895
+ async requireMessage(ref) {
2896
+ const message = await this.getMessage(ref.messageId);
2897
+ if (!message?.media) {
2898
+ throw new TelegramFileNotFoundError();
2899
+ }
2900
+ return message;
2901
+ }
2902
+ async getMessage(id) {
2903
+ try {
2904
+ const messages = await this.client.getMessages(this.peer, { ids: [Number(id)] });
2905
+ const first = messages[0];
2906
+ return first instanceof Api2.Message ? first : void 0;
2907
+ } catch (error) {
2908
+ if (error instanceof TelegramAuthRequiredError) throw error;
2909
+ throw mapTelegramError(error, "Telegram lookup failed");
2910
+ }
2911
+ }
2912
+ };
2913
+
2914
+ // ../telegram/src/connection.ts
2915
+ import { Api as Api3 } from "telegram";
2916
+ import { computeCheck } from "telegram/Password.js";
2917
+ var TELEGRAM_PROVIDER = "telegram";
2918
+ var LOGIN_FLOW_TTL_MS = 5 * 60 * 1e3;
2919
+ var TelegramConnectionService = class {
2920
+ constructor(repos, clientManager, credentials) {
2921
+ this.repos = repos;
2922
+ this.clientManager = clientManager;
2923
+ this.credentials = credentials;
2924
+ }
2925
+ flows = /* @__PURE__ */ new Map();
2926
+ /** Sends the Telegram confirmation code for the phone number. */
2927
+ async startLogin(phone) {
2928
+ this.sweepExpiredFlows();
2929
+ await this.cancelLogin(phone);
2930
+ const client = newTelegramClient(this.credentials);
2931
+ try {
2932
+ await client.connect();
2933
+ const { phoneCodeHash } = await client.sendCode(
2934
+ { apiId: this.credentials.apiId, apiHash: this.credentials.apiHash },
2935
+ phone
2936
+ );
2937
+ this.flows.set(phone, { phone, client, phoneCodeHash, createdAt: Date.now() });
2938
+ } catch (error) {
2939
+ await this.disconnectQuietly(client);
2940
+ throw mapTelegramError(error, "Could not send the Telegram confirmation code");
2941
+ }
2942
+ }
2943
+ /** Verifies the confirmation code. Returns `password_required` for 2FA accounts. */
2944
+ async verifyCode(phone, code) {
2945
+ const flow = this.requireFlow(phone);
2946
+ try {
2947
+ await flow.client.invoke(
2948
+ new Api3.auth.SignIn({
2949
+ phoneNumber: flow.phone,
2950
+ phoneCodeHash: flow.phoneCodeHash,
2951
+ phoneCode: code
2952
+ })
2953
+ );
2954
+ } catch (error) {
2955
+ if (isPasswordRequiredError(error)) {
2956
+ return { status: "password_required" };
2957
+ }
2958
+ throw mapTelegramError(error, "Telegram sign-in failed");
2959
+ }
2960
+ return this.finalizeLogin(flow);
2961
+ }
2962
+ /** Completes sign-in for accounts with two-factor authentication enabled. */
2963
+ async verifyPassword(phone, password) {
2964
+ const flow = this.requireFlow(phone);
2965
+ try {
2966
+ const passwordInfo = await flow.client.invoke(new Api3.account.GetPassword());
2967
+ const check = await computeCheck(passwordInfo, password);
2968
+ await flow.client.invoke(new Api3.auth.CheckPassword({ password: check }));
2969
+ } catch (error) {
2970
+ throw mapTelegramError(error, "Telegram sign-in failed");
2971
+ }
2972
+ return this.finalizeLogin(flow);
2973
+ }
2974
+ /** Aborts a pending login flow (disconnects the client). */
2975
+ async cancelLogin(phone) {
2976
+ const flow = this.flows.get(phone);
2977
+ if (flow) {
2978
+ this.flows.delete(phone);
2979
+ await this.disconnectQuietly(flow.client);
2980
+ }
2981
+ }
2982
+ /**
2983
+ * Ensures the user has a registered private storage channel, creating it
2984
+ * on first use. Idempotent.
2985
+ */
2986
+ async ensureStorage(userId) {
2987
+ const existing = await this.repos.storages.findByUserAndProvider(userId, TELEGRAM_PROVIDER);
2988
+ if (existing) return existing;
2989
+ const client = await this.clientManager.getForUser(userId);
2990
+ return this.ensureStorageForClient(userId, client);
2991
+ }
2992
+ async ensureStorageForClient(userId, client) {
2993
+ const existing = await this.repos.storages.findByUserAndProvider(userId, TELEGRAM_PROVIDER);
2994
+ if (existing) return existing;
2995
+ let channel;
2996
+ try {
2997
+ const result = await client.invoke(
2998
+ new Api3.channels.CreateChannel({
2999
+ title: "OmniCloud Storage",
3000
+ about: "Private storage channel created by OmniCloud (https://github.com/Lacrous/OmniCloud)",
3001
+ megagroup: false
3002
+ })
3003
+ );
3004
+ channel = extractCreatedChannel(result);
3005
+ } catch (error) {
3006
+ throw mapTelegramError(error, "Could not create the private storage channel");
3007
+ }
3008
+ const accessHash = channel.accessHash?.toString();
3009
+ if (!accessHash) {
3010
+ throw new TelegramConnectionError("Telegram did not provide the channel access hash");
3011
+ }
3012
+ return this.repos.storages.create({
3013
+ userId,
3014
+ provider: TELEGRAM_PROVIDER,
3015
+ title: channel.title ?? "OmniCloud Storage",
3016
+ telegramChatId: channel.id.toString(),
3017
+ telegramAccessHash: accessHash
3018
+ });
3019
+ }
3020
+ // ── internals ────────────────────────────────────────────────────────────
3021
+ requireFlow(phone) {
3022
+ const flow = this.flows.get(phone);
3023
+ if (!flow) {
3024
+ throw new NotFoundError("No pending Telegram login for this phone number");
3025
+ }
3026
+ if (Date.now() - flow.createdAt > LOGIN_FLOW_TTL_MS) {
3027
+ this.flows.delete(phone);
3028
+ void this.disconnectQuietly(flow.client);
3029
+ throw new ValidationError("The login session expired \u2014 please start again");
3030
+ }
3031
+ return flow;
3032
+ }
3033
+ async finalizeLogin(flow) {
3034
+ this.flows.delete(flow.phone);
3035
+ let stringSession;
3036
+ let user;
3037
+ try {
3038
+ stringSession = flow.client.session.save();
3039
+ const me = await flow.client.getMe();
3040
+ if (!(me instanceof Api3.User)) {
3041
+ throw new Error("Telegram did not return the authenticated user");
3042
+ }
3043
+ user = await this.repos.users.upsertFromTelegram({
3044
+ telegramUserId: me.id.toString(),
3045
+ username: me.username ?? null,
3046
+ firstName: me.firstName ?? null,
3047
+ lastName: me.lastName ?? null,
3048
+ phone: me.phone ?? flow.phone
3049
+ });
3050
+ } catch (error) {
3051
+ await this.disconnectQuietly(flow.client);
3052
+ throw mapTelegramError(error, "Could not complete the Telegram connection");
3053
+ }
3054
+ await this.repos.sessions.save(user.id, stringSession);
3055
+ await this.ensureStorageForClient(user.id, flow.client);
3056
+ this.clientManager.cacheClient(user.id, flow.client);
3057
+ return { status: "ok", user };
3058
+ }
3059
+ sweepExpiredFlows() {
3060
+ const now = Date.now();
3061
+ for (const [phone, flow] of this.flows) {
3062
+ if (now - flow.createdAt > LOGIN_FLOW_TTL_MS) {
3063
+ this.flows.delete(phone);
3064
+ void this.disconnectQuietly(flow.client);
3065
+ }
3066
+ }
3067
+ }
3068
+ async disconnectQuietly(client) {
3069
+ try {
3070
+ await client.disconnect();
3071
+ } catch {
3072
+ }
3073
+ }
3074
+ };
3075
+
3076
+ // ../database/src/index.ts
3077
+ import {
3078
+ PrismaClient
3079
+ } from "@prisma/client";
3080
+ import { PrismaClient as PrismaClient2 } from "@prisma/client";
3081
+ function createPrismaClient() {
3082
+ return new PrismaClient();
3083
+ }
3084
+ function mapUser(user) {
3085
+ return {
3086
+ id: user.id,
3087
+ telegramUserId: user.telegramUserId.toString(),
3088
+ username: user.username,
3089
+ firstName: user.firstName,
3090
+ lastName: user.lastName,
3091
+ phone: user.phone,
3092
+ createdAt: user.createdAt
3093
+ };
3094
+ }
3095
+ function mapStorage(storage) {
3096
+ return {
3097
+ id: storage.id,
3098
+ userId: storage.userId,
3099
+ provider: storage.provider,
3100
+ title: storage.title,
3101
+ telegramChatId: storage.telegramChatId.toString(),
3102
+ telegramAccessHash: storage.telegramAccessHash.toString(),
3103
+ createdAt: storage.createdAt
3104
+ };
3105
+ }
3106
+ function mapFolder(folder) {
3107
+ return {
3108
+ id: folder.id,
3109
+ userId: folder.userId,
3110
+ parentId: folder.parentId,
3111
+ name: folder.name,
3112
+ starred: folder.starred,
3113
+ deletedAt: folder.deletedAt,
3114
+ trashBatchId: folder.trashBatchId,
3115
+ createdAt: folder.createdAt,
3116
+ updatedAt: folder.updatedAt
3117
+ };
3118
+ }
3119
+ function mapFile(file) {
3120
+ return {
3121
+ id: file.id,
3122
+ userId: file.userId,
3123
+ folderId: file.folderId,
3124
+ name: file.name,
3125
+ size: Number(file.size),
3126
+ mimeType: file.mimeType,
3127
+ sha256: file.sha256,
3128
+ telegramMessageId: Number(file.telegramMessageId),
3129
+ starred: file.starred,
3130
+ deletedAt: file.deletedAt,
3131
+ trashBatchId: file.trashBatchId,
3132
+ currentVersionId: file.currentVersionId,
3133
+ versionCount: file.versionCount,
3134
+ createdAt: file.createdAt,
3135
+ updatedAt: file.updatedAt
3136
+ };
3137
+ }
3138
+ function mapVersion(version, _currentVersionId) {
3139
+ return {
3140
+ id: version.id,
3141
+ fileId: version.fileId,
3142
+ userId: version.userId,
3143
+ versionNumber: version.versionNumber,
3144
+ size: Number(version.size),
3145
+ mimeType: version.mimeType,
3146
+ sha256: version.sha256,
3147
+ telegramMessageId: Number(version.telegramMessageId),
3148
+ createdAt: version.createdAt
3149
+ };
3150
+ }
3151
+ function mapActivity(event) {
3152
+ return {
3153
+ id: event.id,
3154
+ userId: event.userId,
3155
+ action: event.action,
3156
+ resourceType: event.resourceType,
3157
+ resourceId: event.resourceId,
3158
+ resourceName: event.resourceName,
3159
+ metadata: event.metadata ?? null,
3160
+ createdAt: event.createdAt
3161
+ };
3162
+ }
3163
+ function fileWhere(userId, query) {
3164
+ const where = { userId };
3165
+ if (query.trashed === true) where.deletedAt = { not: null };
3166
+ else if (query.trashed === false) where.deletedAt = null;
3167
+ if (query.folderIds) {
3168
+ where.folderId = { in: query.folderIds };
3169
+ } else if (query.folderId !== void 0) {
3170
+ where.folderId = query.folderId;
3171
+ }
3172
+ if (query.nameContains) {
3173
+ where.name = { contains: query.nameContains, mode: "insensitive" };
3174
+ }
3175
+ if (query.starred !== void 0) where.starred = query.starred;
3176
+ if (query.minSize !== void 0 || query.maxSize !== void 0) {
3177
+ where.size = {};
3178
+ if (query.minSize !== void 0) where.size.gte = BigInt(query.minSize);
3179
+ if (query.maxSize !== void 0) where.size.lte = BigInt(query.maxSize);
3180
+ }
3181
+ if (query.from || query.to) {
3182
+ where.createdAt = {};
3183
+ if (query.from) where.createdAt.gte = query.from;
3184
+ if (query.to) where.createdAt.lte = query.to;
3185
+ }
3186
+ if (query.ext) {
3187
+ where.name = { ...where.name, endsWith: `.${query.ext}`, mode: "insensitive" };
3188
+ }
3189
+ if (query.mimeMatchers && query.mimeMatchers.length > 0) {
3190
+ where.OR = query.mimeMatchers.map(
3191
+ (matcher) => matcher.endsWith("/") ? { mimeType: { startsWith: matcher } } : { mimeType: matcher }
3192
+ );
3193
+ }
3194
+ return where;
3195
+ }
3196
+ function folderWhere(userId, query) {
3197
+ const where = { userId };
3198
+ if (query.trashed === true) where.deletedAt = { not: null };
3199
+ else if (query.trashed === false) where.deletedAt = null;
3200
+ if (query.folderId !== void 0) where.parentId = query.folderId;
3201
+ if (query.nameContains) where.name = { contains: query.nameContains, mode: "insensitive" };
3202
+ if (query.starred !== void 0) where.starred = query.starred;
3203
+ if (query.from || query.to) {
3204
+ where.createdAt = {};
3205
+ if (query.from) where.createdAt.gte = query.from;
3206
+ if (query.to) where.createdAt.lte = query.to;
3207
+ }
3208
+ return where;
3209
+ }
3210
+ function fileOrderBy(query) {
3211
+ const order = query.order ?? "asc";
3212
+ switch (query.sort) {
3213
+ case "size":
3214
+ return [{ size: order }];
3215
+ case "createdAt":
3216
+ return [{ createdAt: order }];
3217
+ case "updatedAt":
3218
+ return [{ updatedAt: order }];
3219
+ case "type":
3220
+ return [{ mimeType: order }, { name: "asc" }];
3221
+ case "name":
3222
+ default:
3223
+ return [{ name: order }, { createdAt: "asc" }];
3224
+ }
3225
+ }
3226
+ function folderOrderBy(query) {
3227
+ const order = query.order ?? "asc";
3228
+ switch (query.sort) {
3229
+ case "createdAt":
3230
+ return [{ createdAt: order }];
3231
+ case "updatedAt":
3232
+ return [{ updatedAt: order }];
3233
+ case "name":
3234
+ default:
3235
+ return [{ name: order }, { createdAt: "asc" }];
3236
+ }
3237
+ }
3238
+ function skipTake(page) {
3239
+ return { skip: (page.page - 1) * page.limit, take: page.limit };
3240
+ }
3241
+ function createUserRepo(prisma) {
3242
+ return {
3243
+ async findById(id) {
3244
+ const user = await prisma.user.findUnique({ where: { id } });
3245
+ return user ? mapUser(user) : null;
3246
+ },
3247
+ async findByTelegramId(telegramUserId) {
3248
+ const user = await prisma.user.findUnique({
3249
+ where: { telegramUserId: BigInt(telegramUserId) }
3250
+ });
3251
+ return user ? mapUser(user) : null;
3252
+ },
3253
+ async upsertFromTelegram(input) {
3254
+ const data = {
3255
+ username: input.username,
3256
+ firstName: input.firstName,
3257
+ lastName: input.lastName,
3258
+ phone: input.phone
3259
+ };
3260
+ const user = await prisma.user.upsert({
3261
+ where: { telegramUserId: BigInt(input.telegramUserId) },
3262
+ update: data,
3263
+ create: { telegramUserId: BigInt(input.telegramUserId), ...data }
3264
+ });
3265
+ return mapUser(user);
3266
+ }
3267
+ };
3268
+ }
3269
+ var SEALED_PREFIX = /^v\d+:/;
3270
+ function createSessionRepo(prisma, box) {
3271
+ const seal = (value) => box ? box.seal(value) : value;
3272
+ const open = (stored) => box && SEALED_PREFIX.test(stored) ? box.open(stored) : stored;
3273
+ return {
3274
+ async get(userId) {
3275
+ const session = await prisma.telegramSession.findUnique({ where: { userId } });
3276
+ if (!session) return null;
3277
+ const stringSession = open(session.stringSession);
3278
+ if (box && !SEALED_PREFIX.test(session.stringSession)) {
3279
+ await prisma.telegramSession.update({
3280
+ where: { userId },
3281
+ data: { stringSession: seal(stringSession) }
3282
+ });
3283
+ }
3284
+ const record = { userId: session.userId, stringSession };
3285
+ return record;
3286
+ },
3287
+ async save(userId, stringSession) {
3288
+ await prisma.telegramSession.upsert({
3289
+ where: { userId },
3290
+ update: { stringSession: seal(stringSession) },
3291
+ create: { userId, stringSession: seal(stringSession) }
3292
+ });
3293
+ },
3294
+ async delete(userId) {
3295
+ await prisma.telegramSession.deleteMany({ where: { userId } });
3296
+ }
3297
+ };
3298
+ }
3299
+ function mapBrowserSession(row) {
3300
+ return { ...row };
3301
+ }
3302
+ function mapUploadOperation(row) {
3303
+ return {
3304
+ id: row.id,
3305
+ userId: row.userId,
3306
+ operationId: row.operationId,
3307
+ status: row.status,
3308
+ telegramMessageId: row.telegramMessageId === null ? null : Number(row.telegramMessageId),
3309
+ sha256: row.sha256,
3310
+ size: row.size === null ? null : Number(row.size),
3311
+ fileId: row.fileId,
3312
+ error: row.error,
3313
+ createdAt: row.createdAt,
3314
+ updatedAt: row.updatedAt
3315
+ };
3316
+ }
3317
+ function createUploadOperationRepo(prisma) {
3318
+ return {
3319
+ async findByOperationId(userId, operationId) {
3320
+ const row = await prisma.uploadOperation.findUnique({
3321
+ where: { userId_operationId: { userId, operationId } }
3322
+ });
3323
+ return row ? mapUploadOperation(row) : null;
3324
+ },
3325
+ async listByStatus(userId, status) {
3326
+ const rows = await prisma.uploadOperation.findMany({ where: { userId, status } });
3327
+ return rows.map(mapUploadOperation);
3328
+ },
3329
+ async create(input) {
3330
+ try {
3331
+ const row = await prisma.uploadOperation.create({
3332
+ data: { userId: input.userId, operationId: input.operationId, status: "PENDING" }
3333
+ });
3334
+ return mapUploadOperation(row);
3335
+ } catch (error) {
3336
+ if (error.code === "P2002") throw new OperationAlreadyExistsError();
3337
+ throw error;
3338
+ }
3339
+ },
3340
+ async claim(id, from, to) {
3341
+ const result = await prisma.uploadOperation.updateMany({
3342
+ where: { id, status: from },
3343
+ data: { status: to }
3344
+ });
3345
+ return result.count === 1;
3346
+ },
3347
+ async update(id, patch) {
3348
+ const row = await prisma.uploadOperation.update({
3349
+ where: { id },
3350
+ data: {
3351
+ ...patch.status !== void 0 ? { status: patch.status } : {},
3352
+ ...patch.telegramMessageId !== void 0 ? {
3353
+ telegramMessageId: patch.telegramMessageId === null ? null : BigInt(patch.telegramMessageId)
3354
+ } : {},
3355
+ ...patch.sha256 !== void 0 ? { sha256: patch.sha256 } : {},
3356
+ ...patch.size !== void 0 ? { size: patch.size === null ? null : BigInt(patch.size) } : {},
3357
+ ...patch.fileId !== void 0 ? { fileId: patch.fileId } : {},
3358
+ ...patch.error !== void 0 ? { error: patch.error } : {}
3359
+ }
3360
+ });
3361
+ return mapUploadOperation(row);
3362
+ }
3363
+ };
3364
+ }
3365
+ function createBrowserSessionRepo(prisma) {
3366
+ return {
3367
+ async create(input) {
3368
+ const row = await prisma.browserSession.create({ data: input });
3369
+ return mapBrowserSession(row);
3370
+ },
3371
+ async findByTokenHash(tokenHash) {
3372
+ const row = await prisma.browserSession.findUnique({ where: { tokenHash } });
3373
+ return row ? mapBrowserSession(row) : null;
3374
+ },
3375
+ async touch(id, at) {
3376
+ await prisma.browserSession.update({ where: { id }, data: { lastUsedAt: at } });
3377
+ },
3378
+ async revoke(id, at) {
3379
+ await prisma.browserSession.updateMany({
3380
+ where: { id, revokedAt: null },
3381
+ data: { revokedAt: at }
3382
+ });
3383
+ },
3384
+ async revokeAllForUser(userId, at) {
3385
+ const result = await prisma.browserSession.updateMany({
3386
+ where: { userId, revokedAt: null },
3387
+ data: { revokedAt: at }
3388
+ });
3389
+ return result.count;
3390
+ },
3391
+ async listActiveForUser(userId, now) {
3392
+ const rows = await prisma.browserSession.findMany({
3393
+ where: { userId, revokedAt: null, expiresAt: { gt: now } },
3394
+ orderBy: { createdAt: "desc" }
3395
+ });
3396
+ return rows.map(mapBrowserSession);
3397
+ },
3398
+ async deleteExpiredBefore(cutoff) {
3399
+ const result = await prisma.browserSession.deleteMany({
3400
+ where: { expiresAt: { lt: cutoff } }
3401
+ });
3402
+ return result.count;
3403
+ }
3404
+ };
3405
+ }
3406
+ function createStorageRepo(prisma) {
3407
+ return {
3408
+ async findByUserAndProvider(userId, provider) {
3409
+ const storage = await prisma.storage.findUnique({
3410
+ where: { userId_provider: { userId, provider } }
3411
+ });
3412
+ return storage ? mapStorage(storage) : null;
3413
+ },
3414
+ async create(input) {
3415
+ const storage = await prisma.storage.create({
3416
+ data: {
3417
+ userId: input.userId,
3418
+ provider: input.provider,
3419
+ title: input.title,
3420
+ telegramChatId: BigInt(input.telegramChatId),
3421
+ telegramAccessHash: BigInt(input.telegramAccessHash)
3422
+ }
3423
+ });
3424
+ return mapStorage(storage);
3425
+ }
3426
+ };
3427
+ }
3428
+ function createFolderRepo(prisma) {
3429
+ return {
3430
+ async moveSafely(id, newParentId) {
3431
+ const folder = await prisma.$transaction(
3432
+ async (tx) => {
3433
+ if (newParentId !== null) {
3434
+ let cursor = newParentId;
3435
+ while (cursor !== null) {
3436
+ if (cursor === id) {
3437
+ throw new ConflictError("Cannot move a folder into one of its subfolders");
3438
+ }
3439
+ const parent = await tx.folder.findUnique({
3440
+ where: { id: cursor },
3441
+ select: { parentId: true }
3442
+ });
3443
+ if (!parent) throw new NotFoundError("Folder not found", "folder");
3444
+ cursor = parent.parentId;
3445
+ }
3446
+ }
3447
+ return tx.folder.update({ where: { id }, data: { parentId: newParentId } });
3448
+ },
3449
+ { isolationLevel: "Serializable" }
3450
+ );
3451
+ return mapFolder(folder);
3452
+ },
3453
+ async create(input) {
3454
+ const folder = await prisma.folder.create({
3455
+ data: { userId: input.userId, parentId: input.parentId, name: input.name }
3456
+ });
3457
+ return mapFolder(folder);
3458
+ },
3459
+ async findById(id) {
3460
+ const folder = await prisma.folder.findUnique({ where: { id } });
3461
+ return folder ? mapFolder(folder) : null;
3462
+ },
3463
+ async listByUser(userId) {
3464
+ const rows = await prisma.folder.findMany({
3465
+ where: { userId },
3466
+ orderBy: { createdAt: "asc" }
3467
+ });
3468
+ return rows.map(mapFolder);
3469
+ },
3470
+ async listChildren(userId, parentId) {
3471
+ const rows = await prisma.folder.findMany({
3472
+ where: { userId, parentId, deletedAt: null },
3473
+ orderBy: [{ name: "asc" }, { createdAt: "asc" }]
3474
+ });
3475
+ return rows.map(mapFolder);
3476
+ },
3477
+ async query(userId, query, page) {
3478
+ const where = folderWhere(userId, query);
3479
+ const [rows, total] = await Promise.all([
3480
+ prisma.folder.findMany({
3481
+ where,
3482
+ orderBy: folderOrderBy(query),
3483
+ ...skipTake(page)
3484
+ }),
3485
+ prisma.folder.count({ where })
3486
+ ]);
3487
+ return { items: rows.map(mapFolder), total };
3488
+ },
3489
+ async findIdsByName(userId, name) {
3490
+ const rows = await prisma.folder.findMany({
3491
+ where: { userId, name: { contains: name, mode: "insensitive" }, deletedAt: null },
3492
+ select: { id: true }
3493
+ });
3494
+ return rows.map((row) => row.id);
3495
+ },
3496
+ async update(id, patch) {
3497
+ const folder = await prisma.folder.update({
3498
+ where: { id },
3499
+ data: {
3500
+ ...patch.name !== void 0 ? { name: patch.name } : {},
3501
+ ...patch.parentId !== void 0 ? { parentId: patch.parentId } : {},
3502
+ ...patch.starred !== void 0 ? { starred: patch.starred } : {},
3503
+ ...patch.deletedAt !== void 0 ? { deletedAt: patch.deletedAt } : {},
3504
+ ...patch.trashBatchId !== void 0 ? { trashBatchId: patch.trashBatchId } : {}
3505
+ }
3506
+ });
3507
+ return mapFolder(folder);
3508
+ },
3509
+ async updateMany(ids, patch) {
3510
+ const result = await prisma.folder.updateMany({
3511
+ where: { id: { in: ids } },
3512
+ data: {
3513
+ ...patch.starred !== void 0 ? { starred: patch.starred } : {},
3514
+ ...patch.deletedAt !== void 0 ? { deletedAt: patch.deletedAt } : {},
3515
+ ...patch.trashBatchId !== void 0 ? { trashBatchId: patch.trashBatchId } : {}
3516
+ }
3517
+ });
3518
+ return result.count;
3519
+ },
3520
+ async deleteMany(ids) {
3521
+ let remaining = [...ids];
3522
+ let guard = 0;
3523
+ while (remaining.length > 0 && guard < 100) {
3524
+ guard += 1;
3525
+ const withChildren = await prisma.folder.findMany({
3526
+ where: { parentId: { in: remaining } },
3527
+ select: { parentId: true }
3528
+ });
3529
+ const parentsWithChildren = new Set(
3530
+ withChildren.map((row) => row.parentId).filter((id) => id !== null)
3531
+ );
3532
+ const leaves = remaining.filter((id) => !parentsWithChildren.has(id));
3533
+ if (leaves.length === 0) break;
3534
+ await prisma.folder.deleteMany({ where: { id: { in: leaves } } });
3535
+ remaining = remaining.filter((id) => !leaves.includes(id));
3536
+ }
3537
+ if (remaining.length > 0) {
3538
+ await prisma.folder.deleteMany({ where: { id: { in: remaining } } });
3539
+ }
3540
+ },
3541
+ async countByUser(userId) {
3542
+ return prisma.folder.count({ where: { userId, deletedAt: null } });
3543
+ }
3544
+ };
3545
+ }
3546
+ function createFileRepo(prisma) {
3547
+ return {
3548
+ async create(input) {
3549
+ const file = await prisma.file.create({
3550
+ data: {
3551
+ userId: input.userId,
3552
+ folderId: input.folderId,
3553
+ name: input.name,
3554
+ size: BigInt(input.size),
3555
+ mimeType: input.mimeType,
3556
+ sha256: input.sha256,
3557
+ telegramMessageId: BigInt(input.telegramMessageId)
3558
+ }
3559
+ });
3560
+ return mapFile(file);
3561
+ },
3562
+ async findById(id) {
3563
+ const file = await prisma.file.findUnique({ where: { id } });
3564
+ return file ? mapFile(file) : null;
3565
+ },
3566
+ async listByFolder(userId, folderId) {
3567
+ const rows = await prisma.file.findMany({
3568
+ where: { userId, folderId, deletedAt: null },
3569
+ orderBy: [{ name: "asc" }, { createdAt: "asc" }]
3570
+ });
3571
+ return rows.map(mapFile);
3572
+ },
3573
+ async listByUser(userId) {
3574
+ const rows = await prisma.file.findMany({ where: { userId } });
3575
+ return rows.map(mapFile);
3576
+ },
3577
+ async listByIds(userId, ids) {
3578
+ if (ids.length === 0) return [];
3579
+ const rows = await prisma.file.findMany({ where: { userId, id: { in: ids } } });
3580
+ return rows.map(mapFile);
3581
+ },
3582
+ async query(userId, query, page) {
3583
+ const where = fileWhere(userId, query);
3584
+ const [rows, total] = await Promise.all([
3585
+ prisma.file.findMany({ where, orderBy: fileOrderBy(query), ...skipTake(page) }),
3586
+ prisma.file.count({ where })
3587
+ ]);
3588
+ return { items: rows.map(mapFile), total };
3589
+ },
3590
+ async update(id, patch) {
3591
+ const file = await prisma.file.update({
3592
+ where: { id },
3593
+ data: {
3594
+ ...patch.name !== void 0 ? { name: patch.name } : {},
3595
+ ...patch.folderId !== void 0 ? { folderId: patch.folderId } : {},
3596
+ ...patch.starred !== void 0 ? { starred: patch.starred } : {},
3597
+ ...patch.deletedAt !== void 0 ? { deletedAt: patch.deletedAt } : {},
3598
+ ...patch.trashBatchId !== void 0 ? { trashBatchId: patch.trashBatchId } : {},
3599
+ ...patch.size !== void 0 ? { size: BigInt(patch.size) } : {},
3600
+ ...patch.mimeType !== void 0 ? { mimeType: patch.mimeType } : {},
3601
+ ...patch.sha256 !== void 0 ? { sha256: patch.sha256 } : {},
3602
+ ...patch.telegramMessageId !== void 0 ? { telegramMessageId: BigInt(patch.telegramMessageId) } : {},
3603
+ ...patch.currentVersionId !== void 0 ? { currentVersionId: patch.currentVersionId } : {},
3604
+ ...patch.versionCount !== void 0 ? { versionCount: patch.versionCount } : {}
3605
+ }
3606
+ });
3607
+ return mapFile(file);
3608
+ },
3609
+ async updateMany(ids, patch) {
3610
+ const result = await prisma.file.updateMany({
3611
+ where: { id: { in: ids } },
3612
+ data: {
3613
+ ...patch.folderId !== void 0 ? { folderId: patch.folderId } : {},
3614
+ ...patch.starred !== void 0 ? { starred: patch.starred } : {},
3615
+ ...patch.deletedAt !== void 0 ? { deletedAt: patch.deletedAt } : {},
3616
+ ...patch.trashBatchId !== void 0 ? { trashBatchId: patch.trashBatchId } : {}
3617
+ }
3618
+ });
3619
+ return result.count;
3620
+ },
3621
+ async statsByUser(userId) {
3622
+ const [fileAgg, folderCount] = await Promise.all([
3623
+ prisma.file.groupBy({
3624
+ by: ["deletedAt"],
3625
+ where: { userId },
3626
+ _count: { _all: true },
3627
+ _sum: { size: true }
3628
+ }),
3629
+ prisma.folder.count({ where: { userId, deletedAt: null } })
3630
+ ]);
3631
+ let fileCount = 0;
3632
+ let totalBytes = 0;
3633
+ let trashBytes = 0;
3634
+ let trashFileCount = 0;
3635
+ for (const group of fileAgg) {
3636
+ const count = group._count._all;
3637
+ const bytes = Number(group._sum.size ?? 0);
3638
+ if (group.deletedAt === null) {
3639
+ fileCount = count;
3640
+ totalBytes = bytes;
3641
+ } else {
3642
+ trashFileCount = count;
3643
+ trashBytes = bytes;
3644
+ }
3645
+ }
3646
+ const starredCount = await prisma.file.count({
3647
+ where: { userId, starred: true, deletedAt: null }
3648
+ });
3649
+ return { fileCount, folderCount, totalBytes, trashBytes, trashFileCount, starredCount };
3650
+ },
3651
+ async deleteMany(ids) {
3652
+ await prisma.file.deleteMany({ where: { id: { in: ids } } });
3653
+ },
3654
+ // ── Versions ───────────────────────────────────────────────────────────
3655
+ async createVersion(input) {
3656
+ const count = await prisma.fileVersion.count({ where: { fileId: input.fileId } });
3657
+ const version = await prisma.fileVersion.create({
3658
+ data: {
3659
+ fileId: input.fileId,
3660
+ userId: input.userId,
3661
+ versionNumber: count + 1,
3662
+ size: BigInt(input.size),
3663
+ mimeType: input.mimeType,
3664
+ sha256: input.sha256,
3665
+ telegramMessageId: BigInt(input.telegramMessageId)
3666
+ }
3667
+ });
3668
+ return mapVersion(version, null);
3669
+ },
3670
+ async listVersionsForFiles(fileIds) {
3671
+ const grouped = /* @__PURE__ */ new Map();
3672
+ for (const id of fileIds) grouped.set(id, []);
3673
+ if (fileIds.length === 0) return grouped;
3674
+ const rows = await prisma.fileVersion.findMany({
3675
+ where: { fileId: { in: fileIds } },
3676
+ orderBy: { versionNumber: "desc" }
3677
+ });
3678
+ for (const row of rows) grouped.get(row.fileId)?.push(mapVersion(row, null));
3679
+ return grouped;
3680
+ },
3681
+ async listVersions(fileId) {
3682
+ const [rows, file] = await Promise.all([
3683
+ prisma.fileVersion.findMany({ where: { fileId }, orderBy: { versionNumber: "desc" } }),
3684
+ prisma.file.findUnique({ where: { id: fileId }, select: { currentVersionId: true } })
3685
+ ]);
3686
+ const currentId = file?.currentVersionId ?? null;
3687
+ return rows.map((row) => mapVersion(row, currentId));
3688
+ },
3689
+ async findVersionById(versionId) {
3690
+ const version = await prisma.fileVersion.findUnique({ where: { id: versionId } });
3691
+ return version ? mapVersion(version, null) : null;
3692
+ },
3693
+ async countVersions(fileId) {
3694
+ return prisma.fileVersion.count({ where: { fileId } });
3695
+ },
3696
+ async deleteVersionsByFileIds(fileIds) {
3697
+ if (fileIds.length === 0) return;
3698
+ await prisma.fileVersion.deleteMany({ where: { fileId: { in: fileIds } } });
3699
+ }
3700
+ };
3701
+ }
3702
+ function createActivityRepo(prisma) {
3703
+ return {
3704
+ async record(input) {
3705
+ await prisma.activityEvent.create({
3706
+ data: {
3707
+ userId: input.userId,
3708
+ action: input.action,
3709
+ resourceType: input.resourceType,
3710
+ resourceId: input.resourceId,
3711
+ resourceName: input.resourceName ?? null,
3712
+ metadata: input.metadata ?? void 0
3713
+ }
3714
+ });
3715
+ },
3716
+ async list(userId, page) {
3717
+ const where = { userId };
3718
+ const [rows, total] = await Promise.all([
3719
+ prisma.activityEvent.findMany({
3720
+ where,
3721
+ orderBy: { createdAt: "desc" },
3722
+ ...skipTake(page)
3723
+ }),
3724
+ prisma.activityEvent.count({ where })
3725
+ ]);
3726
+ return { items: rows.map(mapActivity), total };
3727
+ },
3728
+ async recentFiles(userId, actions, limit) {
3729
+ const rows = await prisma.activityEvent.findMany({
3730
+ where: { userId, resourceType: "file", action: { in: [...actions] } },
3731
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
3732
+ take: limit
3733
+ });
3734
+ const seen = /* @__PURE__ */ new Set();
3735
+ const result = [];
3736
+ for (const row of rows) {
3737
+ if (seen.has(row.resourceId)) continue;
3738
+ seen.add(row.resourceId);
3739
+ result.push(mapActivity(row));
3740
+ }
3741
+ return result;
3742
+ },
3743
+ async countByUser(userId) {
3744
+ return prisma.activityEvent.count({ where: { userId } });
3745
+ },
3746
+ async pruneOlderThan(cutoff) {
3747
+ const result = await prisma.activityEvent.deleteMany({
3748
+ where: { createdAt: { lt: cutoff } }
3749
+ });
3750
+ return result.count;
3751
+ }
3752
+ };
3753
+ }
3754
+ function createPrismaRepos(prisma, box = null) {
3755
+ return {
3756
+ users: createUserRepo(prisma),
3757
+ sessions: createSessionRepo(prisma, box),
3758
+ browserSessions: createBrowserSessionRepo(prisma),
3759
+ uploadOperations: createUploadOperationRepo(prisma),
3760
+ storages: createStorageRepo(prisma),
3761
+ folders: createFolderRepo(prisma),
3762
+ files: createFileRepo(prisma),
3763
+ activity: createActivityRepo(prisma)
3764
+ };
3765
+ }
3766
+
3767
+ // ../../apps/api/src/container.ts
3768
+ function buildContainer(config2, options = {}) {
3769
+ const prisma = createPrismaClient();
3770
+ const box = config2.encryptionKey ? new SecretBox([{ version: 1, key: deriveKey(config2.encryptionKey) }]) : null;
3771
+ const repos = createPrismaRepos(prisma, box);
3772
+ return buildContainerFromRepos(config2, repos, {
3773
+ operationLog: options.operationLog ?? null,
3774
+ dispose: () => prisma.$disconnect()
3775
+ });
3776
+ }
3777
+ function buildContainerFromRepos(config2, repos, overrides = {}) {
3778
+ const credentials = {
3779
+ apiId: config2.telegramApiId,
3780
+ apiHash: config2.telegramApiHash
3781
+ };
3782
+ const clientManager = new TelegramClientManager(credentials, repos.sessions);
3783
+ const connection = overrides.connection ?? new TelegramConnectionService(repos, clientManager, credentials);
3784
+ const engineCache = /* @__PURE__ */ new Map();
3785
+ const defaultEngineFor = async (userId) => {
3786
+ const cached = engineCache.get(userId);
3787
+ if (cached) {
3788
+ try {
3789
+ return await cached;
3790
+ } catch {
3791
+ engineCache.delete(userId);
3792
+ }
3793
+ }
3794
+ const promise = (async () => {
3795
+ const storage = await repos.storages.findByUserAndProvider(userId, TELEGRAM_PROVIDER);
3796
+ if (!storage) throw new StorageNotInitializedError();
3797
+ const client = await clientManager.getForUser(userId);
3798
+ return new StorageEngine(
3799
+ new TelegramStorageProvider(client, {
3800
+ chatId: storage.telegramChatId,
3801
+ accessHash: storage.telegramAccessHash,
3802
+ title: storage.title
3803
+ })
3804
+ );
3805
+ })();
3806
+ engineCache.set(userId, promise);
3807
+ try {
3808
+ return await promise;
3809
+ } catch (error) {
3810
+ engineCache.delete(userId);
3811
+ throw error;
3812
+ }
3813
+ };
3814
+ const engineFor = overrides.engineFor ?? defaultEngineFor;
3815
+ const defaultStorageHealth = {
3816
+ async health(userId, deep = false) {
3817
+ const status = clientManager.statusForUser(userId);
3818
+ const storage = await repos.storages.findByUserAndProvider(userId, TELEGRAM_PROVIDER);
3819
+ if (!storage) {
3820
+ return {
3821
+ healthy: false,
3822
+ latencyMs: null,
3823
+ message: "Storage is not initialized",
3824
+ targetTitle: null,
3825
+ state: "DISCONNECTED"
3826
+ };
3827
+ }
3828
+ const probe = await clientManager.healthCheck(userId, deep);
3829
+ return {
3830
+ ...probe,
3831
+ targetTitle: probe.targetTitle ?? storage.title,
3832
+ state: clientManager.statusForUser(userId).state || status.state
3833
+ };
3834
+ },
3835
+ async disconnect(userId) {
3836
+ await clientManager.disconnectUser(userId);
3837
+ }
3838
+ };
3839
+ const activity = new ActivityService(repos.activity);
3840
+ return {
3841
+ config: config2,
3842
+ repos,
3843
+ connection,
3844
+ storageHealth: overrides.storageHealth ?? defaultStorageHealth,
3845
+ files: new FileService(
3846
+ repos.files,
3847
+ repos.folders,
3848
+ engineFor,
3849
+ activity,
3850
+ repos.uploadOperations,
3851
+ overrides.operationLog ?? null
3852
+ ),
3853
+ folders: new FolderService(repos.folders, repos.files, engineFor, activity),
3854
+ search: new SearchService(repos.files, repos.folders),
3855
+ stats: new StatsService(repos.files, repos.folders),
3856
+ trash: new TrashService(repos.files, repos.folders, engineFor),
3857
+ recent: new RecentService(repos.activity, repos.files),
3858
+ activity,
3859
+ integrity: new IntegrityService(repos.files, engineFor),
3860
+ reconciliation: new ReconciliationService(repos, engineFor),
3861
+ reconciliationFlow: new ReconciliationFlow(repos, engineFor),
3862
+ authSessions: new AuthSessionService(repos.browserSessions),
3863
+ engineFor,
3864
+ shutdown: async () => {
3865
+ await clientManager.disconnectAll();
3866
+ await overrides.dispose?.();
3867
+ }
3868
+ };
3869
+ }
3870
+
3871
+ // ../../apps/api/src/app.ts
3872
+ import { readFileSync } from "node:fs";
3873
+ import { join as join2 } from "node:path";
3874
+
3875
+ // ../../apps/api/src/csp.ts
3876
+ import { createHash as createHash5 } from "node:crypto";
3877
+ function sha256Source(content) {
3878
+ return `'sha256-${createHash5("sha256").update(content, "utf8").digest("base64")}'`;
3879
+ }
3880
+ function buildContentSecurityPolicy(inlineScripts, inlineStyles) {
3881
+ const scriptSources = ["'self'", ...inlineScripts.map(sha256Source)];
3882
+ const styleSources = ["'self'", ...inlineStyles.map(sha256Source)];
3883
+ return [
3884
+ "default-src 'self'",
3885
+ `script-src ${scriptSources.join(" ")}`,
3886
+ `style-src ${styleSources.join(" ")}`,
3887
+ "img-src 'self' data: blob:",
3888
+ "font-src 'self' data:",
3889
+ "connect-src 'self'",
3890
+ "object-src 'none'",
3891
+ "base-uri 'self'",
3892
+ "form-action 'self'",
3893
+ "frame-ancestors 'none'"
3894
+ ].join("; ");
3895
+ }
3896
+ function inlineBlocks(html) {
3897
+ const scripts = [];
3898
+ const styles = [];
3899
+ for (const match of html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)) {
3900
+ scripts.push(match[1]);
3901
+ }
3902
+ for (const match of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
3903
+ styles.push(match[1]);
3904
+ }
3905
+ return { scripts, styles };
3906
+ }
3907
+
3908
+ // ../../apps/api/src/app.ts
3909
+ import { existsSync } from "node:fs";
3910
+ import { resolve } from "node:path";
3911
+ import Fastify from "fastify";
3912
+ import cookie from "@fastify/cookie";
3913
+ import multipart from "@fastify/multipart";
3914
+ import fastifyStatic from "@fastify/static";
3915
+
3916
+ // ../../apps/api/src/auth.ts
3917
+ function registerAuthHook(app2, repos, authSessions) {
3918
+ app2.addHook("preHandler", async (request) => {
3919
+ const url = request.routeOptions?.url ?? "";
3920
+ if (!url.startsWith("/api/") || url.startsWith("/api/auth/") || url.startsWith("/api/health")) {
3921
+ return;
3922
+ }
3923
+ const session = await authSessions.resolve(request.cookies[SESSION_COOKIE]);
3924
+ if (!session) throw new UnauthorizedError();
3925
+ const user = await repos.users.findById(session.userId);
3926
+ if (!user) throw new AuthInvalidError("Account no longer exists");
3927
+ request.user = { id: user.id };
3928
+ request.authSessionId = session.id;
3929
+ });
3930
+ }
3931
+ async function issueSessionCookie(authSessions, request, reply, userId, cookieSecure) {
3932
+ const { token } = await authSessions.issue(userId, {
3933
+ userAgent: request.headers["user-agent"] ?? null,
3934
+ ip: request.ip
3935
+ });
3936
+ reply.setCookie(SESSION_COOKIE, token, {
3937
+ httpOnly: true,
3938
+ sameSite: "lax",
3939
+ secure: cookieSecure,
3940
+ path: "/",
3941
+ maxAge: DEFAULT_SESSION_TTL_DAYS * 24 * 60 * 60
3942
+ });
3943
+ }
3944
+ async function currentUser(repos, authSessions, cookies) {
3945
+ const session = await authSessions.resolve(cookies[SESSION_COOKIE]);
3946
+ if (!session) return null;
3947
+ return repos.users.findById(session.userId);
3948
+ }
3949
+
3950
+ // ../../apps/api/src/logging.ts
3951
+ var SECRET_KEYS = [
3952
+ "stringSession",
3953
+ "session",
3954
+ "cookie",
3955
+ "password",
3956
+ "code",
3957
+ "token",
3958
+ "tokenHash",
3959
+ "encryptionKey",
3960
+ "sessionSecret",
3961
+ "OMNICLOUD_ENCRYPTION_KEY",
3962
+ "SESSION_SECRET",
3963
+ "TELEGRAM_API_HASH"
3964
+ ];
3965
+ var REDACT_PATHS = [
3966
+ "req.headers.cookie",
3967
+ "req.headers.authorization",
3968
+ "res.headers['set-cookie']",
3969
+ ...SECRET_KEYS,
3970
+ ...SECRET_KEYS.map((key) => `*.${key}`)
3971
+ ];
3972
+ var LOGGER_OPTIONS = (level) => ({
3973
+ level,
3974
+ redact: { paths: REDACT_PATHS, censor: "[Redacted]" }
3975
+ });
3976
+
3977
+ // ../../apps/api/src/http.ts
3978
+ import { randomUUID } from "node:crypto";
3979
+ function resolveRequestId(headers) {
3980
+ const inbound = headers[REQUEST_ID_HEADER];
3981
+ const candidate = Array.isArray(inbound) ? inbound[0] : inbound;
3982
+ return typeof candidate === "string" && candidate.length > 0 && candidate.length <= 128 ? candidate : randomUUID();
3983
+ }
3984
+ function attachRequestId(request, reply) {
3985
+ reply.header(REQUEST_ID_HEADER, request.id);
3986
+ return request.id;
3987
+ }
3988
+ var SECURITY_HEADERS = {
3989
+ "X-Content-Type-Options": "nosniff",
3990
+ "X-Frame-Options": "DENY",
3991
+ "Referrer-Policy": "no-referrer",
3992
+ "Cross-Origin-Resource-Policy": "same-origin",
3993
+ "Cross-Origin-Opener-Policy": "same-origin",
3994
+ "Permissions-Policy": "geolocation=(), microphone=(), camera=()",
3995
+ "X-Permitted-Cross-Domain-Policies": "none"
3996
+ };
3997
+ function applySecurityHeaders(reply) {
3998
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
3999
+ reply.header(name, value);
4000
+ }
4001
+ }
4002
+ function isAllowedOrigin(origin, host, allowed) {
4003
+ if (!origin) return true;
4004
+ if (allowed.length > 0) return allowed.includes(origin);
4005
+ if (!host) return false;
4006
+ try {
4007
+ return new URL(origin).host === host;
4008
+ } catch {
4009
+ return false;
4010
+ }
4011
+ }
4012
+
4013
+ // ../../apps/api/src/rate-limit.ts
4014
+ function createRateLimiter({ windowMs, max }) {
4015
+ const hits = /* @__PURE__ */ new Map();
4016
+ return function check(key) {
4017
+ const now = Date.now();
4018
+ const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
4019
+ if (recent.length >= max) {
4020
+ throw new RateLimitedError(
4021
+ `Too many attempts \u2014 try again in ${Math.ceil(windowMs / 1e3)} seconds`
4022
+ );
4023
+ }
4024
+ recent.push(now);
4025
+ hits.set(key, recent);
4026
+ if (hits.size > 1e4) {
4027
+ for (const [k, times] of hits) {
4028
+ if (times.every((t) => now - t >= windowMs)) hits.delete(k);
4029
+ }
4030
+ }
4031
+ };
4032
+ }
4033
+
4034
+ // ../../apps/api/src/validation.ts
4035
+ function requireString(body, field) {
4036
+ const value = optionalString(body, field);
4037
+ if (value === void 0) {
4038
+ throw new ValidationError(`Field "${field}" is required`);
4039
+ }
4040
+ return value;
4041
+ }
4042
+ function optionalString(body, field) {
4043
+ if (body === null || typeof body !== "object") return void 0;
4044
+ const value = body[field];
4045
+ if (value === void 0 || value === null) return void 0;
4046
+ if (typeof value !== "string") {
4047
+ throw new ValidationError(`Field "${field}" must be a string`);
4048
+ }
4049
+ return value;
4050
+ }
4051
+ function stringOrNull(body, field) {
4052
+ if (body === null || typeof body !== "object") {
4053
+ throw new ValidationError("A JSON object body is required");
4054
+ }
4055
+ const value = body[field];
4056
+ if (value === void 0 || value === null) return null;
4057
+ if (typeof value !== "string" || value === "") return null;
4058
+ return value;
4059
+ }
4060
+ function requireBody(request) {
4061
+ if (request.body === void 0 || request.body === null) {
4062
+ throw new ValidationError("A JSON object body is required");
4063
+ }
4064
+ if (typeof request.body !== "object" || Array.isArray(request.body)) {
4065
+ throw new ValidationError("Request body must be a JSON object");
4066
+ }
4067
+ return request.body;
4068
+ }
4069
+ function optionalBoolean(body, field) {
4070
+ if (body === null || typeof body !== "object") return void 0;
4071
+ const value = body[field];
4072
+ if (value === void 0 || value === null) return void 0;
4073
+ if (typeof value === "boolean") return value;
4074
+ if (value === "true") return true;
4075
+ if (value === "false") return false;
4076
+ throw new ValidationError(`Field "${field}" must be a boolean`);
4077
+ }
4078
+ function requireStringArray(body, field, max = 500) {
4079
+ if (body === null || typeof body !== "object") {
4080
+ throw new ValidationError("A JSON object body is required");
4081
+ }
4082
+ const value = body[field];
4083
+ if (!Array.isArray(value)) {
4084
+ throw new ValidationError(`Field "${field}" must be an array of ids`);
4085
+ }
4086
+ if (value.length === 0) throw new ValidationError(`Field "${field}" must not be empty`);
4087
+ if (value.length > max) {
4088
+ throw new ValidationError(`Field "${field}" accepts at most ${max} ids`);
4089
+ }
4090
+ const ids = [];
4091
+ for (const item of value) {
4092
+ if (typeof item !== "string" || item.trim() === "") {
4093
+ throw new ValidationError(`Field "${field}" must contain non-empty string ids`);
4094
+ }
4095
+ ids.push(item);
4096
+ }
4097
+ return [...new Set(ids)];
4098
+ }
4099
+ function requireEnum(body, field, allowed) {
4100
+ const value = requireString(body, field);
4101
+ if (!allowed.includes(value)) {
4102
+ throw new ValidationError(`Field "${field}" must be one of: ${allowed.join(", ")}`);
4103
+ }
4104
+ return value;
4105
+ }
4106
+ function optionalQueryInt(value) {
4107
+ if (value === void 0 || value === "" || value === null) return void 0;
4108
+ const parsed = Number(value);
4109
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : void 0;
4110
+ }
4111
+ function optionalQueryBool(value) {
4112
+ if (value === void 0 || value === "" || value === null) return void 0;
4113
+ if (value === "true" || value === "1") return true;
4114
+ if (value === "false" || value === "0") return false;
4115
+ return void 0;
4116
+ }
4117
+ function optionalQueryString(value) {
4118
+ if (typeof value !== "string" || value.trim() === "") return void 0;
4119
+ return value.trim();
4120
+ }
4121
+ function parseListQuery(raw) {
4122
+ const status = optionalQueryString(raw.status);
4123
+ const query = {
4124
+ page: normalizePage(raw.page),
4125
+ limit: normalizeLimit(raw.limit),
4126
+ sort: normalizeSort(raw.sort),
4127
+ order: normalizeOrder(raw.order),
4128
+ q: optionalQueryString(raw.q),
4129
+ type: optionalQueryString(raw.type),
4130
+ ext: optionalQueryString(raw.ext),
4131
+ starred: optionalQueryBool(raw.starred),
4132
+ // Default to active items: normal views must never surface trashed
4133
+ // content. Trash/starred/search endpoints opt into their own scope.
4134
+ status: status && ITEM_STATUSES.includes(status) ? status : "active",
4135
+ minSize: optionalQueryInt(raw.minSize),
4136
+ maxSize: optionalQueryInt(raw.maxSize),
4137
+ from: optionalQueryString(raw.from),
4138
+ to: optionalQueryString(raw.to)
4139
+ };
4140
+ if ("folderId" in raw) {
4141
+ const folderId = raw.folderId;
4142
+ query.folderId = typeof folderId === "string" && folderId !== "" ? folderId : null;
4143
+ }
4144
+ return { query, page: query.page, limit: query.limit };
4145
+ }
4146
+
4147
+ // ../../apps/api/src/mappers.ts
4148
+ function toUserDTO(user) {
4149
+ return {
4150
+ id: user.id,
4151
+ username: user.username,
4152
+ firstName: user.firstName,
4153
+ lastName: user.lastName
4154
+ };
4155
+ }
4156
+ function toStorageDTO(storage) {
4157
+ return {
4158
+ id: storage.id,
4159
+ provider: storage.provider,
4160
+ title: storage.title,
4161
+ createdAt: storage.createdAt.toISOString()
4162
+ };
4163
+ }
4164
+ function toFileDTO(file) {
4165
+ return {
4166
+ id: file.id,
4167
+ name: file.name,
4168
+ size: file.size,
4169
+ mimeType: file.mimeType,
4170
+ sha256: file.sha256,
4171
+ folderId: file.folderId,
4172
+ starred: file.starred,
4173
+ trashed: file.deletedAt !== null,
4174
+ deletedAt: file.deletedAt ? file.deletedAt.toISOString() : null,
4175
+ versionCount: file.versionCount,
4176
+ currentVersionId: file.currentVersionId,
4177
+ createdAt: file.createdAt.toISOString(),
4178
+ updatedAt: file.updatedAt.toISOString()
4179
+ };
4180
+ }
4181
+ function toFolderDTO(folder) {
4182
+ return {
4183
+ id: folder.id,
4184
+ name: folder.name,
4185
+ parentId: folder.parentId,
4186
+ starred: folder.starred,
4187
+ trashed: folder.deletedAt !== null,
4188
+ deletedAt: folder.deletedAt ? folder.deletedAt.toISOString() : null,
4189
+ createdAt: folder.createdAt.toISOString(),
4190
+ updatedAt: folder.updatedAt.toISOString()
4191
+ };
4192
+ }
4193
+ function toVersionDTO(version, currentVersionId) {
4194
+ return {
4195
+ id: version.id,
4196
+ fileId: version.fileId,
4197
+ versionNumber: version.versionNumber,
4198
+ size: version.size,
4199
+ mimeType: version.mimeType,
4200
+ sha256: version.sha256,
4201
+ telegramMessageId: version.telegramMessageId,
4202
+ isCurrent: version.id === currentVersionId,
4203
+ createdAt: version.createdAt.toISOString()
4204
+ };
4205
+ }
4206
+ function toActivityDTO(event) {
4207
+ return {
4208
+ id: event.id,
4209
+ action: event.action,
4210
+ resourceType: event.resourceType,
4211
+ resourceId: event.resourceId,
4212
+ resourceName: event.resourceName,
4213
+ metadata: event.metadata,
4214
+ createdAt: event.createdAt.toISOString()
4215
+ };
4216
+ }
4217
+ function toStatsDTO(stats) {
4218
+ return {
4219
+ fileCount: stats.fileCount,
4220
+ folderCount: stats.folderCount,
4221
+ totalBytes: stats.totalBytes,
4222
+ trashBytes: stats.trashBytes,
4223
+ trashFileCount: stats.trashFileCount,
4224
+ starredCount: stats.starredCount,
4225
+ byType: stats.byType,
4226
+ largestFiles: stats.largestFiles.map(toFileDTO),
4227
+ quotaBytes: stats.quotaBytes
4228
+ };
4229
+ }
4230
+ function toStorageHealthDTO(input) {
4231
+ const status = input.healthy ? "healthy" : input.state === "ERROR" || input.state === "AUTH_REQUIRED" ? "unavailable" : "degraded";
4232
+ return {
4233
+ provider: input.provider,
4234
+ state: input.state,
4235
+ status,
4236
+ latencyMs: input.latencyMs,
4237
+ message: input.message,
4238
+ channelTitle: input.targetTitle,
4239
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
4240
+ };
4241
+ }
4242
+
4243
+ // ../../apps/api/src/routes/auth.ts
4244
+ var PHONE_PATTERN = /^\+?[0-9]\d{4,14}$/;
4245
+ function registerAuthRoutes(app2, container2) {
4246
+ const limiter = createRateLimiter({ windowMs: 6e4, max: 10 });
4247
+ app2.post("/api/auth/telegram/start", async (request, reply) => {
4248
+ limiter(request.ip);
4249
+ const body = requireBody(request);
4250
+ const phone = requireString(body, "phone");
4251
+ if (!PHONE_PATTERN.test(phone)) {
4252
+ throw new ValidationError("Please provide a valid phone number in international format");
4253
+ }
4254
+ await container2.connection.startLogin(phone);
4255
+ return reply.send({ ok: true });
4256
+ });
4257
+ app2.post("/api/auth/telegram/verify", async (request, reply) => {
4258
+ limiter(request.ip);
4259
+ const body = requireBody(request);
4260
+ const phone = requireString(body, "phone");
4261
+ const code = requireString(body, "code");
4262
+ if (!/^\d{3,10}$/.test(code)) {
4263
+ throw new ValidationError("Please provide the numeric Telegram login code");
4264
+ }
4265
+ const result = await container2.connection.verifyCode(phone, code);
4266
+ if (result.status === "password_required") {
4267
+ const response2 = { status: "password_required" };
4268
+ return reply.send(response2);
4269
+ }
4270
+ await issueSessionCookie(
4271
+ container2.authSessions,
4272
+ request,
4273
+ reply,
4274
+ result.user.id,
4275
+ container2.config.cookieSecure
4276
+ );
4277
+ const response = { status: "ok", user: toUserDTO(result.user) };
4278
+ return reply.send(response);
4279
+ });
4280
+ app2.post("/api/auth/telegram/password", async (request, reply) => {
4281
+ limiter(request.ip);
4282
+ const body = requireBody(request);
4283
+ const phone = requireString(body, "phone");
4284
+ const password = requireString(body, "password");
4285
+ if (password.length < 1) {
4286
+ throw new ValidationError("Please provide the two-factor password");
4287
+ }
4288
+ const result = await container2.connection.verifyPassword(phone, password);
4289
+ if (result.status !== "ok") {
4290
+ throw new ValidationError("Sign-in incomplete");
4291
+ }
4292
+ await issueSessionCookie(
4293
+ container2.authSessions,
4294
+ request,
4295
+ reply,
4296
+ result.user.id,
4297
+ container2.config.cookieSecure
4298
+ );
4299
+ return reply.send({ status: "ok", user: toUserDTO(result.user) });
4300
+ });
4301
+ app2.get("/api/auth/me", async (request) => {
4302
+ const user = await currentUser(container2.repos, container2.authSessions, request.cookies);
4303
+ if (!user) return { user: null, storage: null, health: null };
4304
+ const storage = await container2.repos.storages.findByUserAndProvider(user.id, "telegram");
4305
+ if (!storage) return { user: toUserDTO(user), storage: null, health: null };
4306
+ const status = await container2.storageHealth.health(user.id, false);
4307
+ return {
4308
+ user: toUserDTO(user),
4309
+ storage: toStorageDTO(storage),
4310
+ health: toStorageHealthDTO({
4311
+ provider: "telegram",
4312
+ state: status.state,
4313
+ healthy: status.healthy,
4314
+ latencyMs: status.latencyMs,
4315
+ message: status.message,
4316
+ targetTitle: status.targetTitle
4317
+ })
4318
+ };
4319
+ });
4320
+ app2.post("/api/auth/logout-all", async (request, reply) => {
4321
+ const user = await currentUser(container2.repos, container2.authSessions, request.cookies);
4322
+ if (!user)
4323
+ return reply.status(401).send({ error: { code: "AUTH_REQUIRED", message: "Sign in first" } });
4324
+ const revoked = await container2.authSessions.revokeAll(user.id);
4325
+ reply.clearCookie(SESSION_COOKIE, { path: "/" });
4326
+ return { ok: true, revoked };
4327
+ });
4328
+ app2.post("/api/auth/logout", async (request, reply) => {
4329
+ const user = await currentUser(container2.repos, container2.authSessions, request.cookies);
4330
+ await container2.authSessions.revoke(request.cookies[SESSION_COOKIE]);
4331
+ if (user) await container2.storageHealth.disconnect(user.id);
4332
+ reply.clearCookie(SESSION_COOKIE, { path: "/" });
4333
+ return { ok: true };
4334
+ });
4335
+ }
4336
+
4337
+ // ../../apps/api/src/routes/files.ts
4338
+ function operationIdFrom(headers, fields) {
4339
+ const header = headers["idempotency-key"];
4340
+ if (typeof header === "string" && header !== "") return header;
4341
+ const field = fields["operationId"];
4342
+ if (field && !Array.isArray(field) && field.type === "field" && typeof field.value === "string") {
4343
+ return field.value || void 0;
4344
+ }
4345
+ return void 0;
4346
+ }
4347
+ function folderIdFromFields(fields) {
4348
+ const field = fields["folderId"];
4349
+ if (!field || Array.isArray(field) || field.type !== "field") return null;
4350
+ const value = field.value;
4351
+ return typeof value === "string" && value !== "" ? value : null;
4352
+ }
4353
+ async function withSpooledUpload(part, maxUploadBytes, use) {
4354
+ const spool = await spoolToFile(part.file, maxUploadBytes);
4355
+ try {
4356
+ if (part.file.truncated) {
4357
+ throw new PayloadTooLargeError(
4358
+ `Files larger than ${Math.floor(maxUploadBytes / (1024 * 1024))} MB are not supported`
4359
+ );
4360
+ }
4361
+ return await use({ path: spool.path, size: spool.size, sha256: spool.sha256 });
4362
+ } finally {
4363
+ await spool.discard();
4364
+ }
4365
+ }
4366
+ function registerFileRoutes(app2, container2) {
4367
+ const maxUploadBytes = container2.config.maxUploadBytes;
4368
+ app2.post("/api/files", async (request, reply) => {
4369
+ const part = await request.file();
4370
+ if (!part) throw new ValidationError('A multipart body with a "file" field is required');
4371
+ if (!part.filename) throw new ValidationError("The uploaded file has no filename");
4372
+ const folderId = folderIdFromFields(part.fields);
4373
+ const operationId = operationIdFrom(request.headers, part.fields);
4374
+ const record = await withSpooledUpload(
4375
+ part,
4376
+ maxUploadBytes,
4377
+ (spooled) => container2.files.upload(request.user.id, {
4378
+ folderId,
4379
+ name: part.filename,
4380
+ spooled,
4381
+ operationId
4382
+ })
4383
+ );
4384
+ return reply.status(201).send({ file: toFileDTO(record) });
4385
+ });
4386
+ app2.get("/api/files", async (request) => {
4387
+ const { query, page, limit } = parseListQuery(request.query);
4388
+ const result = await container2.files.query(request.user.id, query, { page, limit });
4389
+ return {
4390
+ files: result.items.map(toFileDTO),
4391
+ pagination: { page, limit, total: result.total, hasMore: page * limit < result.total }
4392
+ };
4393
+ });
4394
+ app2.post("/api/files/batch", async (request) => {
4395
+ const body = requireBody(request);
4396
+ const operation = requireEnum(body, "operation", BATCH_FILE_OPERATIONS);
4397
+ const ids = requireStringArray(body, "ids");
4398
+ const folderId = operation === "move" ? stringOrNull(body, "folderId") : void 0;
4399
+ const operationId = stringOrNull(body, "operationId") ?? void 0;
4400
+ if (operationId !== void 0 && !isValidOperationId(operationId)) {
4401
+ throw new ValidationError("Invalid batch operation id");
4402
+ }
4403
+ const result = await container2.files.batch(request.user.id, ids, operation, {
4404
+ folderId: folderId ?? null,
4405
+ operationId
4406
+ });
4407
+ return {
4408
+ ...result.operationId ? { operationId: result.operationId } : {},
4409
+ requested: result.requested,
4410
+ succeeded: result.succeeded,
4411
+ failed: result.failed,
4412
+ errors: result.errors.map((error) => ({
4413
+ id: error.id,
4414
+ code: "INVALID_REQUEST",
4415
+ message: error.message
4416
+ }))
4417
+ };
4418
+ });
4419
+ app2.get("/api/files/:id", async (request) => {
4420
+ const { id } = request.params;
4421
+ const record = await container2.files.get(request.user.id, id);
4422
+ return { file: toFileDTO(record) };
4423
+ });
4424
+ app2.get("/api/files/:id/versions", async (request) => {
4425
+ const { id } = request.params;
4426
+ const record = await container2.files.get(request.user.id, id);
4427
+ const versions = await container2.files.listVersions(request.user.id, id);
4428
+ return { versions: versions.map((v) => toVersionDTO(v, record.currentVersionId)) };
4429
+ });
4430
+ app2.post("/api/files/:id/replace", async (request, reply) => {
4431
+ const { id } = request.params;
4432
+ const part = await request.file();
4433
+ if (!part) throw new ValidationError('A multipart body with a "file" field is required');
4434
+ if (!part.filename) throw new ValidationError("The uploaded file has no filename");
4435
+ const record = await withSpooledUpload(
4436
+ part,
4437
+ maxUploadBytes,
4438
+ (spooled) => container2.files.upload(request.user.id, {
4439
+ folderId: null,
4440
+ name: part.filename,
4441
+ spooled,
4442
+ replaceFileId: id
4443
+ })
4444
+ );
4445
+ return reply.send({ file: toFileDTO(record) });
4446
+ });
4447
+ app2.get("/api/files/:id/download", async (request, reply) => {
4448
+ const { id } = request.params;
4449
+ const { record, stream } = await container2.files.downloadStreamed(request.user.id, id);
4450
+ const asciiName = record.name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
4451
+ reply.header("Content-Type", record.mimeType).header("Content-Length", record.size).header("X-Content-SHA256", record.sha256).header(
4452
+ "Content-Disposition",
4453
+ `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(record.name)}`
4454
+ );
4455
+ stream.once("error", (error) => {
4456
+ request.log.error({ fileId: record.id, err: error }, "download stream failed");
4457
+ });
4458
+ return reply.send(stream);
4459
+ });
4460
+ app2.patch("/api/files/:id", async (request) => {
4461
+ const { id } = request.params;
4462
+ const body = requireBody(request);
4463
+ const name = optionalString(body, "name");
4464
+ const starred = optionalBoolean(body, "starred");
4465
+ if (name === void 0 && starred === void 0) {
4466
+ throw new ValidationError('At least one of "name" or "starred" is required');
4467
+ }
4468
+ let record = await container2.files.get(request.user.id, id);
4469
+ if (name !== void 0) record = await container2.files.rename(request.user.id, id, name);
4470
+ if (starred !== void 0)
4471
+ record = await container2.files.setStarred(request.user.id, id, starred);
4472
+ return { file: toFileDTO(record) };
4473
+ });
4474
+ app2.post("/api/files/:id/move", async (request) => {
4475
+ const { id } = request.params;
4476
+ const body = requireBody(request);
4477
+ const folderId = stringOrNull(body, "folderId");
4478
+ const record = await container2.files.move(request.user.id, id, folderId);
4479
+ return { file: toFileDTO(record) };
4480
+ });
4481
+ app2.post("/api/files/:id/trash", async (request) => {
4482
+ const { id } = request.params;
4483
+ const record = await container2.files.trash(request.user.id, id);
4484
+ return { file: toFileDTO(record) };
4485
+ });
4486
+ app2.post("/api/files/:id/restore", async (request) => {
4487
+ const { id } = request.params;
4488
+ const record = await container2.files.restore(request.user.id, id);
4489
+ return { file: toFileDTO(record) };
4490
+ });
4491
+ app2.delete("/api/files/:id", async (request, reply) => {
4492
+ const { id } = request.params;
4493
+ await container2.files.deletePermanently(request.user.id, id);
4494
+ return reply.status(204).send();
4495
+ });
4496
+ }
4497
+
4498
+ // ../../apps/api/src/routes/folders.ts
4499
+ function registerFolderRoutes(app2, container2) {
4500
+ app2.post("/api/folders", async (request, reply) => {
4501
+ const body = requireBody(request);
4502
+ const name = optionalString(body, "name");
4503
+ if (name === void 0) throw new ValidationError('Field "name" is required');
4504
+ const parentId = stringOrNull(body, "parentId");
4505
+ const record = await container2.folders.create(request.user.id, { name, parentId });
4506
+ return reply.status(201).send({ folder: toFolderDTO(record) });
4507
+ });
4508
+ app2.get("/api/folders", async (request) => {
4509
+ const raw = request.query;
4510
+ if (!("parentId" in raw) && !("status" in raw) && !("q" in raw) && !("starred" in raw)) {
4511
+ const folders = await container2.folders.listChildren(request.user.id, null);
4512
+ return {
4513
+ folders: folders.map(toFolderDTO),
4514
+ pagination: { page: 1, limit: folders.length, total: folders.length, hasMore: false }
4515
+ };
4516
+ }
4517
+ const { query, page, limit } = parseListQuery(raw);
4518
+ if ("parentId" in raw) {
4519
+ query.folderId = typeof raw.parentId === "string" && raw.parentId !== "" ? raw.parentId : null;
4520
+ }
4521
+ const result = await container2.folders.query(request.user.id, query, { page, limit });
4522
+ return {
4523
+ folders: result.items.map(toFolderDTO),
4524
+ pagination: { page, limit, total: result.total, hasMore: page * limit < result.total }
4525
+ };
4526
+ });
4527
+ app2.get("/api/folders/tree", async (request) => {
4528
+ const folders = await container2.folders.tree(request.user.id);
4529
+ return { folders: folders.map(toFolderDTO) };
4530
+ });
4531
+ app2.post("/api/folders/batch", async (request) => {
4532
+ const body = requireBody(request);
4533
+ const operation = requireEnum(body, "operation", BATCH_FOLDER_OPERATIONS);
4534
+ const ids = requireStringArray(body, "ids");
4535
+ const parentId = operation === "move" ? stringOrNull(body, "parentId") : void 0;
4536
+ let succeeded = 0;
4537
+ const errors = [];
4538
+ for (const id of ids) {
4539
+ try {
4540
+ switch (operation) {
4541
+ case "trash":
4542
+ await container2.folders.trash(request.user.id, id);
4543
+ break;
4544
+ case "restore":
4545
+ await container2.folders.restore(request.user.id, id);
4546
+ break;
4547
+ case "delete":
4548
+ await container2.folders.deletePermanently(request.user.id, id);
4549
+ break;
4550
+ case "star":
4551
+ await container2.folders.setStarred(request.user.id, id, true);
4552
+ break;
4553
+ case "unstar":
4554
+ await container2.folders.setStarred(request.user.id, id, false);
4555
+ break;
4556
+ case "move":
4557
+ await container2.folders.move(request.user.id, id, parentId ?? null);
4558
+ break;
4559
+ }
4560
+ succeeded += 1;
4561
+ } catch (error) {
4562
+ errors.push({
4563
+ id,
4564
+ code: "INVALID_REQUEST",
4565
+ message: error instanceof Error ? error.message : "Operation failed"
4566
+ });
4567
+ }
4568
+ }
4569
+ return { requested: ids.length, succeeded, failed: errors.length, errors };
4570
+ });
4571
+ app2.patch("/api/folders/:id", async (request) => {
4572
+ const { id } = request.params;
4573
+ const body = requireBody(request);
4574
+ const name = optionalString(body, "name");
4575
+ const starred = optionalBoolean(body, "starred");
4576
+ if (name === void 0 && starred === void 0) {
4577
+ throw new ValidationError('At least one of "name" or "starred" is required');
4578
+ }
4579
+ let record = await container2.folders.get(request.user.id, id);
4580
+ if (name !== void 0) record = await container2.folders.rename(request.user.id, id, name);
4581
+ if (starred !== void 0) {
4582
+ record = await container2.folders.setStarred(request.user.id, id, starred);
4583
+ }
4584
+ return { folder: toFolderDTO(record) };
4585
+ });
4586
+ app2.post("/api/folders/:id/move", async (request) => {
4587
+ const { id } = request.params;
4588
+ const body = requireBody(request);
4589
+ const parentId = stringOrNull(body, "parentId");
4590
+ const record = await container2.folders.move(request.user.id, id, parentId);
4591
+ return { folder: toFolderDTO(record) };
4592
+ });
4593
+ app2.post("/api/folders/:id/trash", async (request) => {
4594
+ const { id } = request.params;
4595
+ const result = await container2.folders.trash(request.user.id, id);
4596
+ return { affectedFolders: result.affectedFolders, affectedFiles: result.affectedFiles };
4597
+ });
4598
+ app2.post("/api/folders/:id/restore", async (request) => {
4599
+ const { id } = request.params;
4600
+ const result = await container2.folders.restore(request.user.id, id);
4601
+ return { affectedFolders: result.affectedFolders, affectedFiles: result.affectedFiles };
4602
+ });
4603
+ app2.delete("/api/folders/:id", async (request) => {
4604
+ const { id } = request.params;
4605
+ const result = await container2.folders.deletePermanently(request.user.id, id);
4606
+ return { affectedFolders: result.affectedFolders, affectedFiles: result.affectedFiles };
4607
+ });
4608
+ }
4609
+
4610
+ // ../../apps/api/src/routes/collections.ts
4611
+ function registerCollectionRoutes(app2, container2) {
4612
+ app2.get("/api/trash", async (request) => {
4613
+ const raw = request.query;
4614
+ const { query, page, limit } = parseListQuery(raw);
4615
+ const listing = await container2.trash.list(request.user.id, { page, limit }, query);
4616
+ return {
4617
+ files: listing.files.map(toFileDTO),
4618
+ folders: listing.folders.map(toFolderDTO),
4619
+ pagination: listing.pagination
4620
+ };
4621
+ });
4622
+ app2.post("/api/trash/empty", async (request) => {
4623
+ const result = await container2.trash.empty(request.user.id);
4624
+ return {
4625
+ deletedFiles: result.deletedFiles,
4626
+ deletedFolders: result.deletedFolders,
4627
+ failedFiles: result.failedFiles
4628
+ };
4629
+ });
4630
+ app2.get("/api/starred", async (request) => {
4631
+ const raw = request.query;
4632
+ const { query, page, limit } = parseListQuery(raw);
4633
+ const merged = { ...query, starred: true, folderId: void 0 };
4634
+ const [fileResult, folderResult] = await Promise.all([
4635
+ container2.files.query(request.user.id, merged, { page, limit }),
4636
+ container2.folders.query(request.user.id, merged, { page, limit })
4637
+ ]);
4638
+ const total = fileResult.total + folderResult.total;
4639
+ return {
4640
+ files: fileResult.items.map(toFileDTO),
4641
+ folders: folderResult.items.map(toFolderDTO),
4642
+ pagination: { page, limit, total, hasMore: page * limit < total }
4643
+ };
4644
+ });
4645
+ app2.get("/api/recent", async (request) => {
4646
+ const { page, limit } = parseListQuery(request.query);
4647
+ return container2.recent.list(request.user.id, { page, limit });
4648
+ });
4649
+ app2.get("/api/search", async (request) => {
4650
+ const raw = request.query;
4651
+ const query = typeof raw.q === "string" ? raw.q : "";
4652
+ if (query.trim() === "") {
4653
+ throw new ValidationError('Query parameter "q" is required');
4654
+ }
4655
+ const { page, limit } = parseListQuery(raw);
4656
+ const result = await container2.search.search(request.user.id, query, { page, limit });
4657
+ return {
4658
+ files: result.files.map(toFileDTO),
4659
+ folders: result.folders.map(toFolderDTO),
4660
+ pagination: result.pagination
4661
+ };
4662
+ });
4663
+ app2.get("/api/activity", async (request) => {
4664
+ const { page, limit } = parseListQuery(request.query);
4665
+ const result = await container2.activity.list(request.user.id, { page, limit });
4666
+ return {
4667
+ events: result.items.map(toActivityDTO),
4668
+ pagination: { page, limit, total: result.total, hasMore: page * limit < result.total }
4669
+ };
4670
+ });
4671
+ }
4672
+
4673
+ // ../../apps/api/src/health.ts
4674
+ function readiness(checks) {
4675
+ const required = checks.filter((check) => check.name !== "storage");
4676
+ const ready = required.every((check) => check.state === "healthy");
4677
+ return { ready, checks };
4678
+ }
4679
+ function encryptionCheck(configured) {
4680
+ return configured ? { name: "encryption", state: "healthy", detail: null } : {
4681
+ name: "encryption",
4682
+ state: "not_configured",
4683
+ detail: "OMNICLOUD_ENCRYPTION_KEY is not set; Telegram sessions are stored unsealed"
4684
+ };
4685
+ }
4686
+
4687
+ // ../../apps/api/src/routes/storage.ts
4688
+ var VERSION = "0.2.4";
4689
+ function registerStorageRoutes(app2, container2) {
4690
+ app2.get("/api/health", async () => {
4691
+ const database = await probeDatabase(container2);
4692
+ return {
4693
+ status: database,
4694
+ database,
4695
+ storage: "unknown",
4696
+ uptimeSeconds: Math.round(process.uptime()),
4697
+ version: VERSION
4698
+ };
4699
+ });
4700
+ app2.get("/api/health/live", async () => ({ status: "live" }));
4701
+ app2.get("/api/health/ready", async (_request, reply) => {
4702
+ const database = await probeDatabase(container2);
4703
+ const report = readiness([
4704
+ {
4705
+ name: "database",
4706
+ state: database === "healthy" ? "healthy" : "unhealthy",
4707
+ detail: database === "healthy" ? null : "database did not answer"
4708
+ },
4709
+ encryptionCheck(container2.config.encryptionKey !== null)
4710
+ ]);
4711
+ if (!report.ready) reply.status(503);
4712
+ return report;
4713
+ });
4714
+ app2.get("/api/health/database", async () => {
4715
+ const database = await probeDatabase(container2);
4716
+ return { status: database, database };
4717
+ });
4718
+ app2.post("/api/storage/ensure", async (request) => {
4719
+ const storage = await container2.connection.ensureStorage(request.user.id);
4720
+ return { storage: toStorageDTO(storage) };
4721
+ });
4722
+ app2.get("/api/storage/health", async (request) => {
4723
+ const health = await container2.storageHealth.health(request.user.id);
4724
+ const stats = await container2.stats.stats(request.user.id, container2.config.quotaBytes);
4725
+ return {
4726
+ health: toStorageHealthDTO({
4727
+ provider: "telegram",
4728
+ state: health.state,
4729
+ healthy: health.healthy,
4730
+ latencyMs: health.latencyMs,
4731
+ message: health.message,
4732
+ targetTitle: health.targetTitle
4733
+ }),
4734
+ stats: toStatsDTO(stats)
4735
+ };
4736
+ });
4737
+ app2.post("/api/storage/health/check", async (request) => {
4738
+ const body = request.body ?? {};
4739
+ const deep = optionalBoolean(body, "deep") ?? false;
4740
+ const health = await container2.storageHealth.health(request.user.id, deep);
4741
+ return {
4742
+ health: toStorageHealthDTO({
4743
+ provider: "telegram",
4744
+ state: health.state,
4745
+ healthy: health.healthy,
4746
+ latencyMs: health.latencyMs,
4747
+ message: health.message,
4748
+ targetTitle: health.targetTitle
4749
+ })
4750
+ };
4751
+ });
4752
+ app2.get("/api/storage/stats", async (request) => {
4753
+ const stats = await container2.stats.stats(request.user.id, container2.config.quotaBytes);
4754
+ return { stats: toStatsDTO(stats) };
4755
+ });
4756
+ app2.post("/api/storage/reconciliation/plan", async (request) => {
4757
+ const plan = await container2.reconciliationFlow.plan(request.user.id);
4758
+ return { plan };
4759
+ });
4760
+ app2.post("/api/storage/reconciliation/apply", async (request) => {
4761
+ const body = requireBody(request);
4762
+ const raw = body.approvedIds;
4763
+ const approvedIds = Array.isArray(raw) && raw.length === 0 ? [] : requireStringArray(body, "approvedIds");
4764
+ if (approvedIds.length > 200) throw new ValidationError("Approve at most 200 repairs at once");
4765
+ const result = await container2.reconciliationFlow.apply(request.user.id, approvedIds);
4766
+ return { result };
4767
+ });
4768
+ app2.post("/api/storage/reconciliation", async (request) => {
4769
+ const report = await container2.reconciliation.run(request.user.id);
4770
+ return { report };
4771
+ });
4772
+ app2.post("/api/storage/integrity/check", async (request) => {
4773
+ const body = requireBody(request);
4774
+ const deep = optionalBoolean(body, "deep") ?? false;
4775
+ const report = await container2.integrity.check(request.user.id, { deep });
4776
+ return { report };
4777
+ });
4778
+ }
4779
+ async function probeDatabase(container2) {
4780
+ try {
4781
+ await container2.repos.users.findById("__health_probe__");
4782
+ return "healthy";
4783
+ } catch {
4784
+ return "unavailable";
4785
+ }
4786
+ }
4787
+
4788
+ // ../../apps/api/src/app.ts
4789
+ function sendError(reply, status, code, message, requestId, details) {
4790
+ void reply.status(status).send({ error: { code, message, requestId, ...details ? { details } : {} } });
4791
+ }
4792
+ async function createApp(container2) {
4793
+ const app2 = Fastify({
4794
+ logger: LOGGER_OPTIONS(container2.config.logLevel),
4795
+ // JSON request bodies are tiny; uploads go through multipart, not bodyLimit.
4796
+ bodyLimit: 2 * 1024 * 1024,
4797
+ trustProxy: container2.config.trustProxy,
4798
+ genReqId: (request) => resolveRequestId(request.headers)
4799
+ });
4800
+ await app2.register(cookie);
4801
+ await app2.register(multipart, {
4802
+ limits: { fileSize: container2.config.maxUploadBytes, files: 1, parts: 10 }
4803
+ });
4804
+ app2.addHook("onRequest", async (request, reply) => {
4805
+ attachRequestId(request, reply);
4806
+ applySecurityHeaders(reply);
4807
+ });
4808
+ app2.addHook("preHandler", async (request) => {
4809
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
4810
+ const origin = request.headers.origin;
4811
+ const host = request.headers.host;
4812
+ if (!isAllowedOrigin(origin, host, container2.config.allowedOrigins)) {
4813
+ throw new ForbiddenError("Cross-origin request rejected");
4814
+ }
4815
+ }
4816
+ });
4817
+ registerAuthHook(app2, container2.repos, container2.authSessions);
4818
+ app2.setErrorHandler((error, request, reply) => {
4819
+ if (error instanceof DomainError) {
4820
+ sendError(reply, error.status, error.code, error.message, request.id, error.details);
4821
+ return reply;
4822
+ }
4823
+ const code = error.code ?? "";
4824
+ if (code === "FST_PART_FILE_TOO_LARGE" || code === "FST_ERR_CTP_BODY_TOO_LARGE") {
4825
+ sendError(reply, 413, "PAYLOAD_TOO_LARGE", "The uploaded file is too large", request.id);
4826
+ return reply;
4827
+ }
4828
+ if (code === "FST_REQ_FILE_TOO_LARGE") {
4829
+ sendError(reply, 413, "PAYLOAD_TOO_LARGE", "The uploaded file is too large", request.id);
4830
+ return reply;
4831
+ }
4832
+ if (typeof error.statusCode === "number") {
4833
+ sendError(reply, error.statusCode, "INVALID_REQUEST", error.message, request.id);
4834
+ return reply;
4835
+ }
4836
+ request.log.error({ err: error, requestId: request.id }, "Unhandled error");
4837
+ sendError(reply, 500, "INTERNAL_ERROR", "Internal server error", request.id);
4838
+ return reply;
4839
+ });
4840
+ registerAuthRoutes(app2, container2);
4841
+ registerFileRoutes(app2, container2);
4842
+ registerFolderRoutes(app2, container2);
4843
+ registerCollectionRoutes(app2, container2);
4844
+ registerStorageRoutes(app2, container2);
4845
+ const webDistDir = container2.config.webDistDir;
4846
+ if (webDistDir && existsSync(webDistDir)) {
4847
+ await app2.register(fastifyStatic, { root: resolve(webDistDir), wildcard: false });
4848
+ const indexHtml = readFileSync(join2(resolve(webDistDir), "index.html"), "utf8");
4849
+ const { scripts, styles } = inlineBlocks(indexHtml);
4850
+ const contentSecurityPolicy = buildContentSecurityPolicy(scripts, styles);
4851
+ app2.addHook("onSend", async (request, reply, payload) => {
4852
+ if (request.method === "GET" && !request.url.startsWith("/api/")) {
4853
+ reply.header("Content-Security-Policy", contentSecurityPolicy);
4854
+ }
4855
+ return payload;
4856
+ });
4857
+ app2.setNotFoundHandler((request, reply) => {
4858
+ if (request.method === "GET" && !request.url.startsWith("/api/")) {
4859
+ return reply.sendFile("index.html");
4860
+ }
4861
+ sendError(reply, 404, "NOT_FOUND", "Not found", request.id);
4862
+ return reply;
4863
+ });
4864
+ } else {
4865
+ app2.setNotFoundHandler((request, reply) => {
4866
+ sendError(reply, 404, "NOT_FOUND", "Not found", request.id);
4867
+ return reply;
4868
+ });
4869
+ }
4870
+ return app2;
4871
+ }
4872
+
4873
+ // ../../apps/api/src/index.ts
4874
+ var config = loadConfig();
4875
+ var operationLogTarget = { log: null };
4876
+ var container = buildContainer(config, {
4877
+ operationLog: {
4878
+ info: (record, message) => operationLogTarget.log?.info(record, message),
4879
+ warn: (record, message) => operationLogTarget.log?.warn(record, message)
4880
+ }
4881
+ });
4882
+ var app = await createApp(container);
4883
+ operationLogTarget.log = app.log;
4884
+ async function shutdown(signal) {
4885
+ app.log.info({ signal }, "Shutting down");
4886
+ await app.close();
4887
+ await container.shutdown();
4888
+ process.exit(0);
4889
+ }
4890
+ process.on("SIGINT", () => void shutdown("SIGINT"));
4891
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
4892
+ try {
4893
+ const staleSpools = await sweepStaleSpools().catch((error) => {
4894
+ app.log.warn({ err: error }, "Could not sweep stale upload spools");
4895
+ return 0;
4896
+ });
4897
+ if (staleSpools > 0)
4898
+ app.log.info({ staleSpools }, "Removed upload spools left by a stopped process");
4899
+ await app.listen({ port: config.port, host: config.host });
4900
+ const pruneSessions = () => container.authSessions.pruneExpired().then(
4901
+ (removed) => {
4902
+ if (removed > 0) app.log.info({ removed }, "Pruned expired browser sessions");
4903
+ },
4904
+ (error) => app.log.warn({ err: error }, "Could not prune expired sessions")
4905
+ );
4906
+ void pruneSessions();
4907
+ setInterval(pruneSessions, 60 * 60 * 1e3).unref();
4908
+ } catch (error) {
4909
+ app.log.error({ err: error }, "Failed to start");
4910
+ await container.shutdown();
4911
+ process.exit(1);
4912
+ }