@lacrous/omnicloud 0.2.1 → 0.2.3
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/cli.js +161 -0
- 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 +21 -8
- package/server/index.js +4912 -0
- package/server/prisma/migrations/0_init/migration.sql +106 -0
- package/server/prisma/migrations/1_v02_reliability/migration.sql +120 -0
- package/server/prisma/migrations/2_v02_trash_batch/migration.sql +6 -0
- package/server/prisma/migrations/3_browser_sessions/migration.sql +27 -0
- package/server/prisma/migrations/4_upload_operations/migration.sql +26 -0
- package/server/prisma/migrations/migration_lock.toml +3 -0
- package/server/prisma/schema.prisma +194 -0
- package/server/web/assets/index-K0Jhdi-h.js +355 -0
- package/server/web/assets/index-kc0MNEkT.css +1 -0
- package/server/web/favicon.svg +10 -0
- package/server/web/index.html +189 -0
package/dist/index.js
CHANGED
|
@@ -130,6 +130,65 @@ var OmniCloudClient = class {
|
|
|
130
130
|
throw lastError;
|
|
131
131
|
}
|
|
132
132
|
/** @internal */
|
|
133
|
+
/**
|
|
134
|
+
* Opens a binary response as a stream. Bytes are delivered as they arrive, so
|
|
135
|
+
* memory does not grow with the file. The stream errors with DOWNLOAD_INCOMPLETE
|
|
136
|
+
* if the body ends before Content-Length.
|
|
137
|
+
*/
|
|
138
|
+
async requestStream(path, onProgress) {
|
|
139
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
140
|
+
headers: this.defaultHeaders,
|
|
141
|
+
credentials: "include"
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const text = await response.text();
|
|
145
|
+
const error = safeJsonParse(text)?.error;
|
|
146
|
+
throw new OmniCloudError(
|
|
147
|
+
response.status,
|
|
148
|
+
error?.code ?? "DOWNLOAD_FAILED",
|
|
149
|
+
error?.message ?? `Download failed (${response.status})`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const total = Number(response.headers.get("content-length") ?? 0) || null;
|
|
153
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
154
|
+
const sha256 = response.headers.get("x-content-sha256");
|
|
155
|
+
const source = response.body;
|
|
156
|
+
if (!source) {
|
|
157
|
+
throw new OmniCloudError(0, "DOWNLOAD_FAILED", "The response has no body to stream");
|
|
158
|
+
}
|
|
159
|
+
let loaded = 0;
|
|
160
|
+
const reader = source.getReader();
|
|
161
|
+
const stream = new ReadableStream({
|
|
162
|
+
async pull(controller) {
|
|
163
|
+
const { done, value } = await reader.read();
|
|
164
|
+
if (done) {
|
|
165
|
+
if (total !== null && loaded !== total) {
|
|
166
|
+
controller.error(
|
|
167
|
+
new OmniCloudError(
|
|
168
|
+
0,
|
|
169
|
+
"DOWNLOAD_INCOMPLETE",
|
|
170
|
+
`Download ended after ${loaded} of ${total} bytes`
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
controller.close();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
loaded += value.byteLength;
|
|
179
|
+
onProgress?.({
|
|
180
|
+
loaded,
|
|
181
|
+
total,
|
|
182
|
+
percent: total ? Math.round(loaded / total * 100) : null
|
|
183
|
+
});
|
|
184
|
+
controller.enqueue(value);
|
|
185
|
+
},
|
|
186
|
+
cancel(reason) {
|
|
187
|
+
return reader.cancel(reason);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return { stream, contentType, size: total, sha256 };
|
|
191
|
+
}
|
|
133
192
|
async requestBinary(path, onProgress) {
|
|
134
193
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
135
194
|
headers: this.defaultHeaders,
|
|
@@ -153,6 +212,28 @@ var OmniCloudClient = class {
|
|
|
153
212
|
return { data, contentType, size: data.byteLength, sha256, integrityVerified };
|
|
154
213
|
}
|
|
155
214
|
};
|
|
215
|
+
async function toBlob(input) {
|
|
216
|
+
if (input.path !== void 0) {
|
|
217
|
+
const { readFile } = await import("fs/promises");
|
|
218
|
+
return new Blob([new Uint8Array(await readFile(input.path))]);
|
|
219
|
+
}
|
|
220
|
+
const data = input.data;
|
|
221
|
+
if (data === void 0) throw new OmniCloudError(0, "INVALID_REQUEST", "No upload content given");
|
|
222
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return data;
|
|
223
|
+
if (data instanceof Uint8Array) return new Blob([new Uint8Array(data)]);
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (typeof data.getReader === "function") {
|
|
226
|
+
const reader = data.getReader();
|
|
227
|
+
for (; ; ) {
|
|
228
|
+
const { done, value } = await reader.read();
|
|
229
|
+
if (done) break;
|
|
230
|
+
parts.push(value);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
for await (const chunk of data) parts.push(chunk);
|
|
234
|
+
}
|
|
235
|
+
return new Blob(parts);
|
|
236
|
+
}
|
|
156
237
|
async function readBody(response, total, onProgress) {
|
|
157
238
|
const body = response.body;
|
|
158
239
|
if (!body) {
|
|
@@ -176,6 +257,13 @@ async function readBody(response, total, onProgress) {
|
|
|
176
257
|
});
|
|
177
258
|
}
|
|
178
259
|
}
|
|
260
|
+
if (total !== null && loaded !== total) {
|
|
261
|
+
throw new OmniCloudError(
|
|
262
|
+
0,
|
|
263
|
+
"DOWNLOAD_INCOMPLETE",
|
|
264
|
+
`Download ended after ${loaded} of ${total} bytes`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
179
267
|
const merged = new Uint8Array(loaded);
|
|
180
268
|
let offset = 0;
|
|
181
269
|
for (const chunk of chunks) {
|
|
@@ -225,9 +313,9 @@ var FilesApi = class {
|
|
|
225
313
|
}
|
|
226
314
|
throw lastError;
|
|
227
315
|
}
|
|
228
|
-
uploadOnce(input, options) {
|
|
316
|
+
async uploadOnce(input, options) {
|
|
229
317
|
const form = new FormData();
|
|
230
|
-
const blob =
|
|
318
|
+
const blob = await toBlob(input);
|
|
231
319
|
form.append("file", blob, input.name);
|
|
232
320
|
if (options.folderId) form.append("folderId", options.folderId);
|
|
233
321
|
if (typeof XMLHttpRequest === "function") {
|
|
@@ -287,6 +375,45 @@ var FilesApi = class {
|
|
|
287
375
|
async download(id, onProgress) {
|
|
288
376
|
return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
|
|
289
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Streams a file's bytes without holding the whole file in memory. Use this for
|
|
380
|
+
* large files. Read `stream` to the end: a truncated transfer errors instead of
|
|
381
|
+
* ending quietly.
|
|
382
|
+
*/
|
|
383
|
+
async downloadStream(id, onProgress) {
|
|
384
|
+
return this.client.requestStream(`/api/files/${id}/download`, onProgress);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Node only. Streams a file to `targetPath` through a `.part` file that is
|
|
388
|
+
* renamed into place only after the full length is verified. A truncated or
|
|
389
|
+
* failed transfer leaves nothing at `targetPath`.
|
|
390
|
+
*/
|
|
391
|
+
async downloadToFile(id, targetPath, onProgress) {
|
|
392
|
+
const { createWriteStream: createWriteStream2 } = await import("fs");
|
|
393
|
+
const { rename, rm: rm2 } = await import("fs/promises");
|
|
394
|
+
const { Readable: Readable3 } = await import("stream");
|
|
395
|
+
const { pipeline: pipeline2 } = await import("stream/promises");
|
|
396
|
+
const download = await this.downloadStream(id, onProgress);
|
|
397
|
+
const partPath = `${targetPath}.part`;
|
|
398
|
+
let bytes = 0;
|
|
399
|
+
try {
|
|
400
|
+
await pipeline2(
|
|
401
|
+
Readable3.fromWeb(download.stream),
|
|
402
|
+
async function* (source) {
|
|
403
|
+
for await (const chunk of source) {
|
|
404
|
+
bytes += chunk.byteLength;
|
|
405
|
+
yield chunk;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
createWriteStream2(partPath)
|
|
409
|
+
);
|
|
410
|
+
await rename(partPath, targetPath);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
await rm2(partPath, { force: true });
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
return { path: targetPath, bytes, sha256: download.sha256 };
|
|
416
|
+
}
|
|
290
417
|
/** URL suitable for direct download (anchor href). */
|
|
291
418
|
downloadUrl(id) {
|
|
292
419
|
return `${this.client.baseUrl}/api/files/${id}/download`;
|
|
@@ -841,12 +968,18 @@ var DomainError = class extends Error {
|
|
|
841
968
|
code;
|
|
842
969
|
status;
|
|
843
970
|
details;
|
|
844
|
-
|
|
971
|
+
/** Whether repeating the same operation may succeed. Defaults to false. */
|
|
972
|
+
retryable = false;
|
|
973
|
+
/** Server-requested delay before a retry, in seconds, when the provider gave one. */
|
|
974
|
+
retryAfterSeconds;
|
|
975
|
+
constructor(code, status, message, details, retry) {
|
|
845
976
|
super(message);
|
|
846
977
|
this.name = new.target.name;
|
|
847
978
|
this.code = code;
|
|
848
979
|
this.status = status;
|
|
849
980
|
this.details = details;
|
|
981
|
+
this.retryable = retry?.retryable ?? false;
|
|
982
|
+
this.retryAfterSeconds = retry?.retryAfterSeconds;
|
|
850
983
|
}
|
|
851
984
|
};
|
|
852
985
|
var ValidationError = class extends DomainError {
|
|
@@ -879,8 +1012,11 @@ var ConflictError = class extends DomainError {
|
|
|
879
1012
|
}
|
|
880
1013
|
};
|
|
881
1014
|
var RateLimitedError = class extends DomainError {
|
|
882
|
-
constructor(message = "Too many requests, please retry later") {
|
|
883
|
-
super(ERROR_CODES.RATE_LIMITED, 429, message
|
|
1015
|
+
constructor(message = "Too many requests, please retry later", options = {}) {
|
|
1016
|
+
super(ERROR_CODES.RATE_LIMITED, 429, message, void 0, {
|
|
1017
|
+
retryable: true,
|
|
1018
|
+
retryAfterSeconds: options.retryAfterSeconds
|
|
1019
|
+
});
|
|
884
1020
|
}
|
|
885
1021
|
};
|
|
886
1022
|
var StorageNotInitializedError = class extends DomainError {
|
|
@@ -895,7 +1031,7 @@ var TelegramAuthRequiredError = class extends DomainError {
|
|
|
895
1031
|
};
|
|
896
1032
|
var TelegramConnectionError = class extends DomainError {
|
|
897
1033
|
constructor(message = "Could not reach Telegram", details) {
|
|
898
|
-
super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
|
|
1034
|
+
super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details, { retryable: true });
|
|
899
1035
|
}
|
|
900
1036
|
};
|
|
901
1037
|
var TelegramFileNotFoundError = class extends DomainError {
|
|
@@ -908,6 +1044,11 @@ var UploadFailedError = class extends DomainError {
|
|
|
908
1044
|
super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
|
|
909
1045
|
}
|
|
910
1046
|
};
|
|
1047
|
+
var IntegrityCheckError = class extends DomainError {
|
|
1048
|
+
constructor(message = "Integrity check failed", details) {
|
|
1049
|
+
super(ERROR_CODES.INTEGRITY_CHECK_FAILED, 502, message, details);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
911
1052
|
var OperationCancelledError = class extends DomainError {
|
|
912
1053
|
constructor(message = "Operation cancelled") {
|
|
913
1054
|
super(ERROR_CODES.INVALID_REQUEST, 499, message);
|
|
@@ -920,7 +1061,22 @@ function mapProviderError(message, error) {
|
|
|
920
1061
|
}
|
|
921
1062
|
|
|
922
1063
|
// ../core/src/storage/engine.ts
|
|
923
|
-
import {
|
|
1064
|
+
import { createHash as createHash2 } from "crypto";
|
|
1065
|
+
import { Readable, Transform } from "stream";
|
|
1066
|
+
|
|
1067
|
+
// ../core/src/storage/retry.ts
|
|
1068
|
+
function isRetryableError(error) {
|
|
1069
|
+
if (error instanceof OperationCancelledError) return false;
|
|
1070
|
+
if (error instanceof DomainError) return error.retryable;
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
function retryDelayMs(error, attempt, baseDelayMs, random = Math.random) {
|
|
1074
|
+
if (error instanceof DomainError && error.retryAfterSeconds !== void 0) {
|
|
1075
|
+
return error.retryAfterSeconds * 1e3;
|
|
1076
|
+
}
|
|
1077
|
+
const ceiling = baseDelayMs * 2 ** (attempt - 1);
|
|
1078
|
+
return Math.floor(random() * ceiling);
|
|
1079
|
+
}
|
|
924
1080
|
|
|
925
1081
|
// ../core/src/utils/hash.ts
|
|
926
1082
|
import { createHash } from "crypto";
|
|
@@ -930,15 +1086,6 @@ function sha256Hex(data) {
|
|
|
930
1086
|
|
|
931
1087
|
// ../core/src/storage/engine.ts
|
|
932
1088
|
var DEFAULT_RETRY_POLICY = { attempts: 3, baseDelayMs: 400 };
|
|
933
|
-
function isRetryable(error) {
|
|
934
|
-
if (error instanceof OperationCancelledError) return false;
|
|
935
|
-
const name = error instanceof Error ? error.name : "";
|
|
936
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
937
|
-
const code = error.code;
|
|
938
|
-
if (name === "AbortError" || code === "ABORT_ERR") return false;
|
|
939
|
-
if (/AUTH|PASSWORD|PHONE|FLOOD_WAIT|not found|invalid/i.test(message)) return false;
|
|
940
|
-
return true;
|
|
941
|
-
}
|
|
942
1089
|
function delay(ms, signal) {
|
|
943
1090
|
return new Promise((resolve, reject) => {
|
|
944
1091
|
if (signal?.aborted) {
|
|
@@ -974,16 +1121,30 @@ var StorageEngine = class {
|
|
|
974
1121
|
* progress. Retries transient failures with exponential backoff.
|
|
975
1122
|
*/
|
|
976
1123
|
async upload(input, control = {}) {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1124
|
+
if (input.data) {
|
|
1125
|
+
const sha2562 = sha256Hex(input.data);
|
|
1126
|
+
const size2 = input.data.byteLength;
|
|
1127
|
+
control.onProgress?.({ transferred: 0, total: size2, percent: 0 });
|
|
1128
|
+
const stored2 = await this.withRetry(
|
|
1129
|
+
() => this.provider.put(input, control),
|
|
1130
|
+
control,
|
|
1131
|
+
(error) => new UploadFailedError("Upload failed", error)
|
|
1132
|
+
);
|
|
1133
|
+
control.onProgress?.({ transferred: size2, total: size2, percent: 100 });
|
|
1134
|
+
return { stored: stored2, sha256: sha2562, size: size2 };
|
|
1135
|
+
}
|
|
1136
|
+
const { path, size, sha256 } = input;
|
|
1137
|
+
if (!path || size === void 0 || !sha256) {
|
|
1138
|
+
throw new UploadFailedError("Upload input has neither data nor a complete spooled file");
|
|
1139
|
+
}
|
|
1140
|
+
control.onProgress?.({ transferred: 0, total: size, percent: 0 });
|
|
980
1141
|
const stored = await this.withRetry(
|
|
981
|
-
() => this.provider.put(
|
|
1142
|
+
() => this.provider.put(input, control),
|
|
982
1143
|
control,
|
|
983
1144
|
(error) => new UploadFailedError("Upload failed", error)
|
|
984
1145
|
);
|
|
985
|
-
control.onProgress?.({ transferred:
|
|
986
|
-
return { stored, sha256, size
|
|
1146
|
+
control.onProgress?.({ transferred: size, total: size, percent: 100 });
|
|
1147
|
+
return { stored, sha256, size };
|
|
987
1148
|
}
|
|
988
1149
|
async download(ref, control = {}) {
|
|
989
1150
|
return this.withRetry(
|
|
@@ -992,14 +1153,41 @@ var StorageEngine = class {
|
|
|
992
1153
|
(error) => mapProviderError("Download failed", error)
|
|
993
1154
|
);
|
|
994
1155
|
}
|
|
995
|
-
/**
|
|
996
|
-
|
|
997
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* Streams the object and verifies its SHA-256 against `expectedSha256`.
|
|
1158
|
+
*
|
|
1159
|
+
* The last chunk is held back until the digest of everything before it has
|
|
1160
|
+
* been checked, so a corrupt object errors the stream before its final bytes
|
|
1161
|
+
* are released. A consumer therefore never sees a complete-looking body that
|
|
1162
|
+
* fails the checksum. Memory stays bounded to about two provider chunks.
|
|
1163
|
+
*/
|
|
1164
|
+
async downloadStream(ref, expectedSha256, control = {}) {
|
|
1165
|
+
const source = await this.withRetry(
|
|
998
1166
|
() => this.provider.getStream(ref, control),
|
|
999
1167
|
control,
|
|
1000
1168
|
(error) => mapProviderError("Download failed", error)
|
|
1001
1169
|
);
|
|
1002
|
-
|
|
1170
|
+
const hash = createHash2("sha256");
|
|
1171
|
+
let held = null;
|
|
1172
|
+
const gate = new Transform({
|
|
1173
|
+
transform(chunk, _encoding, callback) {
|
|
1174
|
+
hash.update(chunk);
|
|
1175
|
+
if (held) this.push(held);
|
|
1176
|
+
held = chunk;
|
|
1177
|
+
callback();
|
|
1178
|
+
},
|
|
1179
|
+
flush(callback) {
|
|
1180
|
+
if (hash.digest("hex") !== expectedSha256) {
|
|
1181
|
+
callback(new IntegrityCheckError("Downloaded content does not match its checksum"));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (held) this.push(held);
|
|
1185
|
+
callback();
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
source.once("error", (error) => gate.destroy(error));
|
|
1189
|
+
source.pipe(gate);
|
|
1190
|
+
return gate;
|
|
1003
1191
|
}
|
|
1004
1192
|
async remove(ref) {
|
|
1005
1193
|
await this.withRetry(
|
|
@@ -1015,6 +1203,14 @@ var StorageEngine = class {
|
|
|
1015
1203
|
throw mapProviderError("Storage lookup failed", error);
|
|
1016
1204
|
}
|
|
1017
1205
|
}
|
|
1206
|
+
/** Read-only listing of stored objects, for reconciliation. Never modifies anything. */
|
|
1207
|
+
async list(limit) {
|
|
1208
|
+
try {
|
|
1209
|
+
return await this.provider.listObjects(limit);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
throw mapProviderError("Storage listing failed", error);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1018
1214
|
async stat(ref) {
|
|
1019
1215
|
try {
|
|
1020
1216
|
return await this.provider.stat(ref);
|
|
@@ -1038,18 +1234,6 @@ var StorageEngine = class {
|
|
|
1038
1234
|
return sha256Hex(data) === expectedSha256;
|
|
1039
1235
|
}
|
|
1040
1236
|
// ── internals ────────────────────────────────────────────────────────────
|
|
1041
|
-
/** Materializes an upload input so the checksum can be computed. */
|
|
1042
|
-
async readInput(input) {
|
|
1043
|
-
if (input.data) return input.data;
|
|
1044
|
-
if (!input.stream) {
|
|
1045
|
-
throw new UploadFailedError("Upload input has neither data nor a stream");
|
|
1046
|
-
}
|
|
1047
|
-
const chunks = [];
|
|
1048
|
-
for await (const chunk of input.stream) {
|
|
1049
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1050
|
-
}
|
|
1051
|
-
return Buffer.concat(chunks);
|
|
1052
|
-
}
|
|
1053
1237
|
async withRetry(operation, control, wrapError) {
|
|
1054
1238
|
const { attempts, baseDelayMs } = this.retryPolicy;
|
|
1055
1239
|
let lastError;
|
|
@@ -1059,8 +1243,8 @@ var StorageEngine = class {
|
|
|
1059
1243
|
return await operation();
|
|
1060
1244
|
} catch (error) {
|
|
1061
1245
|
lastError = error;
|
|
1062
|
-
if (attempt >= attempts || !
|
|
1063
|
-
await delay(
|
|
1246
|
+
if (attempt >= attempts || !isRetryableError(error)) break;
|
|
1247
|
+
await delay(retryDelayMs(error, attempt, baseDelayMs), control.signal);
|
|
1064
1248
|
}
|
|
1065
1249
|
}
|
|
1066
1250
|
throw wrapError(lastError);
|
|
@@ -1107,6 +1291,51 @@ async function resolveItemQuery(folders, userId, input) {
|
|
|
1107
1291
|
return query;
|
|
1108
1292
|
}
|
|
1109
1293
|
|
|
1294
|
+
// ../core/src/services/auth-session-service.ts
|
|
1295
|
+
import { createHash as createHash3, randomBytes } from "crypto";
|
|
1296
|
+
|
|
1297
|
+
// ../core/src/services/upload-operation.ts
|
|
1298
|
+
function isValidOperationId(id) {
|
|
1299
|
+
return /^[A-Za-z0-9_-]{8,64}$/.test(id);
|
|
1300
|
+
}
|
|
1301
|
+
var OperationAlreadyExistsError = class extends Error {
|
|
1302
|
+
constructor() {
|
|
1303
|
+
super("An upload with this operation id already exists");
|
|
1304
|
+
this.name = "OperationAlreadyExistsError";
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
// ../core/src/utils/operation-log.ts
|
|
1309
|
+
async function timedOperation(sink, base, run, now = Date.now) {
|
|
1310
|
+
const started = now();
|
|
1311
|
+
const emit = (status, errorCode) => {
|
|
1312
|
+
if (!sink) return;
|
|
1313
|
+
const record = {
|
|
1314
|
+
operation: base.operation,
|
|
1315
|
+
userId: base.userId,
|
|
1316
|
+
resourceId: base.resourceId ?? null,
|
|
1317
|
+
sizeBytes: base.sizeBytes ?? null,
|
|
1318
|
+
durationMs: now() - started,
|
|
1319
|
+
status,
|
|
1320
|
+
errorCode
|
|
1321
|
+
};
|
|
1322
|
+
try {
|
|
1323
|
+
if (status === "ok") sink.info({ ...record }, `${base.operation}.completed`);
|
|
1324
|
+
else sink.warn({ ...record }, `${base.operation}.failed`);
|
|
1325
|
+
} catch {
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
try {
|
|
1329
|
+
const result = await run();
|
|
1330
|
+
emit("ok", null);
|
|
1331
|
+
return result;
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
const code = error.code ?? "INTERNAL_ERROR";
|
|
1334
|
+
emit("error", code);
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1110
1339
|
// ../core/src/services/activity-service.ts
|
|
1111
1340
|
var ActivityService = class {
|
|
1112
1341
|
constructor(activity, retentionDays = 90) {
|
|
@@ -1170,18 +1399,120 @@ function sanitizeFileName(name) {
|
|
|
1170
1399
|
|
|
1171
1400
|
// ../core/src/services/file-service.ts
|
|
1172
1401
|
var FileService = class {
|
|
1173
|
-
constructor(files, folders, engineFor, activity = noopActivityRecorder) {
|
|
1402
|
+
constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
|
|
1174
1403
|
this.files = files;
|
|
1175
1404
|
this.folders = folders;
|
|
1176
1405
|
this.engineFor = engineFor;
|
|
1177
1406
|
this.activity = activity;
|
|
1407
|
+
this.operations = operations;
|
|
1408
|
+
this.log = log;
|
|
1178
1409
|
}
|
|
1179
1410
|
files;
|
|
1180
1411
|
folders;
|
|
1181
1412
|
engineFor;
|
|
1182
1413
|
activity;
|
|
1414
|
+
operations;
|
|
1415
|
+
log;
|
|
1183
1416
|
// ── Upload ───────────────────────────────────────────────────────────────
|
|
1184
1417
|
async upload(userId, input) {
|
|
1418
|
+
return timedOperation(
|
|
1419
|
+
this.log,
|
|
1420
|
+
{
|
|
1421
|
+
operation: "upload",
|
|
1422
|
+
userId,
|
|
1423
|
+
resourceId: input.replaceFileId ?? null,
|
|
1424
|
+
sizeBytes: input.spooled?.size ?? input.data?.byteLength ?? null
|
|
1425
|
+
},
|
|
1426
|
+
() => input.operationId && this.operations ? this.uploadOnce(userId, input, input.operationId, this.operations) : this.performUpload(userId, input)
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Idempotent upload keyed by a client-supplied operation id. A retry never
|
|
1431
|
+
* writes a second Telegram object: a completed operation returns its file, and
|
|
1432
|
+
* an operation whose object was already stored is committed without uploading.
|
|
1433
|
+
*/
|
|
1434
|
+
async uploadOnce(userId, input, operationId, operations) {
|
|
1435
|
+
if (!isValidOperationId(operationId)) {
|
|
1436
|
+
throw new ValidationError("Invalid upload operation id");
|
|
1437
|
+
}
|
|
1438
|
+
let op = await operations.findByOperationId(userId, operationId);
|
|
1439
|
+
if (op?.status === "COMPLETED" && op.fileId) {
|
|
1440
|
+
return this.get(userId, op.fileId);
|
|
1441
|
+
}
|
|
1442
|
+
if (op?.status === "UPLOADING" && op.telegramMessageId !== null) {
|
|
1443
|
+
return this.commitStoredObject(userId, input, operations, op);
|
|
1444
|
+
}
|
|
1445
|
+
if (!op) {
|
|
1446
|
+
try {
|
|
1447
|
+
op = await operations.create({ userId, operationId });
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
if (!(error instanceof OperationAlreadyExistsError)) throw error;
|
|
1450
|
+
const outcome = await this.followWinner(userId, input, operationId, operations);
|
|
1451
|
+
if (outcome) return outcome;
|
|
1452
|
+
throw new ConflictError("A concurrent upload with this operation id is still in progress");
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
const claimed = await operations.claim(op.id, "PENDING", "UPLOADING") || await operations.claim(op.id, "FAILED", "UPLOADING");
|
|
1456
|
+
if (!claimed) {
|
|
1457
|
+
const outcome = await this.followWinner(userId, input, operationId, operations);
|
|
1458
|
+
if (outcome) return outcome;
|
|
1459
|
+
throw new ConflictError("A concurrent upload with this operation id is still in progress");
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
const record = await this.performUpload(userId, input, {
|
|
1463
|
+
onStored: async (stored) => {
|
|
1464
|
+
await operations.update(op.id, { telegramMessageId: Number(stored.messageId) });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
|
|
1468
|
+
return record;
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
await operations.update(op.id, {
|
|
1471
|
+
status: "FAILED",
|
|
1472
|
+
error: error instanceof Error ? error.message.slice(0, 200) : "upload failed"
|
|
1473
|
+
});
|
|
1474
|
+
throw error;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Follows an operation another request is driving, for a bounded time. Returns
|
|
1479
|
+
* the finished file if the winner completes, or the stored object's commit if
|
|
1480
|
+
* it has stored one. Returns null when the winner is still in progress after the
|
|
1481
|
+
* budget, so the caller can report a conflict instead of waiting forever.
|
|
1482
|
+
*/
|
|
1483
|
+
async followWinner(userId, input, operationId, operations) {
|
|
1484
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1485
|
+
const current = await operations.findByOperationId(userId, operationId);
|
|
1486
|
+
if (current?.status === "COMPLETED" && current.fileId) {
|
|
1487
|
+
return this.get(userId, current.fileId);
|
|
1488
|
+
}
|
|
1489
|
+
if (current?.status === "FAILED") {
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
if (current?.status === "UPLOADING" && current.telegramMessageId !== null) {
|
|
1493
|
+
return this.commitStoredObject(userId, input, operations, current);
|
|
1494
|
+
}
|
|
1495
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1496
|
+
}
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
/** Commits metadata for an object that is already in Telegram. */
|
|
1500
|
+
async commitStoredObject(userId, input, operations, op) {
|
|
1501
|
+
const name = sanitizeFileName(input.name);
|
|
1502
|
+
const mimeType = mimeFromFilename(name);
|
|
1503
|
+
const size = op.size ?? input.spooled?.size ?? input.data?.byteLength ?? 0;
|
|
1504
|
+
const sha256 = op.sha256 ?? input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
|
|
1505
|
+
const record = await this.commitRecord(userId, input, {
|
|
1506
|
+
name,
|
|
1507
|
+
mimeType,
|
|
1508
|
+
size,
|
|
1509
|
+
sha256,
|
|
1510
|
+
telegramMessageId: op.telegramMessageId
|
|
1511
|
+
});
|
|
1512
|
+
await operations.update(op.id, { status: "COMPLETED", fileId: record.id });
|
|
1513
|
+
return record;
|
|
1514
|
+
}
|
|
1515
|
+
async performUpload(userId, input, hooks = {}) {
|
|
1185
1516
|
const name = sanitizeFileName(input.name);
|
|
1186
1517
|
if (!name) throw new ValidationError("Invalid file name");
|
|
1187
1518
|
let folderId = input.folderId;
|
|
@@ -1197,11 +1528,36 @@ var FileService = class {
|
|
|
1197
1528
|
onProgress: input.onProgress,
|
|
1198
1529
|
signal: input.signal
|
|
1199
1530
|
};
|
|
1531
|
+
if (!input.data && !input.spooled) {
|
|
1532
|
+
throw new ValidationError("An upload needs content");
|
|
1533
|
+
}
|
|
1200
1534
|
const { stored, sha256, size } = await engine.upload(
|
|
1201
|
-
|
|
1535
|
+
input.spooled ? {
|
|
1536
|
+
name,
|
|
1537
|
+
mimeType,
|
|
1538
|
+
path: input.spooled.path,
|
|
1539
|
+
size: input.spooled.size,
|
|
1540
|
+
sha256: input.spooled.sha256
|
|
1541
|
+
} : { name, mimeType, data: input.data },
|
|
1202
1542
|
control
|
|
1203
1543
|
);
|
|
1204
|
-
|
|
1544
|
+
await hooks.onStored?.(stored);
|
|
1545
|
+
try {
|
|
1546
|
+
return await this.commitRecord(userId, input, {
|
|
1547
|
+
name,
|
|
1548
|
+
mimeType,
|
|
1549
|
+
size,
|
|
1550
|
+
sha256,
|
|
1551
|
+
telegramMessageId: Number(stored.messageId)
|
|
1552
|
+
});
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
await engine.remove({ messageId: String(stored.messageId) }).catch(() => void 0);
|
|
1555
|
+
throw error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/** Persists the file (or the new version of a file) for an object already stored. */
|
|
1559
|
+
async commitRecord(userId, input, next) {
|
|
1560
|
+
const { name, mimeType, size, sha256, telegramMessageId } = next;
|
|
1205
1561
|
if (input.replaceFileId) {
|
|
1206
1562
|
return this.replaceVersion(userId, input.replaceFileId, {
|
|
1207
1563
|
name,
|
|
@@ -1211,6 +1567,10 @@ var FileService = class {
|
|
|
1211
1567
|
telegramMessageId
|
|
1212
1568
|
});
|
|
1213
1569
|
}
|
|
1570
|
+
const folderId = input.folderId;
|
|
1571
|
+
if (folderId !== null) {
|
|
1572
|
+
await this.assertFolder(userId, folderId);
|
|
1573
|
+
}
|
|
1214
1574
|
const record = await this.files.create({
|
|
1215
1575
|
userId,
|
|
1216
1576
|
folderId,
|
|
@@ -1384,16 +1744,20 @@ var FileService = class {
|
|
|
1384
1744
|
* retry, rather than silently orphaning the storage object.
|
|
1385
1745
|
*/
|
|
1386
1746
|
async deletePermanently(userId, id) {
|
|
1747
|
+
return timedOperation(
|
|
1748
|
+
this.log,
|
|
1749
|
+
{ operation: "delete", userId, resourceId: id },
|
|
1750
|
+
() => this.performPermanentDelete(userId, id)
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
async performPermanentDelete(userId, id) {
|
|
1387
1754
|
const record = await this.get(userId, id);
|
|
1388
1755
|
const engine = await this.engineFor(userId);
|
|
1389
|
-
await engine.remove({ messageId: String(record.telegramMessageId) });
|
|
1390
1756
|
const versions = await this.files.listVersions(id);
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
} catch {
|
|
1396
|
-
}
|
|
1757
|
+
const messageIds = /* @__PURE__ */ new Set([String(record.telegramMessageId)]);
|
|
1758
|
+
for (const version of versions) messageIds.add(String(version.telegramMessageId));
|
|
1759
|
+
for (const messageId of messageIds) {
|
|
1760
|
+
await engine.remove({ messageId });
|
|
1397
1761
|
}
|
|
1398
1762
|
await this.files.deleteVersionsByFileIds([id]);
|
|
1399
1763
|
await this.files.deleteMany([id]);
|
|
@@ -1428,6 +1792,36 @@ var FileService = class {
|
|
|
1428
1792
|
});
|
|
1429
1793
|
return { record, data, integrityVerified };
|
|
1430
1794
|
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Streaming download for large files. The caller must consume `stream` to
|
|
1797
|
+
* the end and then await `verified` to learn whether the bytes match the
|
|
1798
|
+
* stored checksum. The download activity is recorded when the stream opens.
|
|
1799
|
+
*/
|
|
1800
|
+
async downloadStreamed(userId, id, control = {}) {
|
|
1801
|
+
return timedOperation(
|
|
1802
|
+
this.log,
|
|
1803
|
+
{ operation: "download", userId, resourceId: id },
|
|
1804
|
+
() => this.performStreamedDownload(userId, id, control)
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
async performStreamedDownload(userId, id, control) {
|
|
1808
|
+
const record = await this.getActive(userId, id);
|
|
1809
|
+
const engine = await this.engineFor(userId);
|
|
1810
|
+
const stream = await engine.downloadStream(
|
|
1811
|
+
{ messageId: String(record.telegramMessageId) },
|
|
1812
|
+
record.sha256,
|
|
1813
|
+
control
|
|
1814
|
+
);
|
|
1815
|
+
await this.activity.record({
|
|
1816
|
+
userId,
|
|
1817
|
+
action: "download",
|
|
1818
|
+
resourceType: "file",
|
|
1819
|
+
resourceId: id,
|
|
1820
|
+
resourceName: record.name,
|
|
1821
|
+
metadata: { size: record.size, streamed: true }
|
|
1822
|
+
});
|
|
1823
|
+
return { record, stream };
|
|
1824
|
+
}
|
|
1431
1825
|
/** Records an "open" event (the UI opening the details panel, say). */
|
|
1432
1826
|
async touch(userId, id, action = "open") {
|
|
1433
1827
|
const record = await this.get(userId, id);
|
|
@@ -1479,7 +1873,13 @@ var FileService = class {
|
|
|
1479
1873
|
errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
|
|
1480
1874
|
}
|
|
1481
1875
|
}
|
|
1482
|
-
return {
|
|
1876
|
+
return {
|
|
1877
|
+
...options.operationId ? { operationId: options.operationId } : {},
|
|
1878
|
+
requested: ids.length,
|
|
1879
|
+
succeeded,
|
|
1880
|
+
failed: errors.length,
|
|
1881
|
+
errors
|
|
1882
|
+
};
|
|
1483
1883
|
}
|
|
1484
1884
|
// ── internals ────────────────────────────────────────────────────────────
|
|
1485
1885
|
async assertFolder(userId, folderId) {
|
|
@@ -1573,7 +1973,7 @@ var FolderService = class {
|
|
|
1573
1973
|
cursor = parent.parentId;
|
|
1574
1974
|
}
|
|
1575
1975
|
}
|
|
1576
|
-
const updated = await this.folders.
|
|
1976
|
+
const updated = await this.folders.moveSafely(id, newParentId);
|
|
1577
1977
|
await this.activity.record({
|
|
1578
1978
|
userId,
|
|
1579
1979
|
action: "move",
|
|
@@ -1634,7 +2034,7 @@ var FolderService = class {
|
|
|
1634
2034
|
let parentId = record.parentId;
|
|
1635
2035
|
if (parentId !== null) {
|
|
1636
2036
|
const parent = await this.folders.findById(parentId);
|
|
1637
|
-
if (!parent || parent.deletedAt) parentId = null;
|
|
2037
|
+
if (!parent || parent.userId !== userId || parent.deletedAt) parentId = null;
|
|
1638
2038
|
}
|
|
1639
2039
|
const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
|
|
1640
2040
|
const batchId = record.trashBatchId;
|
|
@@ -1931,10 +2331,28 @@ var IntegrityService = class {
|
|
|
1931
2331
|
const engine = await this.engineFor(userId);
|
|
1932
2332
|
const issues = [];
|
|
1933
2333
|
let healthy = 0;
|
|
2334
|
+
const versionsByFile = await this.files.listVersionsForFiles(targets.map((file) => file.id));
|
|
1934
2335
|
for (const file of targets) {
|
|
1935
2336
|
const issue = await this.inspect(file, options.deep ?? false, engine);
|
|
1936
2337
|
if (issue) issues.push(issue);
|
|
1937
2338
|
else healthy += 1;
|
|
2339
|
+
for (const version of versionsByFile.get(file.id) ?? []) {
|
|
2340
|
+
if (version.telegramMessageId === file.telegramMessageId) continue;
|
|
2341
|
+
const versionIssue = await this.inspectObject(
|
|
2342
|
+
{
|
|
2343
|
+
id: file.id,
|
|
2344
|
+
name: file.name,
|
|
2345
|
+
telegramMessageId: version.telegramMessageId,
|
|
2346
|
+
size: version.size,
|
|
2347
|
+
sha256: version.sha256
|
|
2348
|
+
},
|
|
2349
|
+
options.deep ?? false,
|
|
2350
|
+
engine,
|
|
2351
|
+
version.id
|
|
2352
|
+
);
|
|
2353
|
+
if (versionIssue) issues.push(versionIssue);
|
|
2354
|
+
else healthy += 1;
|
|
2355
|
+
}
|
|
1938
2356
|
}
|
|
1939
2357
|
const missing = issues.filter((issue) => issue.kind === "missing").length;
|
|
1940
2358
|
const inconsistent = issues.filter(
|
|
@@ -1953,53 +2371,69 @@ var IntegrityService = class {
|
|
|
1953
2371
|
};
|
|
1954
2372
|
}
|
|
1955
2373
|
async inspect(file, deep, engine) {
|
|
1956
|
-
|
|
2374
|
+
return this.inspectObject(
|
|
2375
|
+
{
|
|
2376
|
+
id: file.id,
|
|
2377
|
+
name: file.name,
|
|
2378
|
+
telegramMessageId: file.telegramMessageId,
|
|
2379
|
+
size: file.size,
|
|
2380
|
+
sha256: file.sha256
|
|
2381
|
+
},
|
|
2382
|
+
deep,
|
|
2383
|
+
engine,
|
|
2384
|
+
void 0
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
async inspectObject(target, deep, engine, versionId) {
|
|
2388
|
+
const ref = { messageId: String(target.telegramMessageId) };
|
|
2389
|
+
const withVersion = (issue) => versionId ? { ...issue, versionId } : issue;
|
|
1957
2390
|
let stat;
|
|
1958
2391
|
try {
|
|
1959
2392
|
stat = await engine.stat(ref);
|
|
1960
2393
|
} catch (error) {
|
|
1961
|
-
return {
|
|
1962
|
-
fileId:
|
|
1963
|
-
name:
|
|
2394
|
+
return withVersion({
|
|
2395
|
+
fileId: target.id,
|
|
2396
|
+
name: target.name,
|
|
1964
2397
|
kind: "unreadable",
|
|
1965
2398
|
detail: error instanceof Error ? error.message : "Storage lookup failed"
|
|
1966
|
-
};
|
|
2399
|
+
});
|
|
1967
2400
|
}
|
|
1968
2401
|
if (!stat) {
|
|
1969
|
-
return {
|
|
1970
|
-
fileId:
|
|
1971
|
-
name:
|
|
2402
|
+
return withVersion({
|
|
2403
|
+
fileId: target.id,
|
|
2404
|
+
name: target.name,
|
|
1972
2405
|
kind: "missing",
|
|
1973
2406
|
detail: "No message with this id exists in the storage channel"
|
|
1974
|
-
};
|
|
2407
|
+
});
|
|
1975
2408
|
}
|
|
1976
|
-
if (stat.size !==
|
|
1977
|
-
return {
|
|
1978
|
-
fileId:
|
|
1979
|
-
name:
|
|
2409
|
+
if (stat.size !== target.size) {
|
|
2410
|
+
return withVersion({
|
|
2411
|
+
fileId: target.id,
|
|
2412
|
+
name: target.name,
|
|
1980
2413
|
kind: "size_mismatch",
|
|
1981
|
-
detail: `metadata=${
|
|
1982
|
-
};
|
|
2414
|
+
detail: `metadata=${target.size} storage=${stat.size}`
|
|
2415
|
+
});
|
|
1983
2416
|
}
|
|
1984
2417
|
if (deep) {
|
|
1985
2418
|
try {
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2419
|
+
const stream = await engine.downloadStream(ref, target.sha256);
|
|
2420
|
+
for await (const _chunk of stream) {
|
|
2421
|
+
}
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
if (error instanceof IntegrityCheckError) {
|
|
2424
|
+
return withVersion({
|
|
2425
|
+
fileId: target.id,
|
|
2426
|
+
name: target.name,
|
|
1992
2427
|
kind: "hash_mismatch",
|
|
1993
2428
|
detail: "Downloaded bytes do not match the stored SHA-256"
|
|
1994
|
-
};
|
|
2429
|
+
});
|
|
1995
2430
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
name: file.name,
|
|
2431
|
+
return withVersion({
|
|
2432
|
+
fileId: target.id,
|
|
2433
|
+
name: target.name,
|
|
2000
2434
|
kind: "unreadable",
|
|
2001
2435
|
detail: error instanceof Error ? error.message : "Download failed"
|
|
2002
|
-
};
|
|
2436
|
+
});
|
|
2003
2437
|
}
|
|
2004
2438
|
}
|
|
2005
2439
|
return null;
|
|
@@ -2066,6 +2500,18 @@ var RecentService = class {
|
|
|
2066
2500
|
}
|
|
2067
2501
|
};
|
|
2068
2502
|
|
|
2503
|
+
// ../core/src/utils/spool.ts
|
|
2504
|
+
import { createHash as createHash4 } from "crypto";
|
|
2505
|
+
import { createWriteStream } from "fs";
|
|
2506
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
2507
|
+
import { tmpdir } from "os";
|
|
2508
|
+
import { join } from "path";
|
|
2509
|
+
import { Transform as Transform2 } from "stream";
|
|
2510
|
+
import { pipeline } from "stream/promises";
|
|
2511
|
+
|
|
2512
|
+
// ../core/src/utils/secret-box.ts
|
|
2513
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes2, scryptSync } from "crypto";
|
|
2514
|
+
|
|
2069
2515
|
// ../telegram/src/client.ts
|
|
2070
2516
|
import { Api, TelegramClient } from "telegram";
|
|
2071
2517
|
import { StringSession } from "telegram/sessions/index.js";
|
|
@@ -2084,7 +2530,9 @@ function mapTelegramError(error, fallbackMessage) {
|
|
|
2084
2530
|
const msg = error.errorMessage ?? "";
|
|
2085
2531
|
if (msg.startsWith("FLOOD_WAIT_")) {
|
|
2086
2532
|
const seconds = Number(msg.slice("FLOOD_WAIT_".length)) || 60;
|
|
2087
|
-
return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds
|
|
2533
|
+
return new RateLimitedError(`Telegram rate limit reached \u2014 retry in ${seconds} seconds`, {
|
|
2534
|
+
retryAfterSeconds: seconds
|
|
2535
|
+
});
|
|
2088
2536
|
}
|
|
2089
2537
|
switch (msg) {
|
|
2090
2538
|
case "PHONE_NUMBER_INVALID":
|
|
@@ -2372,6 +2820,7 @@ import { Api as Api2 } from "telegram";
|
|
|
2372
2820
|
import bigInt from "big-integer";
|
|
2373
2821
|
import { CustomFile } from "telegram/client/uploads.js";
|
|
2374
2822
|
var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
2823
|
+
var DOWNLOAD_CHUNK_BYTES = 512 * 1024;
|
|
2375
2824
|
var TelegramStorageProvider = class {
|
|
2376
2825
|
constructor(client, options) {
|
|
2377
2826
|
this.client = client;
|
|
@@ -2387,17 +2836,19 @@ var TelegramStorageProvider = class {
|
|
|
2387
2836
|
title;
|
|
2388
2837
|
// ── Upload ───────────────────────────────────────────────────────────────
|
|
2389
2838
|
async put(input, control = {}) {
|
|
2390
|
-
const
|
|
2391
|
-
if (data
|
|
2839
|
+
const total = input.data ? input.data.byteLength : input.size;
|
|
2840
|
+
if (total === void 0 || !input.data && !input.path) {
|
|
2841
|
+
throw new UploadFailedError("Upload input has neither data nor a spooled file");
|
|
2842
|
+
}
|
|
2843
|
+
if (total > MAX_DOCUMENT_BYTES) {
|
|
2392
2844
|
throw new UploadFailedError(
|
|
2393
2845
|
`Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
|
|
2394
2846
|
);
|
|
2395
2847
|
}
|
|
2396
2848
|
this.throwIfAborted(control.signal);
|
|
2397
|
-
const total = data.byteLength;
|
|
2398
2849
|
control.onProgress?.({ transferred: 0, total, percent: 0 });
|
|
2399
2850
|
try {
|
|
2400
|
-
const file = new CustomFile(input.name, total, "", data);
|
|
2851
|
+
const file = input.data ? new CustomFile(input.name, total, "", input.data) : new CustomFile(input.name, total, input.path);
|
|
2401
2852
|
const message = await this.client.sendFile(this.peer, {
|
|
2402
2853
|
file,
|
|
2403
2854
|
forceDocument: true,
|
|
@@ -2468,9 +2919,47 @@ var TelegramStorageProvider = class {
|
|
|
2468
2919
|
});
|
|
2469
2920
|
return buffer;
|
|
2470
2921
|
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Streams the object in Telegram-sized chunks, so memory use stays bounded
|
|
2924
|
+
* by one chunk regardless of file size. The first request happens here so
|
|
2925
|
+
* that a missing message fails before the caller starts consuming bytes.
|
|
2926
|
+
*/
|
|
2471
2927
|
async getStream(ref, control = {}) {
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2928
|
+
const message = await this.requireMessage(ref);
|
|
2929
|
+
this.throwIfAborted(control.signal);
|
|
2930
|
+
const size = this.documentSize(message);
|
|
2931
|
+
const media = message.media;
|
|
2932
|
+
if (size === null || !media) {
|
|
2933
|
+
throw new TelegramFileNotFoundError("Telegram did not return file content");
|
|
2934
|
+
}
|
|
2935
|
+
const iterator = this.client.iterDownload({
|
|
2936
|
+
file: media,
|
|
2937
|
+
fileSize: bigInt(size),
|
|
2938
|
+
requestSize: DOWNLOAD_CHUNK_BYTES
|
|
2939
|
+
});
|
|
2940
|
+
let transferred = 0;
|
|
2941
|
+
control.onProgress?.({ transferred: 0, total: size, percent: 0 });
|
|
2942
|
+
const signal = control.signal;
|
|
2943
|
+
return Readable2.from(
|
|
2944
|
+
(async function* () {
|
|
2945
|
+
try {
|
|
2946
|
+
for await (const chunk of iterator) {
|
|
2947
|
+
if (signal?.aborted) throw new OperationCancelledError();
|
|
2948
|
+
const buffer = Buffer.from(chunk);
|
|
2949
|
+
transferred += buffer.byteLength;
|
|
2950
|
+
control.onProgress?.({
|
|
2951
|
+
transferred,
|
|
2952
|
+
total: size,
|
|
2953
|
+
percent: size > 0 ? Math.round(transferred / size * 100) : null
|
|
2954
|
+
});
|
|
2955
|
+
yield buffer;
|
|
2956
|
+
}
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
if (error instanceof OperationCancelledError) throw error;
|
|
2959
|
+
throw mapTelegramError(error, "Telegram download failed");
|
|
2960
|
+
}
|
|
2961
|
+
})()
|
|
2962
|
+
);
|
|
2474
2963
|
}
|
|
2475
2964
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
2476
2965
|
async delete(ref) {
|
|
@@ -2487,6 +2976,38 @@ var TelegramStorageProvider = class {
|
|
|
2487
2976
|
throw mapTelegramError(error, "Telegram lookup failed");
|
|
2488
2977
|
}
|
|
2489
2978
|
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Read-only listing of stored documents in the channel, newest first, up to
|
|
2981
|
+
* `limit`. Only messages that carry a document are returned; text messages and
|
|
2982
|
+
* service messages are skipped. Nothing is modified.
|
|
2983
|
+
*/
|
|
2984
|
+
async listObjects(limit) {
|
|
2985
|
+
const objects = [];
|
|
2986
|
+
try {
|
|
2987
|
+
for await (const message of this.client.iterMessages(this.peer, { limit: limit * 2 })) {
|
|
2988
|
+
if (objects.length >= limit) break;
|
|
2989
|
+
if (!(message instanceof Api2.Message)) continue;
|
|
2990
|
+
const document = message.document;
|
|
2991
|
+
if (!(document instanceof Api2.Document)) continue;
|
|
2992
|
+
let fileName;
|
|
2993
|
+
for (const attr of document.attributes) {
|
|
2994
|
+
if (attr instanceof Api2.DocumentAttributeFilename) {
|
|
2995
|
+
fileName = attr.fileName;
|
|
2996
|
+
break;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
objects.push({
|
|
3000
|
+
messageId: message.id.toString(),
|
|
3001
|
+
name: fileName ?? "file",
|
|
3002
|
+
size: Number(document.size.toString()),
|
|
3003
|
+
mimeType: document.mimeType
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
throw mapTelegramError(error, "Telegram listing failed");
|
|
3008
|
+
}
|
|
3009
|
+
return objects;
|
|
3010
|
+
}
|
|
2490
3011
|
async stat(ref) {
|
|
2491
3012
|
const message = await this.getMessage(ref.messageId);
|
|
2492
3013
|
if (!message?.media) return null;
|
|
@@ -2549,17 +3070,6 @@ var TelegramStorageProvider = class {
|
|
|
2549
3070
|
throw new TelegramConnectionError("Telegram client is not connected");
|
|
2550
3071
|
}
|
|
2551
3072
|
}
|
|
2552
|
-
async materialize(input) {
|
|
2553
|
-
if (input.data) return input.data;
|
|
2554
|
-
if (!input.stream) {
|
|
2555
|
-
throw new UploadFailedError("Upload input has neither data nor a stream");
|
|
2556
|
-
}
|
|
2557
|
-
const chunks = [];
|
|
2558
|
-
for await (const chunk of input.stream) {
|
|
2559
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2560
|
-
}
|
|
2561
|
-
return Buffer.concat(chunks);
|
|
2562
|
-
}
|
|
2563
3073
|
documentSize(message) {
|
|
2564
3074
|
const document = message.document;
|
|
2565
3075
|
if (document instanceof Api2.Document) return Number(document.size.toString());
|