@lacrous/omnicloud 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +610 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +220 -18
- package/dist/index.d.ts +220 -18
- package/dist/index.js +608 -98
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -193,6 +193,65 @@ var OmniCloudClient = class {
|
|
|
193
193
|
throw lastError;
|
|
194
194
|
}
|
|
195
195
|
/** @internal */
|
|
196
|
+
/**
|
|
197
|
+
* Opens a binary response as a stream. Bytes are delivered as they arrive, so
|
|
198
|
+
* memory does not grow with the file. The stream errors with DOWNLOAD_INCOMPLETE
|
|
199
|
+
* if the body ends before Content-Length.
|
|
200
|
+
*/
|
|
201
|
+
async requestStream(path, onProgress) {
|
|
202
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
203
|
+
headers: this.defaultHeaders,
|
|
204
|
+
credentials: "include"
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
const text = await response.text();
|
|
208
|
+
const error = safeJsonParse(text)?.error;
|
|
209
|
+
throw new OmniCloudError(
|
|
210
|
+
response.status,
|
|
211
|
+
error?.code ?? "DOWNLOAD_FAILED",
|
|
212
|
+
error?.message ?? `Download failed (${response.status})`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const total = Number(response.headers.get("content-length") ?? 0) || null;
|
|
216
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
217
|
+
const sha256 = response.headers.get("x-content-sha256");
|
|
218
|
+
const source = response.body;
|
|
219
|
+
if (!source) {
|
|
220
|
+
throw new OmniCloudError(0, "DOWNLOAD_FAILED", "The response has no body to stream");
|
|
221
|
+
}
|
|
222
|
+
let loaded = 0;
|
|
223
|
+
const reader = source.getReader();
|
|
224
|
+
const stream = new ReadableStream({
|
|
225
|
+
async pull(controller) {
|
|
226
|
+
const { done, value } = await reader.read();
|
|
227
|
+
if (done) {
|
|
228
|
+
if (total !== null && loaded !== total) {
|
|
229
|
+
controller.error(
|
|
230
|
+
new OmniCloudError(
|
|
231
|
+
0,
|
|
232
|
+
"DOWNLOAD_INCOMPLETE",
|
|
233
|
+
`Download ended after ${loaded} of ${total} bytes`
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
controller.close();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
loaded += value.byteLength;
|
|
242
|
+
onProgress?.({
|
|
243
|
+
loaded,
|
|
244
|
+
total,
|
|
245
|
+
percent: total ? Math.round(loaded / total * 100) : null
|
|
246
|
+
});
|
|
247
|
+
controller.enqueue(value);
|
|
248
|
+
},
|
|
249
|
+
cancel(reason) {
|
|
250
|
+
return reader.cancel(reason);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return { stream, contentType, size: total, sha256 };
|
|
254
|
+
}
|
|
196
255
|
async requestBinary(path, onProgress) {
|
|
197
256
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
198
257
|
headers: this.defaultHeaders,
|
|
@@ -216,6 +275,28 @@ var OmniCloudClient = class {
|
|
|
216
275
|
return { data, contentType, size: data.byteLength, sha256, integrityVerified };
|
|
217
276
|
}
|
|
218
277
|
};
|
|
278
|
+
async function toBlob(input) {
|
|
279
|
+
if (input.path !== void 0) {
|
|
280
|
+
const { readFile } = await import("fs/promises");
|
|
281
|
+
return new Blob([new Uint8Array(await readFile(input.path))]);
|
|
282
|
+
}
|
|
283
|
+
const data = input.data;
|
|
284
|
+
if (data === void 0) throw new OmniCloudError(0, "INVALID_REQUEST", "No upload content given");
|
|
285
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return data;
|
|
286
|
+
if (data instanceof Uint8Array) return new Blob([new Uint8Array(data)]);
|
|
287
|
+
const parts = [];
|
|
288
|
+
if (typeof data.getReader === "function") {
|
|
289
|
+
const reader = data.getReader();
|
|
290
|
+
for (; ; ) {
|
|
291
|
+
const { done, value } = await reader.read();
|
|
292
|
+
if (done) break;
|
|
293
|
+
parts.push(value);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
for await (const chunk of data) parts.push(chunk);
|
|
297
|
+
}
|
|
298
|
+
return new Blob(parts);
|
|
299
|
+
}
|
|
219
300
|
async function readBody(response, total, onProgress) {
|
|
220
301
|
const body = response.body;
|
|
221
302
|
if (!body) {
|
|
@@ -239,6 +320,13 @@ async function readBody(response, total, onProgress) {
|
|
|
239
320
|
});
|
|
240
321
|
}
|
|
241
322
|
}
|
|
323
|
+
if (total !== null && loaded !== total) {
|
|
324
|
+
throw new OmniCloudError(
|
|
325
|
+
0,
|
|
326
|
+
"DOWNLOAD_INCOMPLETE",
|
|
327
|
+
`Download ended after ${loaded} of ${total} bytes`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
242
330
|
const merged = new Uint8Array(loaded);
|
|
243
331
|
let offset = 0;
|
|
244
332
|
for (const chunk of chunks) {
|
|
@@ -288,9 +376,9 @@ var FilesApi = class {
|
|
|
288
376
|
}
|
|
289
377
|
throw lastError;
|
|
290
378
|
}
|
|
291
|
-
uploadOnce(input, options) {
|
|
379
|
+
async uploadOnce(input, options) {
|
|
292
380
|
const form = new FormData();
|
|
293
|
-
const blob =
|
|
381
|
+
const blob = await toBlob(input);
|
|
294
382
|
form.append("file", blob, input.name);
|
|
295
383
|
if (options.folderId) form.append("folderId", options.folderId);
|
|
296
384
|
if (typeof XMLHttpRequest === "function") {
|
|
@@ -350,6 +438,45 @@ var FilesApi = class {
|
|
|
350
438
|
async download(id, onProgress) {
|
|
351
439
|
return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
|
|
352
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Streams a file's bytes without holding the whole file in memory. Use this for
|
|
443
|
+
* large files. Read `stream` to the end: a truncated transfer errors instead of
|
|
444
|
+
* ending quietly.
|
|
445
|
+
*/
|
|
446
|
+
async downloadStream(id, onProgress) {
|
|
447
|
+
return this.client.requestStream(`/api/files/${id}/download`, onProgress);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Node only. Streams a file to `targetPath` through a `.part` file that is
|
|
451
|
+
* renamed into place only after the full length is verified. A truncated or
|
|
452
|
+
* failed transfer leaves nothing at `targetPath`.
|
|
453
|
+
*/
|
|
454
|
+
async downloadToFile(id, targetPath, onProgress) {
|
|
455
|
+
const { createWriteStream: createWriteStream2 } = await import("fs");
|
|
456
|
+
const { rename, rm: rm2 } = await import("fs/promises");
|
|
457
|
+
const { Readable: Readable3 } = await import("stream");
|
|
458
|
+
const { pipeline: pipeline2 } = await import("stream/promises");
|
|
459
|
+
const download = await this.downloadStream(id, onProgress);
|
|
460
|
+
const partPath = `${targetPath}.part`;
|
|
461
|
+
let bytes = 0;
|
|
462
|
+
try {
|
|
463
|
+
await pipeline2(
|
|
464
|
+
Readable3.fromWeb(download.stream),
|
|
465
|
+
async function* (source) {
|
|
466
|
+
for await (const chunk of source) {
|
|
467
|
+
bytes += chunk.byteLength;
|
|
468
|
+
yield chunk;
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
createWriteStream2(partPath)
|
|
472
|
+
);
|
|
473
|
+
await rename(partPath, targetPath);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
await rm2(partPath, { force: true });
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
return { path: targetPath, bytes, sha256: download.sha256 };
|
|
479
|
+
}
|
|
353
480
|
/** URL suitable for direct download (anchor href). */
|
|
354
481
|
downloadUrl(id) {
|
|
355
482
|
return `${this.client.baseUrl}/api/files/${id}/download`;
|
|
@@ -904,12 +1031,18 @@ var DomainError = class extends Error {
|
|
|
904
1031
|
code;
|
|
905
1032
|
status;
|
|
906
1033
|
details;
|
|
907
|
-
|
|
1034
|
+
/** Whether repeating the same operation may succeed. Defaults to false. */
|
|
1035
|
+
retryable = false;
|
|
1036
|
+
/** Server-requested delay before a retry, in seconds, when the provider gave one. */
|
|
1037
|
+
retryAfterSeconds;
|
|
1038
|
+
constructor(code, status, message, details, retry) {
|
|
908
1039
|
super(message);
|
|
909
1040
|
this.name = new.target.name;
|
|
910
1041
|
this.code = code;
|
|
911
1042
|
this.status = status;
|
|
912
1043
|
this.details = details;
|
|
1044
|
+
this.retryable = retry?.retryable ?? false;
|
|
1045
|
+
this.retryAfterSeconds = retry?.retryAfterSeconds;
|
|
913
1046
|
}
|
|
914
1047
|
};
|
|
915
1048
|
var ValidationError = class extends DomainError {
|
|
@@ -942,8 +1075,11 @@ var ConflictError = class extends DomainError {
|
|
|
942
1075
|
}
|
|
943
1076
|
};
|
|
944
1077
|
var RateLimitedError = class extends DomainError {
|
|
945
|
-
constructor(message = "Too many requests, please retry later") {
|
|
946
|
-
super(ERROR_CODES.RATE_LIMITED, 429, message
|
|
1078
|
+
constructor(message = "Too many requests, please retry later", options = {}) {
|
|
1079
|
+
super(ERROR_CODES.RATE_LIMITED, 429, message, void 0, {
|
|
1080
|
+
retryable: true,
|
|
1081
|
+
retryAfterSeconds: options.retryAfterSeconds
|
|
1082
|
+
});
|
|
947
1083
|
}
|
|
948
1084
|
};
|
|
949
1085
|
var StorageNotInitializedError = class extends DomainError {
|
|
@@ -958,7 +1094,7 @@ var TelegramAuthRequiredError = class extends DomainError {
|
|
|
958
1094
|
};
|
|
959
1095
|
var TelegramConnectionError = class extends DomainError {
|
|
960
1096
|
constructor(message = "Could not reach Telegram", details) {
|
|
961
|
-
super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
|
|
1097
|
+
super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details, { retryable: true });
|
|
962
1098
|
}
|
|
963
1099
|
};
|
|
964
1100
|
var TelegramFileNotFoundError = class extends DomainError {
|
|
@@ -971,6 +1107,11 @@ var UploadFailedError = class extends DomainError {
|
|
|
971
1107
|
super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
|
|
972
1108
|
}
|
|
973
1109
|
};
|
|
1110
|
+
var IntegrityCheckError = class extends DomainError {
|
|
1111
|
+
constructor(message = "Integrity check failed", details) {
|
|
1112
|
+
super(ERROR_CODES.INTEGRITY_CHECK_FAILED, 502, message, details);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
974
1115
|
var OperationCancelledError = class extends DomainError {
|
|
975
1116
|
constructor(message = "Operation cancelled") {
|
|
976
1117
|
super(ERROR_CODES.INVALID_REQUEST, 499, message);
|
|
@@ -983,8 +1124,23 @@ function mapProviderError(message, error) {
|
|
|
983
1124
|
}
|
|
984
1125
|
|
|
985
1126
|
// ../core/src/storage/engine.ts
|
|
1127
|
+
var import_node_crypto2 = require("crypto");
|
|
986
1128
|
var import_node_stream = require("stream");
|
|
987
1129
|
|
|
1130
|
+
// ../core/src/storage/retry.ts
|
|
1131
|
+
function isRetryableError(error) {
|
|
1132
|
+
if (error instanceof OperationCancelledError) return false;
|
|
1133
|
+
if (error instanceof DomainError) return error.retryable;
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
function retryDelayMs(error, attempt, baseDelayMs, random = Math.random) {
|
|
1137
|
+
if (error instanceof DomainError && error.retryAfterSeconds !== void 0) {
|
|
1138
|
+
return error.retryAfterSeconds * 1e3;
|
|
1139
|
+
}
|
|
1140
|
+
const ceiling = baseDelayMs * 2 ** (attempt - 1);
|
|
1141
|
+
return Math.floor(random() * ceiling);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
988
1144
|
// ../core/src/utils/hash.ts
|
|
989
1145
|
var import_node_crypto = require("crypto");
|
|
990
1146
|
function sha256Hex(data) {
|
|
@@ -993,15 +1149,6 @@ function sha256Hex(data) {
|
|
|
993
1149
|
|
|
994
1150
|
// ../core/src/storage/engine.ts
|
|
995
1151
|
var DEFAULT_RETRY_POLICY = { attempts: 3, baseDelayMs: 400 };
|
|
996
|
-
function isRetryable(error) {
|
|
997
|
-
if (error instanceof OperationCancelledError) return false;
|
|
998
|
-
const name = error instanceof Error ? error.name : "";
|
|
999
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1000
|
-
const code = error.code;
|
|
1001
|
-
if (name === "AbortError" || code === "ABORT_ERR") return false;
|
|
1002
|
-
if (/AUTH|PASSWORD|PHONE|FLOOD_WAIT|not found|invalid/i.test(message)) return false;
|
|
1003
|
-
return true;
|
|
1004
|
-
}
|
|
1005
1152
|
function delay(ms, signal) {
|
|
1006
1153
|
return new Promise((resolve, reject) => {
|
|
1007
1154
|
if (signal?.aborted) {
|
|
@@ -1037,16 +1184,30 @@ var StorageEngine = class {
|
|
|
1037
1184
|
* progress. Retries transient failures with exponential backoff.
|
|
1038
1185
|
*/
|
|
1039
1186
|
async upload(input, control = {}) {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1187
|
+
if (input.data) {
|
|
1188
|
+
const sha2562 = sha256Hex(input.data);
|
|
1189
|
+
const size2 = input.data.byteLength;
|
|
1190
|
+
control.onProgress?.({ transferred: 0, total: size2, percent: 0 });
|
|
1191
|
+
const stored2 = await this.withRetry(
|
|
1192
|
+
() => this.provider.put(input, control),
|
|
1193
|
+
control,
|
|
1194
|
+
(error) => new UploadFailedError("Upload failed", error)
|
|
1195
|
+
);
|
|
1196
|
+
control.onProgress?.({ transferred: size2, total: size2, percent: 100 });
|
|
1197
|
+
return { stored: stored2, sha256: sha2562, size: size2 };
|
|
1198
|
+
}
|
|
1199
|
+
const { path, size, sha256 } = input;
|
|
1200
|
+
if (!path || size === void 0 || !sha256) {
|
|
1201
|
+
throw new UploadFailedError("Upload input has neither data nor a complete spooled file");
|
|
1202
|
+
}
|
|
1203
|
+
control.onProgress?.({ transferred: 0, total: size, percent: 0 });
|
|
1043
1204
|
const stored = await this.withRetry(
|
|
1044
|
-
() => this.provider.put(
|
|
1205
|
+
() => this.provider.put(input, control),
|
|
1045
1206
|
control,
|
|
1046
1207
|
(error) => new UploadFailedError("Upload failed", error)
|
|
1047
1208
|
);
|
|
1048
|
-
control.onProgress?.({ transferred:
|
|
1049
|
-
return { stored, sha256, size
|
|
1209
|
+
control.onProgress?.({ transferred: size, total: size, percent: 100 });
|
|
1210
|
+
return { stored, sha256, size };
|
|
1050
1211
|
}
|
|
1051
1212
|
async download(ref, control = {}) {
|
|
1052
1213
|
return this.withRetry(
|
|
@@ -1055,14 +1216,41 @@ var StorageEngine = class {
|
|
|
1055
1216
|
(error) => mapProviderError("Download failed", error)
|
|
1056
1217
|
);
|
|
1057
1218
|
}
|
|
1058
|
-
/**
|
|
1059
|
-
|
|
1060
|
-
|
|
1219
|
+
/**
|
|
1220
|
+
* Streams the object and verifies its SHA-256 against `expectedSha256`.
|
|
1221
|
+
*
|
|
1222
|
+
* The last chunk is held back until the digest of everything before it has
|
|
1223
|
+
* been checked, so a corrupt object errors the stream before its final bytes
|
|
1224
|
+
* are released. A consumer therefore never sees a complete-looking body that
|
|
1225
|
+
* fails the checksum. Memory stays bounded to about two provider chunks.
|
|
1226
|
+
*/
|
|
1227
|
+
async downloadStream(ref, expectedSha256, control = {}) {
|
|
1228
|
+
const source = await this.withRetry(
|
|
1061
1229
|
() => this.provider.getStream(ref, control),
|
|
1062
1230
|
control,
|
|
1063
1231
|
(error) => mapProviderError("Download failed", error)
|
|
1064
1232
|
);
|
|
1065
|
-
|
|
1233
|
+
const hash = (0, import_node_crypto2.createHash)("sha256");
|
|
1234
|
+
let held = null;
|
|
1235
|
+
const gate = new import_node_stream.Transform({
|
|
1236
|
+
transform(chunk, _encoding, callback) {
|
|
1237
|
+
hash.update(chunk);
|
|
1238
|
+
if (held) this.push(held);
|
|
1239
|
+
held = chunk;
|
|
1240
|
+
callback();
|
|
1241
|
+
},
|
|
1242
|
+
flush(callback) {
|
|
1243
|
+
if (hash.digest("hex") !== expectedSha256) {
|
|
1244
|
+
callback(new IntegrityCheckError("Downloaded content does not match its checksum"));
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (held) this.push(held);
|
|
1248
|
+
callback();
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
source.once("error", (error) => gate.destroy(error));
|
|
1252
|
+
source.pipe(gate);
|
|
1253
|
+
return gate;
|
|
1066
1254
|
}
|
|
1067
1255
|
async remove(ref) {
|
|
1068
1256
|
await this.withRetry(
|
|
@@ -1078,6 +1266,14 @@ var StorageEngine = class {
|
|
|
1078
1266
|
throw mapProviderError("Storage lookup failed", error);
|
|
1079
1267
|
}
|
|
1080
1268
|
}
|
|
1269
|
+
/** Read-only listing of stored objects, for reconciliation. Never modifies anything. */
|
|
1270
|
+
async list(limit) {
|
|
1271
|
+
try {
|
|
1272
|
+
return await this.provider.listObjects(limit);
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
throw mapProviderError("Storage listing failed", error);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1081
1277
|
async stat(ref) {
|
|
1082
1278
|
try {
|
|
1083
1279
|
return await this.provider.stat(ref);
|
|
@@ -1101,18 +1297,6 @@ var StorageEngine = class {
|
|
|
1101
1297
|
return sha256Hex(data) === expectedSha256;
|
|
1102
1298
|
}
|
|
1103
1299
|
// ── internals ────────────────────────────────────────────────────────────
|
|
1104
|
-
/** Materializes an upload input so the checksum can be computed. */
|
|
1105
|
-
async readInput(input) {
|
|
1106
|
-
if (input.data) return input.data;
|
|
1107
|
-
if (!input.stream) {
|
|
1108
|
-
throw new UploadFailedError("Upload input has neither data nor a stream");
|
|
1109
|
-
}
|
|
1110
|
-
const chunks = [];
|
|
1111
|
-
for await (const chunk of input.stream) {
|
|
1112
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1113
|
-
}
|
|
1114
|
-
return Buffer.concat(chunks);
|
|
1115
|
-
}
|
|
1116
1300
|
async withRetry(operation, control, wrapError) {
|
|
1117
1301
|
const { attempts, baseDelayMs } = this.retryPolicy;
|
|
1118
1302
|
let lastError;
|
|
@@ -1122,8 +1306,8 @@ var StorageEngine = class {
|
|
|
1122
1306
|
return await operation();
|
|
1123
1307
|
} catch (error) {
|
|
1124
1308
|
lastError = error;
|
|
1125
|
-
if (attempt >= attempts || !
|
|
1126
|
-
await delay(
|
|
1309
|
+
if (attempt >= attempts || !isRetryableError(error)) break;
|
|
1310
|
+
await delay(retryDelayMs(error, attempt, baseDelayMs), control.signal);
|
|
1127
1311
|
}
|
|
1128
1312
|
}
|
|
1129
1313
|
throw wrapError(lastError);
|
|
@@ -1170,6 +1354,51 @@ async function resolveItemQuery(folders, userId, input) {
|
|
|
1170
1354
|
return query;
|
|
1171
1355
|
}
|
|
1172
1356
|
|
|
1357
|
+
// ../core/src/services/auth-session-service.ts
|
|
1358
|
+
var import_node_crypto3 = require("crypto");
|
|
1359
|
+
|
|
1360
|
+
// ../core/src/services/upload-operation.ts
|
|
1361
|
+
function isValidOperationId(id) {
|
|
1362
|
+
return /^[A-Za-z0-9_-]{8,64}$/.test(id);
|
|
1363
|
+
}
|
|
1364
|
+
var OperationAlreadyExistsError = class extends Error {
|
|
1365
|
+
constructor() {
|
|
1366
|
+
super("An upload with this operation id already exists");
|
|
1367
|
+
this.name = "OperationAlreadyExistsError";
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
// ../core/src/utils/operation-log.ts
|
|
1372
|
+
async function timedOperation(sink, base, run, now = Date.now) {
|
|
1373
|
+
const started = now();
|
|
1374
|
+
const emit = (status, errorCode) => {
|
|
1375
|
+
if (!sink) return;
|
|
1376
|
+
const record = {
|
|
1377
|
+
operation: base.operation,
|
|
1378
|
+
userId: base.userId,
|
|
1379
|
+
resourceId: base.resourceId ?? null,
|
|
1380
|
+
sizeBytes: base.sizeBytes ?? null,
|
|
1381
|
+
durationMs: now() - started,
|
|
1382
|
+
status,
|
|
1383
|
+
errorCode
|
|
1384
|
+
};
|
|
1385
|
+
try {
|
|
1386
|
+
if (status === "ok") sink.info({ ...record }, `${base.operation}.completed`);
|
|
1387
|
+
else sink.warn({ ...record }, `${base.operation}.failed`);
|
|
1388
|
+
} catch {
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
try {
|
|
1392
|
+
const result = await run();
|
|
1393
|
+
emit("ok", null);
|
|
1394
|
+
return result;
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
const code = error.code ?? "INTERNAL_ERROR";
|
|
1397
|
+
emit("error", code);
|
|
1398
|
+
throw error;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1173
1402
|
// ../core/src/services/activity-service.ts
|
|
1174
1403
|
var ActivityService = class {
|
|
1175
1404
|
constructor(activity, retentionDays = 90) {
|
|
@@ -1233,18 +1462,120 @@ function sanitizeFileName(name) {
|
|
|
1233
1462
|
|
|
1234
1463
|
// ../core/src/services/file-service.ts
|
|
1235
1464
|
var FileService = class {
|
|
1236
|
-
constructor(files, folders, engineFor, activity = noopActivityRecorder) {
|
|
1465
|
+
constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
|
|
1237
1466
|
this.files = files;
|
|
1238
1467
|
this.folders = folders;
|
|
1239
1468
|
this.engineFor = engineFor;
|
|
1240
1469
|
this.activity = activity;
|
|
1470
|
+
this.operations = operations;
|
|
1471
|
+
this.log = log;
|
|
1241
1472
|
}
|
|
1242
1473
|
files;
|
|
1243
1474
|
folders;
|
|
1244
1475
|
engineFor;
|
|
1245
1476
|
activity;
|
|
1477
|
+
operations;
|
|
1478
|
+
log;
|
|
1246
1479
|
// ── Upload ───────────────────────────────────────────────────────────────
|
|
1247
1480
|
async upload(userId, input) {
|
|
1481
|
+
return timedOperation(
|
|
1482
|
+
this.log,
|
|
1483
|
+
{
|
|
1484
|
+
operation: "upload",
|
|
1485
|
+
userId,
|
|
1486
|
+
resourceId: input.replaceFileId ?? null,
|
|
1487
|
+
sizeBytes: input.spooled?.size ?? input.data?.byteLength ?? null
|
|
1488
|
+
},
|
|
1489
|
+
() => input.operationId && this.operations ? this.uploadOnce(userId, input, input.operationId, this.operations) : this.performUpload(userId, input)
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Idempotent upload keyed by a client-supplied operation id. A retry never
|
|
1494
|
+
* writes a second Telegram object: a completed operation returns its file, and
|
|
1495
|
+
* an operation whose object was already stored is committed without uploading.
|
|
1496
|
+
*/
|
|
1497
|
+
async uploadOnce(userId, input, operationId, operations) {
|
|
1498
|
+
if (!isValidOperationId(operationId)) {
|
|
1499
|
+
throw new ValidationError("Invalid upload operation id");
|
|
1500
|
+
}
|
|
1501
|
+
let op = await operations.findByOperationId(userId, operationId);
|
|
1502
|
+
if (op?.status === "COMPLETED" && op.fileId) {
|
|
1503
|
+
return this.get(userId, op.fileId);
|
|
1504
|
+
}
|
|
1505
|
+
if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
|
|
1506
|
+
return this.commitStoredObject(userId, input, operations, op);
|
|
1507
|
+
}
|
|
1508
|
+
if (!op) {
|
|
1509
|
+
try {
|
|
1510
|
+
op = await operations.create({ userId, operationId });
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
if (!(error instanceof OperationAlreadyExistsError)) throw error;
|
|
1513
|
+
const outcome = await this.followWinner(userId, input, operationId, operations);
|
|
1514
|
+
if (outcome) return outcome;
|
|
1515
|
+
throw new ConflictError("A concurrent upload with this operation id is still in progress");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const claimed = await operations.claim(op.id, "PENDING", "UPLOADING") || await operations.claim(op.id, "FAILED", "UPLOADING");
|
|
1519
|
+
if (!claimed) {
|
|
1520
|
+
const outcome = await this.followWinner(userId, input, operationId, operations);
|
|
1521
|
+
if (outcome) return outcome;
|
|
1522
|
+
throw new ConflictError("A concurrent upload with this operation id is still in progress");
|
|
1523
|
+
}
|
|
1524
|
+
try {
|
|
1525
|
+
const record = await this.performUpload(userId, input, {
|
|
1526
|
+
onStored: async (stored) => {
|
|
1527
|
+
await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
|
|
1531
|
+
return record;
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
await operations.update(op.id, {
|
|
1534
|
+
status: "FAILED",
|
|
1535
|
+
error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
|
|
1536
|
+
});
|
|
1537
|
+
throw error;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Follows an operation another request is driving, for a bounded time. Returns
|
|
1542
|
+
* the finished file if the winner completes, or the stored object's commit if
|
|
1543
|
+
* it has stored one. Returns null when the winner is still in progress after the
|
|
1544
|
+
* budget, so the caller can report a conflict instead of waiting forever.
|
|
1545
|
+
*/
|
|
1546
|
+
async followWinner(userId, input, operationId, operations) {
|
|
1547
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1548
|
+
const current = await operations.findByOperationId(userId, operationId);
|
|
1549
|
+
if (current?.status === "COMPLETED" && current.fileId) {
|
|
1550
|
+
return this.get(userId, current.fileId);
|
|
1551
|
+
}
|
|
1552
|
+
if (current?.status === "FAILED") {
|
|
1553
|
+
return null;
|
|
1554
|
+
}
|
|
1555
|
+
if (current?.status === "UPLOADING" && current.telegramMessageId !== null) {
|
|
1556
|
+
return this.commitStoredObject(userId, input, operations, current);
|
|
1557
|
+
}
|
|
1558
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1559
|
+
}
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
/** Commits metadata for an object that is already in Telegram. */
|
|
1563
|
+
async commitStoredObject(userId, input, operations, op) {
|
|
1564
|
+
const name = sanitizeFileName(input.name);
|
|
1565
|
+
const mimeType = mimeFromFilename(name);
|
|
1566
|
+
const size = op.size ?? input.spooled?.size ?? input.data?.byteLength ?? 0;
|
|
1567
|
+
const sha256 = op.sha256 ?? input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
|
|
1568
|
+
const record = await this.commitRecord(userId, input, {
|
|
1569
|
+
name,
|
|
1570
|
+
mimeType,
|
|
1571
|
+
size,
|
|
1572
|
+
sha256,
|
|
1573
|
+
telegramMessageId: op.telegramMessageId
|
|
1574
|
+
});
|
|
1575
|
+
await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
|
|
1576
|
+
return record;
|
|
1577
|
+
}
|
|
1578
|
+
async performUpload(userId, input, hooks = {}) {
|
|
1248
1579
|
const name = sanitizeFileName(input.name);
|
|
1249
1580
|
if (!name) throw new ValidationError("Invalid file name");
|
|
1250
1581
|
let folderId = input.folderId;
|
|
@@ -1260,11 +1591,36 @@ var FileService = class {
|
|
|
1260
1591
|
onProgress: input.onProgress,
|
|
1261
1592
|
signal: input.signal
|
|
1262
1593
|
};
|
|
1594
|
+
if (!input.data && !input.spooled) {
|
|
1595
|
+
throw new ValidationError("An upload needs content");
|
|
1596
|
+
}
|
|
1263
1597
|
const { stored, sha256, size } = await engine.upload(
|
|
1264
|
-
|
|
1598
|
+
input.spooled ? {
|
|
1599
|
+
name,
|
|
1600
|
+
mimeType,
|
|
1601
|
+
path: input.spooled.path,
|
|
1602
|
+
size: input.spooled.size,
|
|
1603
|
+
sha256: input.spooled.sha256
|
|
1604
|
+
} : { name, mimeType, data: input.data },
|
|
1265
1605
|
control
|
|
1266
1606
|
);
|
|
1267
|
-
|
|
1607
|
+
await hooks.onStored?.(stored);
|
|
1608
|
+
try {
|
|
1609
|
+
return await this.commitRecord(userId, input, {
|
|
1610
|
+
name,
|
|
1611
|
+
mimeType,
|
|
1612
|
+
size,
|
|
1613
|
+
sha256,
|
|
1614
|
+
telegramMessageId: Number(stored.messageId)
|
|
1615
|
+
});
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
await engine.remove({ messageId: String(stored.messageId) }).catch(() => void 0);
|
|
1618
|
+
throw error;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/** Persists the file (or the new version of a file) for an object already stored. */
|
|
1622
|
+
async commitRecord(userId, input, next) {
|
|
1623
|
+
const { name, mimeType, size, sha256, telegramMessageId } = next;
|
|
1268
1624
|
if (input.replaceFileId) {
|
|
1269
1625
|
return this.replaceVersion(userId, input.replaceFileId, {
|
|
1270
1626
|
name,
|
|
@@ -1274,6 +1630,10 @@ var FileService = class {
|
|
|
1274
1630
|
telegramMessageId
|
|
1275
1631
|
});
|
|
1276
1632
|
}
|
|
1633
|
+
const folderId = input.folderId;
|
|
1634
|
+
if (folderId !== null) {
|
|
1635
|
+
await this.assertFolder(userId, folderId);
|
|
1636
|
+
}
|
|
1277
1637
|
const record = await this.files.create({
|
|
1278
1638
|
userId,
|
|
1279
1639
|
folderId,
|
|
@@ -1447,16 +1807,20 @@ var FileService = class {
|
|
|
1447
1807
|
* retry, rather than silently orphaning the storage object.
|
|
1448
1808
|
*/
|
|
1449
1809
|
async deletePermanently(userId, id) {
|
|
1810
|
+
return timedOperation(
|
|
1811
|
+
this.log,
|
|
1812
|
+
{ operation: "delete", userId, resourceId: id },
|
|
1813
|
+
() => this.performPermanentDelete(userId, id)
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
async performPermanentDelete(userId, id) {
|
|
1450
1817
|
const record = await this.get(userId, id);
|
|
1451
1818
|
const engine = await this.engineFor(userId);
|
|
1452
|
-
await engine.remove({ messageId: String(record.telegramMessageId) });
|
|
1453
1819
|
const versions = await this.files.listVersions(id);
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
} catch {
|
|
1459
|
-
}
|
|
1820
|
+
const messageIds = /* @__PURE__ */ new Set([String(record.telegramMessageId)]);
|
|
1821
|
+
for (const version of versions) messageIds.add(String(version.telegramMessageId));
|
|
1822
|
+
for (const messageId of messageIds) {
|
|
1823
|
+
await engine.remove({ messageId });
|
|
1460
1824
|
}
|
|
1461
1825
|
await this.files.deleteVersionsByFileIds([id]);
|
|
1462
1826
|
await this.files.deleteMany([id]);
|
|
@@ -1491,6 +1855,36 @@ var FileService = class {
|
|
|
1491
1855
|
});
|
|
1492
1856
|
return { record, data, integrityVerified };
|
|
1493
1857
|
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Streaming download for large files. The caller must consume `stream` to
|
|
1860
|
+
* the end and then await `verified` to learn whether the bytes match the
|
|
1861
|
+
* stored checksum. The download activity is recorded when the stream opens.
|
|
1862
|
+
*/
|
|
1863
|
+
async downloadStreamed(userId, id, control = {}) {
|
|
1864
|
+
return timedOperation(
|
|
1865
|
+
this.log,
|
|
1866
|
+
{ operation: "download", userId, resourceId: id },
|
|
1867
|
+
() => this.performStreamedDownload(userId, id, control)
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
async performStreamedDownload(userId, id, control) {
|
|
1871
|
+
const record = await this.getActive(userId, id);
|
|
1872
|
+
const engine = await this.engineFor(userId);
|
|
1873
|
+
const stream = await engine.downloadStream(
|
|
1874
|
+
{ messageId: String(record.telegramMessageId) },
|
|
1875
|
+
record.sha256,
|
|
1876
|
+
control
|
|
1877
|
+
);
|
|
1878
|
+
await this.activity.record({
|
|
1879
|
+
userId,
|
|
1880
|
+
action: "download",
|
|
1881
|
+
resourceType: "file",
|
|
1882
|
+
resourceId: id,
|
|
1883
|
+
resourceName: record.name,
|
|
1884
|
+
metadata: { size: record.size, streamed: true }
|
|
1885
|
+
});
|
|
1886
|
+
return { record, stream };
|
|
1887
|
+
}
|
|
1494
1888
|
/** Records an "open" event (the UI opening the details panel, say). */
|
|
1495
1889
|
async touch(userId, id, action = "open") {
|
|
1496
1890
|
const record = await this.get(userId, id);
|
|
@@ -1542,7 +1936,13 @@ var FileService = class {
|
|
|
1542
1936
|
errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
|
|
1543
1937
|
}
|
|
1544
1938
|
}
|
|
1545
|
-
return {
|
|
1939
|
+
return {
|
|
1940
|
+
...options.operationId ? { operationId: options.operationId } : {},
|
|
1941
|
+
requested: ids.length,
|
|
1942
|
+
succeeded,
|
|
1943
|
+
failed: errors.length,
|
|
1944
|
+
errors
|
|
1945
|
+
};
|
|
1546
1946
|
}
|
|
1547
1947
|
// ── internals ────────────────────────────────────────────────────────────
|
|
1548
1948
|
async assertFolder(userId, folderId) {
|
|
@@ -1636,7 +2036,7 @@ var FolderService = class {
|
|
|
1636
2036
|
cursor = parent.parentId;
|
|
1637
2037
|
}
|
|
1638
2038
|
}
|
|
1639
|
-
const updated = await this.folders.
|
|
2039
|
+
const updated = await this.folders.moveSafely(id, newParentId);
|
|
1640
2040
|
await this.activity.record({
|
|
1641
2041
|
userId,
|
|
1642
2042
|
action: "move",
|
|
@@ -1697,7 +2097,7 @@ var FolderService = class {
|
|
|
1697
2097
|
let parentId = record.parentId;
|
|
1698
2098
|
if (parentId !== null) {
|
|
1699
2099
|
const parent = await this.folders.findById(parentId);
|
|
1700
|
-
if (!parent || parent.deletedAt) parentId = null;
|
|
2100
|
+
if (!parent || parent.userId !== userId || parent.deletedAt) parentId = null;
|
|
1701
2101
|
}
|
|
1702
2102
|
const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
|
|
1703
2103
|
const batchId = record.trashBatchId;
|
|
@@ -1994,10 +2394,28 @@ var IntegrityService = class {
|
|
|
1994
2394
|
const engine = await this.engineFor(userId);
|
|
1995
2395
|
const issues = [];
|
|
1996
2396
|
let healthy = 0;
|
|
2397
|
+
const versionsByFile = await this.files.listVersionsForFiles(targets.map((file) => file.id));
|
|
1997
2398
|
for (const file of targets) {
|
|
1998
2399
|
const issue = await this.inspect(file, options.deep ?? false, engine);
|
|
1999
2400
|
if (issue) issues.push(issue);
|
|
2000
2401
|
else healthy += 1;
|
|
2402
|
+
for (const version of versionsByFile.get(file.id) ?? []) {
|
|
2403
|
+
if (version.telegramMessageId === file.telegramMessageId) continue;
|
|
2404
|
+
const versionIssue = await this.inspectObject(
|
|
2405
|
+
{
|
|
2406
|
+
id: file.id,
|
|
2407
|
+
name: file.name,
|
|
2408
|
+
telegramMessageId: version.telegramMessageId,
|
|
2409
|
+
size: version.size,
|
|
2410
|
+
sha256: version.sha256
|
|
2411
|
+
},
|
|
2412
|
+
options.deep ?? false,
|
|
2413
|
+
engine,
|
|
2414
|
+
version.id
|
|
2415
|
+
);
|
|
2416
|
+
if (versionIssue) issues.push(versionIssue);
|
|
2417
|
+
else healthy += 1;
|
|
2418
|
+
}
|
|
2001
2419
|
}
|
|
2002
2420
|
const missing = issues.filter((issue) => issue.kind === "missing").length;
|
|
2003
2421
|
const inconsistent = issues.filter(
|
|
@@ -2016,53 +2434,69 @@ var IntegrityService = class {
|
|
|
2016
2434
|
};
|
|
2017
2435
|
}
|
|
2018
2436
|
async inspect(file, deep, engine) {
|
|
2019
|
-
|
|
2437
|
+
return this.inspectObject(
|
|
2438
|
+
{
|
|
2439
|
+
id: file.id,
|
|
2440
|
+
name: file.name,
|
|
2441
|
+
telegramMessageId: file.telegramMessageId,
|
|
2442
|
+
size: file.size,
|
|
2443
|
+
sha256: file.sha256
|
|
2444
|
+
},
|
|
2445
|
+
deep,
|
|
2446
|
+
engine,
|
|
2447
|
+
void 0
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
async inspectObject(target, deep, engine, versionId) {
|
|
2451
|
+
const ref = { messageId: String(target.telegramMessageId) };
|
|
2452
|
+
const withVersion = (issue) => versionId ? { ...issue, versionId } : issue;
|
|
2020
2453
|
let stat;
|
|
2021
2454
|
try {
|
|
2022
2455
|
stat = await engine.stat(ref);
|
|
2023
2456
|
} catch (error) {
|
|
2024
|
-
return {
|
|
2025
|
-
fileId:
|
|
2026
|
-
name:
|
|
2457
|
+
return withVersion({
|
|
2458
|
+
fileId: target.id,
|
|
2459
|
+
name: target.name,
|
|
2027
2460
|
kind: "unreadable",
|
|
2028
2461
|
detail: error instanceof Error ? error.message : "Storage lookup failed"
|
|
2029
|
-
};
|
|
2462
|
+
});
|
|
2030
2463
|
}
|
|
2031
2464
|
if (!stat) {
|
|
2032
|
-
return {
|
|
2033
|
-
fileId:
|
|
2034
|
-
name:
|
|
2465
|
+
return withVersion({
|
|
2466
|
+
fileId: target.id,
|
|
2467
|
+
name: target.name,
|
|
2035
2468
|
kind: "missing",
|
|
2036
2469
|
detail: "No message with this id exists in the storage channel"
|
|
2037
|
-
};
|
|
2470
|
+
});
|
|
2038
2471
|
}
|
|
2039
|
-
if (stat.size !==
|
|
2040
|
-
return {
|
|
2041
|
-
fileId:
|
|
2042
|
-
name:
|
|
2472
|
+
if (stat.size !== target.size) {
|
|
2473
|
+
return withVersion({
|
|
2474
|
+
fileId: target.id,
|
|
2475
|
+
name: target.name,
|
|
2043
2476
|
kind: "size_mismatch",
|
|
2044
|
-
detail: `metadata=${
|
|
2045
|
-
};
|
|
2477
|
+
detail: `metadata=${target.size} storage=${stat.size}`
|
|
2478
|
+
});
|
|
2046
2479
|
}
|
|
2047
2480
|
if (deep) {
|
|
2048
2481
|
try {
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2482
|
+
const stream = await engine.downloadStream(ref, target.sha256);
|
|
2483
|
+
for await (const _chunk of stream) {
|
|
2484
|
+
}
|
|
2485
|
+
} catch (error) {
|
|
2486
|
+
if (error instanceof IntegrityCheckError) {
|
|
2487
|
+
return withVersion({
|
|
2488
|
+
fileId: target.id,
|
|
2489
|
+
name: target.name,
|
|
2055
2490
|
kind: "hash_mismatch",
|
|
2056
2491
|
detail: "Downloaded bytes do not match the stored SHA-256"
|
|
2057
|
-
};
|
|
2492
|
+
});
|
|
2058
2493
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
name: file.name,
|
|
2494
|
+
return withVersion({
|
|
2495
|
+
fileId: target.id,
|
|
2496
|
+
name: target.name,
|
|
2063
2497
|
kind: "unreadable",
|
|
2064
2498
|
detail: error instanceof Error ? error.message : "Download failed"
|
|
2065
|
-
};
|
|
2499
|
+
});
|
|
2066
2500
|
}
|
|
2067
2501
|
}
|
|
2068
2502
|
return null;
|
|
@@ -2129,14 +2563,26 @@ var RecentService = class {
|
|
|
2129
2563
|
}
|
|
2130
2564
|
};
|
|
2131
2565
|
|
|
2566
|
+
// ../core/src/utils/spool.ts
|
|
2567
|
+
var import_node_crypto4 = require("crypto");
|
|
2568
|
+
var import_node_fs = require("fs");
|
|
2569
|
+
var import_promises = require("fs/promises");
|
|
2570
|
+
var import_node_os = require("os");
|
|
2571
|
+
var import_node_path = require("path");
|
|
2572
|
+
var import_node_stream2 = require("stream");
|
|
2573
|
+
var import_promises2 = require("stream/promises");
|
|
2574
|
+
|
|
2575
|
+
// ../core/src/utils/secret-box.ts
|
|
2576
|
+
var import_node_crypto5 = require("crypto");
|
|
2577
|
+
|
|
2132
2578
|
// ../telegram/src/client.ts
|
|
2133
2579
|
var import_telegram = require("telegram");
|
|
2134
2580
|
var import_sessions = require("telegram/sessions/index.js");
|
|
2135
2581
|
|
|
2136
2582
|
// ../telegram/src/errors.ts
|
|
2137
|
-
var
|
|
2583
|
+
var import_errors12 = require("telegram/errors/index.js");
|
|
2138
2584
|
function isTelegramRpcError(error) {
|
|
2139
|
-
return error instanceof
|
|
2585
|
+
return error instanceof import_errors12.RPCError;
|
|
2140
2586
|
}
|
|
2141
2587
|
function isPasswordRequiredError(error) {
|
|
2142
2588
|
return isTelegramRpcError(error) && error.errorMessage === "SESSION_PASSWORD_NEEDED";
|
|
@@ -2147,7 +2593,9 @@ function mapTelegramError(error, fallbackMessage) {
|
|
|
2147
2593
|
const msg = error.errorMessage ?? "";
|
|
2148
2594
|
if (msg.startsWith("FLOOD_WAIT_")) {
|
|
2149
2595
|
const seconds = Number(msg.slice("FLOOD_WAIT_".length)) || 60;
|
|
2150
|
-
return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds
|
|
2596
|
+
return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`, {
|
|
2597
|
+
retryAfterSeconds: seconds
|
|
2598
|
+
});
|
|
2151
2599
|
}
|
|
2152
2600
|
switch (msg) {
|
|
2153
2601
|
case "PHONE_NUMBER_INVALID":
|
|
@@ -2430,11 +2878,12 @@ function extractCreatedChannel(result) {
|
|
|
2430
2878
|
}
|
|
2431
2879
|
|
|
2432
2880
|
// ../telegram/src/provider.ts
|
|
2433
|
-
var
|
|
2881
|
+
var import_node_stream3 = require("stream");
|
|
2434
2882
|
var import_telegram2 = require("telegram");
|
|
2435
2883
|
var import_big_integer = __toESM(require("big-integer"), 1);
|
|
2436
2884
|
var import_uploads = require("telegram/client/uploads.js");
|
|
2437
2885
|
var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
2886
|
+
var DOWNLOAD_CHUNK_BYTES = 512 * 1024;
|
|
2438
2887
|
var TelegramStorageProvider = class {
|
|
2439
2888
|
constructor(client, options) {
|
|
2440
2889
|
this.client = client;
|
|
@@ -2450,17 +2899,19 @@ var TelegramStorageProvider = class {
|
|
|
2450
2899
|
title;
|
|
2451
2900
|
// ── Upload ───────────────────────────────────────────────────────────────
|
|
2452
2901
|
async put(input, control = {}) {
|
|
2453
|
-
const
|
|
2454
|
-
if (data
|
|
2902
|
+
const total = input.data ? input.data.byteLength : input.size;
|
|
2903
|
+
if (total === void 0 || !input.data && !input.path) {
|
|
2904
|
+
throw new UploadFailedError("Upload input has neither data nor a spooled file");
|
|
2905
|
+
}
|
|
2906
|
+
if (total > MAX_DOCUMENT_BYTES) {
|
|
2455
2907
|
throw new UploadFailedError(
|
|
2456
2908
|
`Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
|
|
2457
2909
|
);
|
|
2458
2910
|
}
|
|
2459
2911
|
this.throwIfAborted(control.signal);
|
|
2460
|
-
const total = data.byteLength;
|
|
2461
2912
|
control.onProgress?.({ transferred: 0, total, percent: 0 });
|
|
2462
2913
|
try {
|
|
2463
|
-
const file = new import_uploads.CustomFile(input.name, total, "", data);
|
|
2914
|
+
const file = input.data ? new import_uploads.CustomFile(input.name, total, "", input.data) : new import_uploads.CustomFile(input.name, total, input.path);
|
|
2464
2915
|
const message = await this.client.sendFile(this.peer, {
|
|
2465
2916
|
file,
|
|
2466
2917
|
forceDocument: true,
|
|
@@ -2531,9 +2982,47 @@ var TelegramStorageProvider = class {
|
|
|
2531
2982
|
});
|
|
2532
2983
|
return buffer;
|
|
2533
2984
|
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Streams the object in Telegram-sized chunks, so memory use stays bounded
|
|
2987
|
+
* by one chunk regardless of file size. The first request happens here so
|
|
2988
|
+
* that a missing message fails before the caller starts consuming bytes.
|
|
2989
|
+
*/
|
|
2534
2990
|
async getStream(ref, control = {}) {
|
|
2535
|
-
const
|
|
2536
|
-
|
|
2991
|
+
const message = await this.requireMessage(ref);
|
|
2992
|
+
this.throwIfAborted(control.signal);
|
|
2993
|
+
const size = this.documentSize(message);
|
|
2994
|
+
const media = message.media;
|
|
2995
|
+
if (size === null || !media) {
|
|
2996
|
+
throw new TelegramFileNotFoundError("Telegram did not return file content");
|
|
2997
|
+
}
|
|
2998
|
+
const iterator = this.client.iterDownload({
|
|
2999
|
+
file: media,
|
|
3000
|
+
fileSize: (0, import_big_integer.default)(size),
|
|
3001
|
+
requestSize: DOWNLOAD_CHUNK_BYTES
|
|
3002
|
+
});
|
|
3003
|
+
let transferred = 0;
|
|
3004
|
+
control.onProgress?.({ transferred: 0, total: size, percent: 0 });
|
|
3005
|
+
const signal = control.signal;
|
|
3006
|
+
return import_node_stream3.Readable.from(
|
|
3007
|
+
(async function* () {
|
|
3008
|
+
try {
|
|
3009
|
+
for await (const chunk of iterator) {
|
|
3010
|
+
if (signal?.aborted) throw new OperationCancelledError();
|
|
3011
|
+
const buffer = Buffer.from(chunk);
|
|
3012
|
+
transferred += buffer.byteLength;
|
|
3013
|
+
control.onProgress?.({
|
|
3014
|
+
transferred,
|
|
3015
|
+
total: size,
|
|
3016
|
+
percent: size > 0 ? Math.round(transferred / size * 100) : null
|
|
3017
|
+
});
|
|
3018
|
+
yield buffer;
|
|
3019
|
+
}
|
|
3020
|
+
} catch (error) {
|
|
3021
|
+
if (error instanceof OperationCancelledError) throw error;
|
|
3022
|
+
throw mapTelegramError(error, "Telegram download failed");
|
|
3023
|
+
}
|
|
3024
|
+
})()
|
|
3025
|
+
);
|
|
2537
3026
|
}
|
|
2538
3027
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
2539
3028
|
async delete(ref) {
|
|
@@ -2550,6 +3039,38 @@ var TelegramStorageProvider = class {
|
|
|
2550
3039
|
throw mapTelegramError(error, "Telegram lookup failed");
|
|
2551
3040
|
}
|
|
2552
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Read-only listing of stored documents in the channel, newest first, up to
|
|
3044
|
+
* `limit`. Only messages that carry a document are returned; text messages and
|
|
3045
|
+
* service messages are skipped. Nothing is modified.
|
|
3046
|
+
*/
|
|
3047
|
+
async listObjects(limit) {
|
|
3048
|
+
const objects = [];
|
|
3049
|
+
try {
|
|
3050
|
+
for await (const message of this.client.iterMessages(this.peer, { limit: limit * 2 })) {
|
|
3051
|
+
if (objects.length >= limit) break;
|
|
3052
|
+
if (!(message instanceof import_telegram2.Api.Message)) continue;
|
|
3053
|
+
const document = message.document;
|
|
3054
|
+
if (!(document instanceof import_telegram2.Api.Document)) continue;
|
|
3055
|
+
let fileName;
|
|
3056
|
+
for (const attr of document.attributes) {
|
|
3057
|
+
if (attr instanceof import_telegram2.Api.DocumentAttributeFilename) {
|
|
3058
|
+
fileName = attr.fileName;
|
|
3059
|
+
break;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
objects.push({
|
|
3063
|
+
messageId: message.id.toString(),
|
|
3064
|
+
name: fileName ?? "file",
|
|
3065
|
+
size: Number(document.size.toString()),
|
|
3066
|
+
mimeType: document.mimeType
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
} catch (error) {
|
|
3070
|
+
throw mapTelegramError(error, "Telegram listing failed");
|
|
3071
|
+
}
|
|
3072
|
+
return objects;
|
|
3073
|
+
}
|
|
2553
3074
|
async stat(ref) {
|
|
2554
3075
|
const message = await this.getMessage(ref.messageId);
|
|
2555
3076
|
if (!message?.media) return null;
|
|
@@ -2612,17 +3133,6 @@ var TelegramStorageProvider = class {
|
|
|
2612
3133
|
throw new TelegramConnectionError("Telegram client is not connected");
|
|
2613
3134
|
}
|
|
2614
3135
|
}
|
|
2615
|
-
async materialize(input) {
|
|
2616
|
-
if (input.data) return input.data;
|
|
2617
|
-
if (!input.stream) {
|
|
2618
|
-
throw new UploadFailedError("Upload input has neither data nor a stream");
|
|
2619
|
-
}
|
|
2620
|
-
const chunks = [];
|
|
2621
|
-
for await (const chunk of input.stream) {
|
|
2622
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2623
|
-
}
|
|
2624
|
-
return Buffer.concat(chunks);
|
|
2625
|
-
}
|
|
2626
3136
|
documentSize(message) {
|
|
2627
3137
|
const document = message.document;
|
|
2628
3138
|
if (document instanceof import_telegram2.Api.Document) return Number(document.size.toString());
|