@lacrous/omnicloud 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2052 -268
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +346 -426
- package/dist/index.d.ts +346 -426
- package/dist/index.js +2039 -266
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,175 +1,520 @@
|
|
|
1
1
|
// src/client.ts
|
|
2
2
|
var OmniCloudError = class extends Error {
|
|
3
|
-
constructor(status, code, message) {
|
|
3
|
+
constructor(status, code, message, requestId = void 0, details = void 0) {
|
|
4
4
|
super(message);
|
|
5
5
|
this.status = status;
|
|
6
6
|
this.code = code;
|
|
7
|
+
this.requestId = requestId;
|
|
8
|
+
this.details = details;
|
|
7
9
|
this.name = "OmniCloudError";
|
|
8
10
|
}
|
|
9
11
|
status;
|
|
10
12
|
code;
|
|
13
|
+
requestId;
|
|
14
|
+
details;
|
|
11
15
|
};
|
|
16
|
+
var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 300 };
|
|
17
|
+
function sleep(ms, signal) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const timer = setTimeout(resolve, ms);
|
|
20
|
+
signal?.addEventListener(
|
|
21
|
+
"abort",
|
|
22
|
+
() => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
reject(new OmniCloudError(0, "ABORTED", "Request aborted"));
|
|
25
|
+
},
|
|
26
|
+
{ once: true }
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function isRetryableStatus(status) {
|
|
31
|
+
return status === 408 || status === 429 || status >= 500;
|
|
32
|
+
}
|
|
33
|
+
function buildQuery(query) {
|
|
34
|
+
const params = new URLSearchParams();
|
|
35
|
+
if (query) {
|
|
36
|
+
for (const [key, value] of Object.entries(query)) {
|
|
37
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
38
|
+
params.set(key, String(value));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const encoded = params.toString();
|
|
42
|
+
return encoded ? `?${encoded}` : "";
|
|
43
|
+
}
|
|
44
|
+
function safeJsonParse(text) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
12
51
|
var OmniCloudClient = class {
|
|
52
|
+
files;
|
|
53
|
+
folders;
|
|
54
|
+
trash;
|
|
55
|
+
storage;
|
|
56
|
+
search;
|
|
57
|
+
recent;
|
|
58
|
+
activity;
|
|
59
|
+
auth;
|
|
60
|
+
/** @internal */
|
|
13
61
|
baseUrl;
|
|
62
|
+
/** @internal */
|
|
14
63
|
fetchImpl;
|
|
64
|
+
/** @internal */
|
|
65
|
+
retry;
|
|
66
|
+
/** @internal */
|
|
67
|
+
defaultHeaders;
|
|
15
68
|
constructor(options = {}) {
|
|
16
69
|
this.baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
|
|
17
70
|
this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
|
|
71
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
72
|
+
this.defaultHeaders = {
|
|
73
|
+
...options.token ? { Authorization: `Bearer ${options.token}` } : {},
|
|
74
|
+
...options.headers
|
|
75
|
+
};
|
|
76
|
+
this.files = new FilesApi(this);
|
|
77
|
+
this.folders = new FoldersApi(this);
|
|
78
|
+
this.trash = new TrashApi(this);
|
|
79
|
+
this.storage = new StorageApi(this);
|
|
80
|
+
this.search = new SearchApi(this);
|
|
81
|
+
this.recent = new RecentApi(this);
|
|
82
|
+
this.activity = new ActivityApi(this);
|
|
83
|
+
this.auth = new AuthApi(this);
|
|
84
|
+
}
|
|
85
|
+
/** Low-level request helper (retries transient failures). */
|
|
86
|
+
async request(method, path, body) {
|
|
87
|
+
return this.withRetry(async () => this.rawRequest(method, path, body));
|
|
18
88
|
}
|
|
19
|
-
|
|
20
|
-
async
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return this.request("POST", "/api/auth/telegram/password", { phone, password });
|
|
31
|
-
}
|
|
32
|
-
async ensureStorage() {
|
|
33
|
-
const { storage } = await this.request("POST", "/api/storage/ensure");
|
|
34
|
-
return storage;
|
|
35
|
-
}
|
|
36
|
-
async logout() {
|
|
37
|
-
await this.request("POST", "/api/auth/logout");
|
|
38
|
-
}
|
|
39
|
-
// ── Folders ──────────────────────────────────────────────────────────────
|
|
40
|
-
async listFolders(parentId) {
|
|
41
|
-
const { folders } = await this.request(
|
|
42
|
-
"GET",
|
|
43
|
-
`/api/folders${parentId ? `?parentId=${encodeURIComponent(parentId)}` : ""}`
|
|
44
|
-
);
|
|
45
|
-
return folders;
|
|
46
|
-
}
|
|
47
|
-
async folderTree() {
|
|
48
|
-
const { folders } = await this.request("GET", "/api/folders/tree");
|
|
49
|
-
return folders;
|
|
50
|
-
}
|
|
51
|
-
async createFolder(name, parentId = null) {
|
|
52
|
-
const { folder } = await this.request("POST", "/api/folders", {
|
|
53
|
-
name,
|
|
54
|
-
parentId
|
|
89
|
+
/** @internal */
|
|
90
|
+
async rawRequest(method, path, body) {
|
|
91
|
+
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
|
92
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
93
|
+
method,
|
|
94
|
+
headers: {
|
|
95
|
+
...this.defaultHeaders,
|
|
96
|
+
...body !== void 0 && !isForm ? { "Content-Type": "application/json" } : {}
|
|
97
|
+
},
|
|
98
|
+
body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
|
|
99
|
+
credentials: "include"
|
|
55
100
|
});
|
|
56
|
-
return
|
|
101
|
+
if (response.status === 204) return void 0;
|
|
102
|
+
const text = await response.text();
|
|
103
|
+
const data = text ? safeJsonParse(text) : null;
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const error = data?.error;
|
|
106
|
+
throw new OmniCloudError(
|
|
107
|
+
response.status,
|
|
108
|
+
error?.code ?? "INTERNAL_ERROR",
|
|
109
|
+
error?.message ?? `Request failed (${response.status})`,
|
|
110
|
+
error?.requestId,
|
|
111
|
+
error?.details
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return data;
|
|
57
115
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
116
|
+
/** @internal */
|
|
117
|
+
async withRetry(operation) {
|
|
118
|
+
const { attempts, baseDelayMs } = this.retry;
|
|
119
|
+
let lastError;
|
|
120
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
|
|
121
|
+
try {
|
|
122
|
+
return await operation();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
lastError = error;
|
|
125
|
+
const retryable = error instanceof OmniCloudError && isRetryableStatus(error.status);
|
|
126
|
+
if (!retryable || attempt >= attempts) break;
|
|
127
|
+
await sleep(baseDelayMs * 2 ** (attempt - 1));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw lastError;
|
|
63
131
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
|
|
132
|
+
/** @internal */
|
|
133
|
+
async requestBinary(path, onProgress) {
|
|
134
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
135
|
+
headers: this.defaultHeaders,
|
|
136
|
+
credentials: "include"
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
const error = safeJsonParse(text)?.error;
|
|
141
|
+
throw new OmniCloudError(
|
|
142
|
+
response.status,
|
|
143
|
+
error?.code ?? "DOWNLOAD_FAILED",
|
|
144
|
+
error?.message ?? `Download failed (${response.status})`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const total = Number(response.headers.get("content-length") ?? 0) || null;
|
|
148
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
149
|
+
const sha256 = response.headers.get("x-content-sha256");
|
|
150
|
+
const integrityHeader = response.headers.get("x-integrity-verified");
|
|
151
|
+
const integrityVerified = integrityHeader === null ? null : integrityHeader === "true";
|
|
152
|
+
const data = await readBody(response, total, onProgress);
|
|
153
|
+
return { data, contentType, size: data.byteLength, sha256, integrityVerified };
|
|
71
154
|
}
|
|
72
|
-
|
|
73
|
-
|
|
155
|
+
};
|
|
156
|
+
async function readBody(response, total, onProgress) {
|
|
157
|
+
const body = response.body;
|
|
158
|
+
if (!body) {
|
|
159
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
160
|
+
onProgress?.({ loaded: buffer.byteLength, total: buffer.byteLength, percent: 100 });
|
|
161
|
+
return buffer;
|
|
162
|
+
}
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let loaded = 0;
|
|
165
|
+
const reader = body.getReader();
|
|
166
|
+
for (; ; ) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done) break;
|
|
169
|
+
if (value) {
|
|
170
|
+
chunks.push(value);
|
|
171
|
+
loaded += value.byteLength;
|
|
172
|
+
onProgress?.({
|
|
173
|
+
loaded,
|
|
174
|
+
total,
|
|
175
|
+
percent: total ? Math.round(loaded / total * 100) : null
|
|
176
|
+
});
|
|
177
|
+
}
|
|
74
178
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
81
|
-
return files;
|
|
179
|
+
const merged = new Uint8Array(loaded);
|
|
180
|
+
let offset = 0;
|
|
181
|
+
for (const chunk of chunks) {
|
|
182
|
+
merged.set(chunk, offset);
|
|
183
|
+
offset += chunk.byteLength;
|
|
82
184
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
185
|
+
const BufferCtor = globalThis.Buffer;
|
|
186
|
+
return BufferCtor ? BufferCtor.from(merged) : merged;
|
|
187
|
+
}
|
|
188
|
+
var FilesApi = class {
|
|
189
|
+
constructor(client) {
|
|
190
|
+
this.client = client;
|
|
86
191
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
192
|
+
client;
|
|
193
|
+
/** `folderId: null` = root; omit it to list every active file. */
|
|
194
|
+
async list(query = {}) {
|
|
195
|
+
return this.client.request("GET", `/api/files${buildQuery(query)}`);
|
|
90
196
|
}
|
|
91
|
-
async
|
|
92
|
-
const { file } = await this.request("
|
|
93
|
-
folderId
|
|
94
|
-
});
|
|
197
|
+
async get(id) {
|
|
198
|
+
const { file } = await this.client.request("GET", `/api/files/${id}`);
|
|
95
199
|
return file;
|
|
96
200
|
}
|
|
97
|
-
async
|
|
98
|
-
await this.request(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
201
|
+
async versions(id) {
|
|
202
|
+
const { versions } = await this.client.request(
|
|
203
|
+
"GET",
|
|
204
|
+
`/api/files/${id}/versions`
|
|
205
|
+
);
|
|
206
|
+
return versions;
|
|
103
207
|
}
|
|
104
208
|
/**
|
|
105
|
-
* Uploads a file. Uses XMLHttpRequest when
|
|
106
|
-
*
|
|
209
|
+
* Uploads a file with progress and retry support. Uses XMLHttpRequest when
|
|
210
|
+
* available (browsers) so progress and cancellation work; falls back to
|
|
211
|
+
* fetch elsewhere.
|
|
107
212
|
*/
|
|
108
|
-
async
|
|
213
|
+
async upload(input, options = {}) {
|
|
214
|
+
const attempts = Math.max(1, (options.retry ?? 2) + 1);
|
|
215
|
+
let lastError;
|
|
216
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
217
|
+
try {
|
|
218
|
+
return await this.uploadOnce(input, options);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
lastError = error;
|
|
221
|
+
if (options.signal?.aborted) throw error;
|
|
222
|
+
if (error instanceof OmniCloudError && !isRetryableStatus(error.status)) throw error;
|
|
223
|
+
if (attempt < attempts) await sleep(300 * 2 ** (attempt - 1), options.signal);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw lastError;
|
|
227
|
+
}
|
|
228
|
+
uploadOnce(input, options) {
|
|
109
229
|
const form = new FormData();
|
|
110
|
-
const blob = input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
|
|
230
|
+
const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
|
|
111
231
|
form.append("file", blob, input.name);
|
|
112
232
|
if (options.folderId) form.append("folderId", options.folderId);
|
|
113
233
|
if (typeof XMLHttpRequest === "function") {
|
|
114
|
-
return this.uploadViaXhr(form, options
|
|
234
|
+
return this.uploadViaXhr(form, options);
|
|
115
235
|
}
|
|
116
|
-
return this.request("POST", "/api/files", form);
|
|
117
|
-
}
|
|
118
|
-
async search(query) {
|
|
119
|
-
return this.request("GET", `/api/search?q=${encodeURIComponent(query)}`);
|
|
236
|
+
return this.client.request("POST", "/api/files", form).then((response) => response.file);
|
|
120
237
|
}
|
|
121
|
-
|
|
122
|
-
uploadViaXhr(form, onProgress) {
|
|
238
|
+
uploadViaXhr(form, options) {
|
|
123
239
|
return new Promise((resolve, reject) => {
|
|
124
240
|
const xhr = new XMLHttpRequest();
|
|
125
|
-
xhr.open("POST", `${this.baseUrl}/api/files`);
|
|
241
|
+
xhr.open("POST", `${this.client.baseUrl}/api/files`);
|
|
126
242
|
xhr.responseType = "json";
|
|
127
243
|
xhr.withCredentials = true;
|
|
244
|
+
const onAbort = () => xhr.abort();
|
|
245
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
128
246
|
xhr.upload.onprogress = (event) => {
|
|
129
|
-
|
|
130
|
-
onProgress({
|
|
247
|
+
options.onProgress?.({
|
|
131
248
|
loaded: event.loaded,
|
|
132
249
|
total: event.total,
|
|
133
250
|
percent: event.total > 0 ? Math.round(event.loaded / event.total * 100) : 0
|
|
134
251
|
});
|
|
135
252
|
};
|
|
136
253
|
xhr.onload = () => {
|
|
254
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
137
255
|
const body = xhr.response;
|
|
138
256
|
if (xhr.status >= 200 && xhr.status < 300 && body?.file) {
|
|
257
|
+
options.onProgress?.({ loaded: 1, total: 1, percent: 100 });
|
|
139
258
|
resolve(body.file);
|
|
140
259
|
return;
|
|
141
260
|
}
|
|
142
261
|
reject(
|
|
143
262
|
new OmniCloudError(
|
|
144
263
|
xhr.status,
|
|
145
|
-
body?.error?.code ?? "
|
|
146
|
-
body?.error?.message ?? `Upload failed (${xhr.status})
|
|
264
|
+
body?.error?.code ?? "UPLOAD_FAILED",
|
|
265
|
+
body?.error?.message ?? `Upload failed (${xhr.status})`,
|
|
266
|
+
body?.error?.requestId
|
|
147
267
|
)
|
|
148
268
|
);
|
|
149
269
|
};
|
|
150
270
|
xhr.onerror = () => reject(new OmniCloudError(0, "NETWORK_ERROR", "Network error during upload"));
|
|
271
|
+
xhr.onabort = () => reject(new OmniCloudError(0, "ABORTED", "Upload aborted"));
|
|
151
272
|
xhr.send(form);
|
|
152
273
|
});
|
|
153
274
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
275
|
+
/** Uploads a new version of an existing file. */
|
|
276
|
+
async replace(id, input) {
|
|
277
|
+
const form = new FormData();
|
|
278
|
+
const blob = typeof Blob !== "undefined" && input.data instanceof Blob ? input.data : new Blob([new Uint8Array(input.data)]);
|
|
279
|
+
form.append("file", blob, input.name);
|
|
280
|
+
const { file } = await this.client.request(
|
|
281
|
+
"POST",
|
|
282
|
+
`/api/files/${id}/replace`,
|
|
283
|
+
form
|
|
284
|
+
);
|
|
285
|
+
return file;
|
|
286
|
+
}
|
|
287
|
+
async download(id, onProgress) {
|
|
288
|
+
return this.client.requestBinary(`/api/files/${id}/download`, onProgress);
|
|
289
|
+
}
|
|
290
|
+
/** URL suitable for direct download (anchor href). */
|
|
291
|
+
downloadUrl(id) {
|
|
292
|
+
return `${this.client.baseUrl}/api/files/${id}/download`;
|
|
293
|
+
}
|
|
294
|
+
async rename(id, name) {
|
|
295
|
+
const { file } = await this.client.request("PATCH", `/api/files/${id}`, {
|
|
296
|
+
name
|
|
297
|
+
});
|
|
298
|
+
return file;
|
|
299
|
+
}
|
|
300
|
+
async star(id, starred = true) {
|
|
301
|
+
const { file } = await this.client.request("PATCH", `/api/files/${id}`, {
|
|
302
|
+
starred
|
|
303
|
+
});
|
|
304
|
+
return file;
|
|
305
|
+
}
|
|
306
|
+
async move(id, folderId) {
|
|
307
|
+
const { file } = await this.client.request("POST", `/api/files/${id}/move`, {
|
|
308
|
+
folderId
|
|
309
|
+
});
|
|
310
|
+
return file;
|
|
311
|
+
}
|
|
312
|
+
async trash(id) {
|
|
313
|
+
const { file } = await this.client.request("POST", `/api/files/${id}/trash`);
|
|
314
|
+
return file;
|
|
315
|
+
}
|
|
316
|
+
async restore(id) {
|
|
317
|
+
const { file } = await this.client.request(
|
|
318
|
+
"POST",
|
|
319
|
+
`/api/files/${id}/restore`
|
|
320
|
+
);
|
|
321
|
+
return file;
|
|
322
|
+
}
|
|
323
|
+
/** Permanently deletes a file and its Telegram objects. */
|
|
324
|
+
async delete(id) {
|
|
325
|
+
await this.client.request("DELETE", `/api/files/${id}`);
|
|
326
|
+
}
|
|
327
|
+
/** Batch operation: trash | restore | delete | star | unstar | move. */
|
|
328
|
+
async batch(operation, ids, options = {}) {
|
|
329
|
+
return this.client.request("POST", "/api/files/batch", {
|
|
330
|
+
operation,
|
|
331
|
+
ids,
|
|
332
|
+
...operation === "move" ? { folderId: options.folderId ?? null } : {}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
var FoldersApi = class {
|
|
337
|
+
constructor(client) {
|
|
338
|
+
this.client = client;
|
|
339
|
+
}
|
|
340
|
+
client;
|
|
341
|
+
async list(query = {}) {
|
|
342
|
+
const { parentId, ...rest } = query;
|
|
343
|
+
const path = parentId !== void 0 ? `/api/folders?parentId=${encodeURIComponent(parentId ?? "")}` : `/api/folders${buildQuery(rest)}`;
|
|
344
|
+
return this.client.request("GET", path);
|
|
345
|
+
}
|
|
346
|
+
/** The full active folder tree (for move dialogs). */
|
|
347
|
+
async tree() {
|
|
348
|
+
const { folders } = await this.client.request(
|
|
349
|
+
"GET",
|
|
350
|
+
"/api/folders/tree"
|
|
351
|
+
);
|
|
352
|
+
return folders;
|
|
353
|
+
}
|
|
354
|
+
async create(name, parentId = null) {
|
|
355
|
+
const { folder } = await this.client.request("POST", "/api/folders", {
|
|
356
|
+
name,
|
|
357
|
+
parentId
|
|
358
|
+
});
|
|
359
|
+
return folder;
|
|
360
|
+
}
|
|
361
|
+
async rename(id, name) {
|
|
362
|
+
const { folder } = await this.client.request(
|
|
363
|
+
"PATCH",
|
|
364
|
+
`/api/folders/${id}`,
|
|
365
|
+
{ name }
|
|
366
|
+
);
|
|
367
|
+
return folder;
|
|
368
|
+
}
|
|
369
|
+
async star(id, starred = true) {
|
|
370
|
+
const { folder } = await this.client.request(
|
|
371
|
+
"PATCH",
|
|
372
|
+
`/api/folders/${id}`,
|
|
373
|
+
{ starred }
|
|
374
|
+
);
|
|
375
|
+
return folder;
|
|
376
|
+
}
|
|
377
|
+
async move(id, parentId) {
|
|
378
|
+
const { folder } = await this.client.request(
|
|
379
|
+
"POST",
|
|
380
|
+
`/api/folders/${id}/move`,
|
|
381
|
+
{ parentId }
|
|
382
|
+
);
|
|
383
|
+
return folder;
|
|
384
|
+
}
|
|
385
|
+
async trash(id) {
|
|
386
|
+
return this.client.request("POST", `/api/folders/${id}/trash`);
|
|
387
|
+
}
|
|
388
|
+
async restore(id) {
|
|
389
|
+
return this.client.request("POST", `/api/folders/${id}/restore`);
|
|
390
|
+
}
|
|
391
|
+
/** Permanently deletes the folder subtree and its Telegram objects. */
|
|
392
|
+
async delete(id) {
|
|
393
|
+
return this.client.request("DELETE", `/api/folders/${id}`);
|
|
394
|
+
}
|
|
395
|
+
async batch(operation, ids, options = {}) {
|
|
396
|
+
return this.client.request("POST", "/api/folders/batch", {
|
|
397
|
+
operation,
|
|
398
|
+
ids,
|
|
399
|
+
...operation === "move" ? { parentId: options.parentId ?? null } : {}
|
|
161
400
|
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var TrashApi = class {
|
|
404
|
+
constructor(client) {
|
|
405
|
+
this.client = client;
|
|
406
|
+
}
|
|
407
|
+
client;
|
|
408
|
+
async list(query = {}) {
|
|
409
|
+
return this.client.request("GET", `/api/trash${buildQuery(query)}`);
|
|
410
|
+
}
|
|
411
|
+
/** Permanently removes everything in the Trash. */
|
|
412
|
+
async empty() {
|
|
413
|
+
return this.client.request("POST", "/api/trash/empty");
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
var StorageApi = class {
|
|
417
|
+
constructor(client) {
|
|
418
|
+
this.client = client;
|
|
419
|
+
}
|
|
420
|
+
client;
|
|
421
|
+
/** Creates the private storage channel if it does not exist yet. */
|
|
422
|
+
async ensure() {
|
|
423
|
+
const { storage } = await this.client.request(
|
|
424
|
+
"POST",
|
|
425
|
+
"/api/storage/ensure"
|
|
426
|
+
);
|
|
427
|
+
return storage;
|
|
428
|
+
}
|
|
429
|
+
async stats() {
|
|
430
|
+
const { stats } = await this.client.request(
|
|
431
|
+
"GET",
|
|
432
|
+
"/api/storage/stats"
|
|
433
|
+
);
|
|
434
|
+
return stats;
|
|
435
|
+
}
|
|
436
|
+
async health() {
|
|
437
|
+
return this.client.request("GET", "/api/storage/health");
|
|
438
|
+
}
|
|
439
|
+
/** Probes the storage backend; `deep` performs a real Telegram round-trip. */
|
|
440
|
+
async check(deep = false) {
|
|
441
|
+
const { health } = await this.client.request(
|
|
442
|
+
"POST",
|
|
443
|
+
"/api/storage/health/check",
|
|
444
|
+
{ deep }
|
|
445
|
+
);
|
|
446
|
+
return health;
|
|
447
|
+
}
|
|
448
|
+
/** Read-only integrity scan; nothing is repaired or deleted. */
|
|
449
|
+
async integrityCheck(options = {}) {
|
|
450
|
+
const { report } = await this.client.request(
|
|
451
|
+
"POST",
|
|
452
|
+
"/api/storage/integrity/check",
|
|
453
|
+
{ deep: options.deep ?? false }
|
|
454
|
+
);
|
|
455
|
+
return report;
|
|
456
|
+
}
|
|
457
|
+
/** Public server health (no authentication required). */
|
|
458
|
+
async serverHealth() {
|
|
459
|
+
return this.client.request("GET", "/api/health");
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
var SearchApi = class {
|
|
463
|
+
constructor(client) {
|
|
464
|
+
this.client = client;
|
|
465
|
+
}
|
|
466
|
+
client;
|
|
467
|
+
/**
|
|
468
|
+
* Searches with the v0.2 query language:
|
|
469
|
+
* `type:pdf`, `size:>100MB`, `folder:Projects`, `starred:true`, `after:2026-01-01`
|
|
470
|
+
*/
|
|
471
|
+
async query(q, page = {}) {
|
|
472
|
+
return this.client.request(
|
|
473
|
+
"GET",
|
|
474
|
+
`/api/search${buildQuery({ q, ...page })}`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
var RecentApi = class {
|
|
479
|
+
constructor(client) {
|
|
480
|
+
this.client = client;
|
|
481
|
+
}
|
|
482
|
+
client;
|
|
483
|
+
async list(page = {}) {
|
|
484
|
+
return this.client.request("GET", `/api/recent${buildQuery(page)}`);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
var ActivityApi = class {
|
|
488
|
+
constructor(client) {
|
|
489
|
+
this.client = client;
|
|
490
|
+
}
|
|
491
|
+
client;
|
|
492
|
+
async list(page = {}) {
|
|
493
|
+
return this.client.request("GET", `/api/activity${buildQuery(page)}`);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
var AuthApi = class {
|
|
497
|
+
constructor(client) {
|
|
498
|
+
this.client = client;
|
|
499
|
+
}
|
|
500
|
+
client;
|
|
501
|
+
async me() {
|
|
502
|
+
return this.client.request("GET", "/api/auth/me");
|
|
503
|
+
}
|
|
504
|
+
async startTelegramLogin(phone) {
|
|
505
|
+
await this.client.request("POST", "/api/auth/telegram/start", { phone });
|
|
506
|
+
}
|
|
507
|
+
async verifyTelegramCode(phone, code) {
|
|
508
|
+
return this.client.request("POST", "/api/auth/telegram/verify", {
|
|
509
|
+
phone,
|
|
510
|
+
code
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async submitTelegramPassword(phone, password) {
|
|
514
|
+
return this.client.request("POST", "/api/auth/telegram/password", { phone, password });
|
|
515
|
+
}
|
|
516
|
+
async logout() {
|
|
517
|
+
await this.client.request("POST", "/api/auth/logout");
|
|
173
518
|
}
|
|
174
519
|
};
|
|
175
520
|
|
|
@@ -289,18 +634,205 @@ function fileCategory(mimeType) {
|
|
|
289
634
|
if (OFFICE_MIMES.has(mimeType)) return "document";
|
|
290
635
|
return "other";
|
|
291
636
|
}
|
|
637
|
+
var FILE_CATEGORIES = [
|
|
638
|
+
"image",
|
|
639
|
+
"video",
|
|
640
|
+
"audio",
|
|
641
|
+
"pdf",
|
|
642
|
+
"archive",
|
|
643
|
+
"document",
|
|
644
|
+
"text",
|
|
645
|
+
"other"
|
|
646
|
+
];
|
|
647
|
+
var CATEGORY_PREFIXES = {
|
|
648
|
+
image: ["image/"],
|
|
649
|
+
video: ["video/"],
|
|
650
|
+
audio: ["audio/"],
|
|
651
|
+
pdf: ["application/pdf"],
|
|
652
|
+
archive: [
|
|
653
|
+
"application/zip",
|
|
654
|
+
"application/x-tar",
|
|
655
|
+
"application/gzip",
|
|
656
|
+
"application/x-7z-compressed",
|
|
657
|
+
"application/vnd.rar",
|
|
658
|
+
"application/x-bzip2",
|
|
659
|
+
"application/x-xz"
|
|
660
|
+
],
|
|
661
|
+
text: [
|
|
662
|
+
"text/",
|
|
663
|
+
"application/json",
|
|
664
|
+
"application/xml",
|
|
665
|
+
"application/yaml",
|
|
666
|
+
"application/javascript"
|
|
667
|
+
],
|
|
668
|
+
document: [...OFFICE_MIMES],
|
|
669
|
+
other: []
|
|
670
|
+
};
|
|
671
|
+
function isFileCategory(value) {
|
|
672
|
+
return FILE_CATEGORIES.includes(value);
|
|
673
|
+
}
|
|
674
|
+
function mimeMatchersForType(value) {
|
|
675
|
+
const normalized = value.toLowerCase().trim();
|
|
676
|
+
if (!normalized) return null;
|
|
677
|
+
if (isFileCategory(normalized)) {
|
|
678
|
+
const prefixes = CATEGORY_PREFIXES[normalized];
|
|
679
|
+
return prefixes.length > 0 ? prefixes : null;
|
|
680
|
+
}
|
|
681
|
+
return [normalized];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ../shared/src/query.ts
|
|
685
|
+
var SIZE_UNITS = {
|
|
686
|
+
b: 1,
|
|
687
|
+
kb: 1024,
|
|
688
|
+
mb: 1024 ** 2,
|
|
689
|
+
gb: 1024 ** 3,
|
|
690
|
+
tb: 1024 ** 4
|
|
691
|
+
};
|
|
692
|
+
function parseSize(value) {
|
|
693
|
+
const match = /^\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]*)\s*$/.exec(value);
|
|
694
|
+
if (!match) return null;
|
|
695
|
+
const amount = Number(match[1]);
|
|
696
|
+
if (!Number.isFinite(amount) || amount < 0) return null;
|
|
697
|
+
const unit = (match[2] ?? "").toLowerCase() || "b";
|
|
698
|
+
const factor = SIZE_UNITS[unit];
|
|
699
|
+
if (factor === void 0) return null;
|
|
700
|
+
return Math.round(amount * factor);
|
|
701
|
+
}
|
|
702
|
+
function parseSizeComparison(value) {
|
|
703
|
+
const match = /^\s*(>=|<=|>|<|=)?\s*(.+)$/.exec(value);
|
|
704
|
+
if (!match) return null;
|
|
705
|
+
const operator = match[1] ?? "=";
|
|
706
|
+
const bytes = parseSize(match[2]);
|
|
707
|
+
if (bytes === null) return null;
|
|
708
|
+
switch (operator) {
|
|
709
|
+
case ">":
|
|
710
|
+
return { min: bytes + 1 };
|
|
711
|
+
case ">=":
|
|
712
|
+
return { min: bytes };
|
|
713
|
+
case "<":
|
|
714
|
+
return { max: bytes - 1 };
|
|
715
|
+
case "<=":
|
|
716
|
+
return { max: bytes };
|
|
717
|
+
default:
|
|
718
|
+
return { min: bytes, max: bytes };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function parseBoolean(value) {
|
|
722
|
+
const normalized = value.toLowerCase();
|
|
723
|
+
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
|
724
|
+
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
function parseDate(value, endOfDay) {
|
|
728
|
+
const trimmed = value.trim();
|
|
729
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
730
|
+
const date2 = /* @__PURE__ */ new Date(`${trimmed}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`);
|
|
731
|
+
return Number.isNaN(date2.getTime()) ? null : date2;
|
|
732
|
+
}
|
|
733
|
+
const date = new Date(trimmed);
|
|
734
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
735
|
+
}
|
|
736
|
+
function parseSearchQuery(input, base = {}) {
|
|
737
|
+
const query = { ...base };
|
|
738
|
+
const terms = [];
|
|
739
|
+
for (const rawToken of input.trim().split(/\s+/)) {
|
|
740
|
+
if (!rawToken) continue;
|
|
741
|
+
const separator = rawToken.indexOf(":");
|
|
742
|
+
if (separator <= 0) {
|
|
743
|
+
terms.push(rawToken);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const field = rawToken.slice(0, separator).toLowerCase();
|
|
747
|
+
const value = rawToken.slice(separator + 1);
|
|
748
|
+
if (!value) {
|
|
749
|
+
terms.push(rawToken);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
switch (field) {
|
|
753
|
+
case "name":
|
|
754
|
+
case "filename":
|
|
755
|
+
terms.push(value);
|
|
756
|
+
break;
|
|
757
|
+
case "type":
|
|
758
|
+
query.type = value.toLowerCase();
|
|
759
|
+
break;
|
|
760
|
+
case "ext":
|
|
761
|
+
case "extension":
|
|
762
|
+
query.ext = value.replace(/^\./, "").toLowerCase();
|
|
763
|
+
break;
|
|
764
|
+
case "size": {
|
|
765
|
+
const comparison = parseSizeComparison(value);
|
|
766
|
+
if (!comparison) {
|
|
767
|
+
terms.push(rawToken);
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
if (comparison.min !== void 0) query.minSize = comparison.min;
|
|
771
|
+
if (comparison.max !== void 0) query.maxSize = comparison.max;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
case "folder":
|
|
775
|
+
query.folderName = value;
|
|
776
|
+
break;
|
|
777
|
+
case "starred": {
|
|
778
|
+
const parsed = parseBoolean(value);
|
|
779
|
+
if (parsed === null) terms.push(rawToken);
|
|
780
|
+
else query.starred = parsed;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case "trashed":
|
|
784
|
+
case "deleted": {
|
|
785
|
+
const parsed = parseBoolean(value);
|
|
786
|
+
if (parsed === null) terms.push(rawToken);
|
|
787
|
+
else query.status = parsed ? "trashed" : "active";
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case "after":
|
|
791
|
+
case "since": {
|
|
792
|
+
const date = parseDate(value, false);
|
|
793
|
+
if (!date) terms.push(rawToken);
|
|
794
|
+
else query.from = date.toISOString();
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
case "before": {
|
|
798
|
+
const date = parseDate(value, true);
|
|
799
|
+
if (!date) terms.push(rawToken);
|
|
800
|
+
else query.to = date.toISOString();
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
default:
|
|
804
|
+
terms.push(rawToken);
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const freeText = terms.join(" ").trim();
|
|
809
|
+
if (freeText) query.q = freeText;
|
|
810
|
+
return { query, freeText };
|
|
811
|
+
}
|
|
812
|
+
function paginationMeta(page, limit, total) {
|
|
813
|
+
return { page, limit, total, hasMore: page * limit < total };
|
|
814
|
+
}
|
|
292
815
|
|
|
293
816
|
// ../shared/src/index.ts
|
|
294
817
|
var ERROR_CODES = {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
818
|
+
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
819
|
+
AUTH_INVALID: "AUTH_INVALID",
|
|
820
|
+
PERMISSION_DENIED: "PERMISSION_DENIED",
|
|
821
|
+
INVALID_REQUEST: "INVALID_REQUEST",
|
|
298
822
|
NOT_FOUND: "NOT_FOUND",
|
|
823
|
+
FILE_NOT_FOUND: "FILE_NOT_FOUND",
|
|
824
|
+
FOLDER_NOT_FOUND: "FOLDER_NOT_FOUND",
|
|
299
825
|
CONFLICT: "CONFLICT",
|
|
300
826
|
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
|
301
827
|
RATE_LIMITED: "RATE_LIMITED",
|
|
302
828
|
STORAGE_NOT_INITIALIZED: "STORAGE_NOT_INITIALIZED",
|
|
303
|
-
|
|
829
|
+
STORAGE_UNAVAILABLE: "STORAGE_UNAVAILABLE",
|
|
830
|
+
TELEGRAM_AUTH_REQUIRED: "TELEGRAM_AUTH_REQUIRED",
|
|
831
|
+
TELEGRAM_CONNECTION_FAILED: "TELEGRAM_CONNECTION_FAILED",
|
|
832
|
+
TELEGRAM_FILE_NOT_FOUND: "TELEGRAM_FILE_NOT_FOUND",
|
|
833
|
+
UPLOAD_FAILED: "UPLOAD_FAILED",
|
|
834
|
+
DOWNLOAD_FAILED: "DOWNLOAD_FAILED",
|
|
835
|
+
INTEGRITY_CHECK_FAILED: "INTEGRITY_CHECK_FAILED",
|
|
304
836
|
INTERNAL: "INTERNAL_ERROR"
|
|
305
837
|
};
|
|
306
838
|
|
|
@@ -319,22 +851,31 @@ var DomainError = class extends Error {
|
|
|
319
851
|
};
|
|
320
852
|
var ValidationError = class extends DomainError {
|
|
321
853
|
constructor(message, details) {
|
|
322
|
-
super(ERROR_CODES.
|
|
854
|
+
super(ERROR_CODES.INVALID_REQUEST, 400, message, details);
|
|
323
855
|
}
|
|
324
856
|
};
|
|
325
857
|
var UnauthorizedError = class extends DomainError {
|
|
326
858
|
constructor(message = "Authentication required") {
|
|
327
|
-
super(ERROR_CODES.
|
|
859
|
+
super(ERROR_CODES.AUTH_REQUIRED, 401, message);
|
|
328
860
|
}
|
|
329
861
|
};
|
|
330
862
|
var ForbiddenError = class extends DomainError {
|
|
331
863
|
constructor(message = "You do not have access to this resource") {
|
|
332
|
-
super(ERROR_CODES.
|
|
864
|
+
super(ERROR_CODES.PERMISSION_DENIED, 403, message);
|
|
333
865
|
}
|
|
334
866
|
};
|
|
335
867
|
var NotFoundError = class extends DomainError {
|
|
336
|
-
constructor(message = "Resource not found") {
|
|
337
|
-
super(
|
|
868
|
+
constructor(message = "Resource not found", resource) {
|
|
869
|
+
super(
|
|
870
|
+
resource === "file" ? ERROR_CODES.FILE_NOT_FOUND : resource === "folder" ? ERROR_CODES.FOLDER_NOT_FOUND : ERROR_CODES.NOT_FOUND,
|
|
871
|
+
404,
|
|
872
|
+
message
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
var ConflictError = class extends DomainError {
|
|
877
|
+
constructor(message) {
|
|
878
|
+
super(ERROR_CODES.CONFLICT, 409, message);
|
|
338
879
|
}
|
|
339
880
|
};
|
|
340
881
|
var RateLimitedError = class extends DomainError {
|
|
@@ -347,11 +888,39 @@ var StorageNotInitializedError = class extends DomainError {
|
|
|
347
888
|
super(ERROR_CODES.STORAGE_NOT_INITIALIZED, 409, "Storage is not initialized for this account");
|
|
348
889
|
}
|
|
349
890
|
};
|
|
350
|
-
var
|
|
351
|
-
constructor(message
|
|
352
|
-
super(ERROR_CODES.
|
|
891
|
+
var TelegramAuthRequiredError = class extends DomainError {
|
|
892
|
+
constructor(message = "Your Telegram session has expired \u2014 please reconnect") {
|
|
893
|
+
super(ERROR_CODES.TELEGRAM_AUTH_REQUIRED, 401, message);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
var TelegramConnectionError = class extends DomainError {
|
|
897
|
+
constructor(message = "Could not reach Telegram", details) {
|
|
898
|
+
super(ERROR_CODES.TELEGRAM_CONNECTION_FAILED, 502, message, details);
|
|
353
899
|
}
|
|
354
900
|
};
|
|
901
|
+
var TelegramFileNotFoundError = class extends DomainError {
|
|
902
|
+
constructor(message = "The stored file is missing from Telegram") {
|
|
903
|
+
super(ERROR_CODES.TELEGRAM_FILE_NOT_FOUND, 404, message);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
var UploadFailedError = class extends DomainError {
|
|
907
|
+
constructor(message = "Upload failed", details) {
|
|
908
|
+
super(ERROR_CODES.UPLOAD_FAILED, 502, message, details);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
var OperationCancelledError = class extends DomainError {
|
|
912
|
+
constructor(message = "Operation cancelled") {
|
|
913
|
+
super(ERROR_CODES.INVALID_REQUEST, 499, message);
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
function mapProviderError(message, error) {
|
|
917
|
+
if (error instanceof DomainError) return error;
|
|
918
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
919
|
+
return new UploadFailedError(detail ? `${message}: ${detail}` : message, error);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ../core/src/storage/engine.ts
|
|
923
|
+
import { Readable } from "stream";
|
|
355
924
|
|
|
356
925
|
// ../core/src/utils/hash.ts
|
|
357
926
|
import { createHash } from "crypto";
|
|
@@ -360,49 +929,237 @@ function sha256Hex(data) {
|
|
|
360
929
|
}
|
|
361
930
|
|
|
362
931
|
// ../core/src/storage/engine.ts
|
|
932
|
+
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
|
+
function delay(ms, signal) {
|
|
943
|
+
return new Promise((resolve, reject) => {
|
|
944
|
+
if (signal?.aborted) {
|
|
945
|
+
reject(new OperationCancelledError());
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const timer = setTimeout(() => {
|
|
949
|
+
signal?.removeEventListener("abort", onAbort);
|
|
950
|
+
resolve();
|
|
951
|
+
}, ms);
|
|
952
|
+
function onAbort() {
|
|
953
|
+
clearTimeout(timer);
|
|
954
|
+
reject(new OperationCancelledError());
|
|
955
|
+
}
|
|
956
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
function throwIfAborted(signal) {
|
|
960
|
+
if (signal?.aborted) throw new OperationCancelledError();
|
|
961
|
+
}
|
|
363
962
|
var StorageEngine = class {
|
|
364
|
-
constructor(provider) {
|
|
963
|
+
constructor(provider, retryPolicy = DEFAULT_RETRY_POLICY) {
|
|
365
964
|
this.provider = provider;
|
|
965
|
+
this.retryPolicy = retryPolicy;
|
|
366
966
|
}
|
|
367
967
|
provider;
|
|
968
|
+
retryPolicy;
|
|
368
969
|
get providerName() {
|
|
369
970
|
return this.provider.name;
|
|
370
971
|
}
|
|
371
|
-
/**
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
972
|
+
/**
|
|
973
|
+
* Uploads an object, computing its SHA-256 as it goes and reporting
|
|
974
|
+
* progress. Retries transient failures with exponential backoff.
|
|
975
|
+
*/
|
|
976
|
+
async upload(input, control = {}) {
|
|
977
|
+
const data = await this.readInput(input);
|
|
978
|
+
const sha256 = sha256Hex(data);
|
|
979
|
+
control.onProgress?.({ transferred: 0, total: data.byteLength, percent: 0 });
|
|
980
|
+
const stored = await this.withRetry(
|
|
981
|
+
() => this.provider.put({ ...input, data }, control),
|
|
982
|
+
control,
|
|
983
|
+
(error) => new UploadFailedError("Upload failed", error)
|
|
984
|
+
);
|
|
985
|
+
control.onProgress?.({ transferred: data.byteLength, total: data.byteLength, percent: 100 });
|
|
986
|
+
return { stored, sha256, size: data.byteLength };
|
|
987
|
+
}
|
|
988
|
+
async download(ref, control = {}) {
|
|
989
|
+
return this.withRetry(
|
|
990
|
+
() => this.provider.get(ref, control),
|
|
991
|
+
control,
|
|
992
|
+
(error) => mapProviderError("Download failed", error)
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
/** Streams the object, computing the checksum on the fly when requested. */
|
|
996
|
+
async downloadStream(ref, control = {}) {
|
|
997
|
+
const stream = await this.withRetry(
|
|
998
|
+
() => this.provider.getStream(ref, control),
|
|
999
|
+
control,
|
|
1000
|
+
(error) => mapProviderError("Download failed", error)
|
|
1001
|
+
);
|
|
1002
|
+
return { stream, hash: () => null };
|
|
1003
|
+
}
|
|
1004
|
+
async remove(ref) {
|
|
1005
|
+
await this.withRetry(
|
|
1006
|
+
() => this.provider.delete(ref),
|
|
1007
|
+
{},
|
|
1008
|
+
(error) => mapProviderError("Delete failed", error)
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
async exists(ref) {
|
|
375
1012
|
try {
|
|
376
|
-
|
|
1013
|
+
return await this.provider.exists(ref);
|
|
377
1014
|
} catch (error) {
|
|
378
|
-
throw mapProviderError("
|
|
1015
|
+
throw mapProviderError("Storage lookup failed", error);
|
|
379
1016
|
}
|
|
380
|
-
return { stored, sha256, size: input.data.byteLength };
|
|
381
1017
|
}
|
|
382
|
-
async
|
|
1018
|
+
async stat(ref) {
|
|
383
1019
|
try {
|
|
384
|
-
return await this.provider.
|
|
1020
|
+
return await this.provider.stat(ref);
|
|
385
1021
|
} catch (error) {
|
|
386
|
-
throw mapProviderError("
|
|
1022
|
+
throw mapProviderError("Storage lookup failed", error);
|
|
387
1023
|
}
|
|
388
1024
|
}
|
|
389
|
-
async
|
|
1025
|
+
async health() {
|
|
390
1026
|
try {
|
|
391
|
-
await this.provider.
|
|
1027
|
+
return await this.provider.healthCheck();
|
|
392
1028
|
} catch (error) {
|
|
393
|
-
|
|
1029
|
+
return {
|
|
1030
|
+
healthy: false,
|
|
1031
|
+
latencyMs: null,
|
|
1032
|
+
message: error instanceof Error ? error.message : "Storage health check failed",
|
|
1033
|
+
targetTitle: null
|
|
1034
|
+
};
|
|
394
1035
|
}
|
|
395
1036
|
}
|
|
396
1037
|
async verifyIntegrity(data, expectedSha256) {
|
|
397
1038
|
return sha256Hex(data) === expectedSha256;
|
|
398
1039
|
}
|
|
1040
|
+
// ── 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
|
+
async withRetry(operation, control, wrapError) {
|
|
1054
|
+
const { attempts, baseDelayMs } = this.retryPolicy;
|
|
1055
|
+
let lastError;
|
|
1056
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
|
|
1057
|
+
throwIfAborted(control.signal);
|
|
1058
|
+
try {
|
|
1059
|
+
return await operation();
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
lastError = error;
|
|
1062
|
+
if (attempt >= attempts || !isRetryable(error)) break;
|
|
1063
|
+
await delay(baseDelayMs * 2 ** (attempt - 1), control.signal);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
throw wrapError(lastError);
|
|
1067
|
+
}
|
|
399
1068
|
};
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
1069
|
+
|
|
1070
|
+
// ../core/src/services/query-resolver.ts
|
|
1071
|
+
async function resolveItemQuery(folders, userId, input) {
|
|
1072
|
+
const query = {};
|
|
1073
|
+
if (input.folderId !== void 0) query.folderId = input.folderId;
|
|
1074
|
+
if (input.q && input.q.trim()) query.nameContains = input.q.trim();
|
|
1075
|
+
if (input.ext) query.ext = input.ext.replace(/^\./, "").toLowerCase();
|
|
1076
|
+
if (input.starred !== void 0) query.starred = input.starred;
|
|
1077
|
+
if (input.minSize !== void 0) query.minSize = input.minSize;
|
|
1078
|
+
if (input.maxSize !== void 0) query.maxSize = input.maxSize;
|
|
1079
|
+
if (input.sort) query.sort = input.sort;
|
|
1080
|
+
if (input.order) query.order = input.order;
|
|
1081
|
+
switch (input.status) {
|
|
1082
|
+
case "active":
|
|
1083
|
+
query.trashed = false;
|
|
1084
|
+
break;
|
|
1085
|
+
case "trashed":
|
|
1086
|
+
query.trashed = true;
|
|
1087
|
+
break;
|
|
1088
|
+
default:
|
|
1089
|
+
query.trashed = void 0;
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
if (input.type) {
|
|
1093
|
+
const matchers = mimeMatchersForType(input.type);
|
|
1094
|
+
if (matchers) query.mimeMatchers = matchers;
|
|
1095
|
+
}
|
|
1096
|
+
if (input.from) {
|
|
1097
|
+
const from = new Date(input.from);
|
|
1098
|
+
if (!Number.isNaN(from.getTime())) query.from = from;
|
|
1099
|
+
}
|
|
1100
|
+
if (input.to) {
|
|
1101
|
+
const to = new Date(input.to);
|
|
1102
|
+
if (!Number.isNaN(to.getTime())) query.to = to;
|
|
1103
|
+
}
|
|
1104
|
+
if (input.folderName) {
|
|
1105
|
+
query.folderIds = await folders.findIdsByName(userId, input.folderName);
|
|
1106
|
+
}
|
|
1107
|
+
return query;
|
|
404
1108
|
}
|
|
405
1109
|
|
|
1110
|
+
// ../core/src/services/activity-service.ts
|
|
1111
|
+
var ActivityService = class {
|
|
1112
|
+
constructor(activity, retentionDays = 90) {
|
|
1113
|
+
this.activity = activity;
|
|
1114
|
+
this.retentionDays = retentionDays;
|
|
1115
|
+
}
|
|
1116
|
+
activity;
|
|
1117
|
+
retentionDays;
|
|
1118
|
+
/**
|
|
1119
|
+
* Records an event. Never throws — a logging failure must not fail the
|
|
1120
|
+
* user-facing operation that produced it.
|
|
1121
|
+
*/
|
|
1122
|
+
async record(input) {
|
|
1123
|
+
try {
|
|
1124
|
+
await this.activity.record({
|
|
1125
|
+
userId: input.userId,
|
|
1126
|
+
action: input.action,
|
|
1127
|
+
resourceType: input.resourceType,
|
|
1128
|
+
resourceId: input.resourceId,
|
|
1129
|
+
resourceName: input.resourceName ?? null,
|
|
1130
|
+
metadata: sanitizeMetadata(input.metadata)
|
|
1131
|
+
});
|
|
1132
|
+
} catch {
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async list(userId, page) {
|
|
1136
|
+
return this.activity.list(userId, page);
|
|
1137
|
+
}
|
|
1138
|
+
/** Most recent distinct files for the given actions, for the Recent view. */
|
|
1139
|
+
async recentFiles(userId, actions, limit) {
|
|
1140
|
+
return this.activity.recentFiles(userId, actions, limit);
|
|
1141
|
+
}
|
|
1142
|
+
async prune() {
|
|
1143
|
+
const cutoff = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3);
|
|
1144
|
+
return this.activity.pruneOlderThan(cutoff);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
var SENSITIVE_KEY = /(password|secret|token|session|hash|credential)/i;
|
|
1148
|
+
function sanitizeMetadata(metadata) {
|
|
1149
|
+
if (!metadata) return null;
|
|
1150
|
+
const clean = {};
|
|
1151
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
1152
|
+
if (SENSITIVE_KEY.test(key)) continue;
|
|
1153
|
+
if (value === void 0) continue;
|
|
1154
|
+
clean[key] = value;
|
|
1155
|
+
}
|
|
1156
|
+
return Object.keys(clean).length > 0 ? clean : null;
|
|
1157
|
+
}
|
|
1158
|
+
var noopActivityRecorder = {
|
|
1159
|
+
async record() {
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
|
|
406
1163
|
// ../core/src/utils/filename.ts
|
|
407
1164
|
function sanitizeFileName(name) {
|
|
408
1165
|
const base = name.split(/[/\\]/).pop() ?? name;
|
|
@@ -413,170 +1170,588 @@ function sanitizeFileName(name) {
|
|
|
413
1170
|
|
|
414
1171
|
// ../core/src/services/file-service.ts
|
|
415
1172
|
var FileService = class {
|
|
416
|
-
constructor(files, folders, engineFor) {
|
|
1173
|
+
constructor(files, folders, engineFor, activity = noopActivityRecorder) {
|
|
417
1174
|
this.files = files;
|
|
418
1175
|
this.folders = folders;
|
|
419
1176
|
this.engineFor = engineFor;
|
|
1177
|
+
this.activity = activity;
|
|
420
1178
|
}
|
|
421
1179
|
files;
|
|
422
1180
|
folders;
|
|
423
1181
|
engineFor;
|
|
1182
|
+
activity;
|
|
1183
|
+
// ── Upload ───────────────────────────────────────────────────────────────
|
|
424
1184
|
async upload(userId, input) {
|
|
425
1185
|
const name = sanitizeFileName(input.name);
|
|
426
1186
|
if (!name) throw new ValidationError("Invalid file name");
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1187
|
+
let folderId = input.folderId;
|
|
1188
|
+
if (input.replaceFileId) {
|
|
1189
|
+
const existing = await this.get(userId, input.replaceFileId);
|
|
1190
|
+
folderId = existing.folderId;
|
|
1191
|
+
} else if (folderId !== null) {
|
|
1192
|
+
await this.assertFolder(userId, folderId);
|
|
430
1193
|
}
|
|
431
1194
|
const mimeType = mimeFromFilename(name);
|
|
432
1195
|
const engine = await this.engineFor(userId);
|
|
433
|
-
const
|
|
434
|
-
|
|
1196
|
+
const control = {
|
|
1197
|
+
onProgress: input.onProgress,
|
|
1198
|
+
signal: input.signal
|
|
1199
|
+
};
|
|
1200
|
+
const { stored, sha256, size } = await engine.upload(
|
|
1201
|
+
{ name, mimeType, data: input.data },
|
|
1202
|
+
control
|
|
1203
|
+
);
|
|
1204
|
+
const telegramMessageId = Number(stored.messageId);
|
|
1205
|
+
if (input.replaceFileId) {
|
|
1206
|
+
return this.replaceVersion(userId, input.replaceFileId, {
|
|
1207
|
+
name,
|
|
1208
|
+
size,
|
|
1209
|
+
mimeType,
|
|
1210
|
+
sha256,
|
|
1211
|
+
telegramMessageId
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
const record = await this.files.create({
|
|
435
1215
|
userId,
|
|
436
|
-
folderId
|
|
1216
|
+
folderId,
|
|
437
1217
|
name,
|
|
438
1218
|
size,
|
|
439
1219
|
mimeType,
|
|
440
1220
|
sha256,
|
|
441
|
-
telegramMessageId
|
|
1221
|
+
telegramMessageId
|
|
442
1222
|
});
|
|
1223
|
+
const version = await this.files.createVersion({
|
|
1224
|
+
fileId: record.id,
|
|
1225
|
+
userId,
|
|
1226
|
+
size,
|
|
1227
|
+
mimeType,
|
|
1228
|
+
sha256,
|
|
1229
|
+
telegramMessageId
|
|
1230
|
+
});
|
|
1231
|
+
const withVersion = await this.files.update(record.id, {
|
|
1232
|
+
currentVersionId: version.id,
|
|
1233
|
+
versionCount: 1
|
|
1234
|
+
});
|
|
1235
|
+
await this.activity.record({
|
|
1236
|
+
userId,
|
|
1237
|
+
action: "upload",
|
|
1238
|
+
resourceType: "file",
|
|
1239
|
+
resourceId: withVersion.id,
|
|
1240
|
+
resourceName: withVersion.name,
|
|
1241
|
+
metadata: { size, mimeType }
|
|
1242
|
+
});
|
|
1243
|
+
return withVersion;
|
|
1244
|
+
}
|
|
1245
|
+
/** Uploads a new version of an existing file and makes it current. */
|
|
1246
|
+
async replaceVersion(userId, fileId, next) {
|
|
1247
|
+
const existing = await this.get(userId, fileId);
|
|
1248
|
+
const version = await this.files.createVersion({
|
|
1249
|
+
fileId,
|
|
1250
|
+
userId,
|
|
1251
|
+
size: next.size,
|
|
1252
|
+
mimeType: next.mimeType,
|
|
1253
|
+
sha256: next.sha256,
|
|
1254
|
+
telegramMessageId: next.telegramMessageId
|
|
1255
|
+
});
|
|
1256
|
+
const count = await this.files.countVersions(fileId);
|
|
1257
|
+
const updated = await this.files.update(fileId, {
|
|
1258
|
+
name: next.name,
|
|
1259
|
+
size: next.size,
|
|
1260
|
+
mimeType: next.mimeType,
|
|
1261
|
+
sha256: next.sha256,
|
|
1262
|
+
telegramMessageId: next.telegramMessageId,
|
|
1263
|
+
currentVersionId: version.id,
|
|
1264
|
+
versionCount: count
|
|
1265
|
+
});
|
|
1266
|
+
await this.activity.record({
|
|
1267
|
+
userId,
|
|
1268
|
+
action: "replace",
|
|
1269
|
+
resourceType: "file",
|
|
1270
|
+
resourceId: fileId,
|
|
1271
|
+
resourceName: updated.name,
|
|
1272
|
+
metadata: { previousSha256: existing.sha256, size: next.size }
|
|
1273
|
+
});
|
|
1274
|
+
return updated;
|
|
443
1275
|
}
|
|
1276
|
+
// ── Reads ────────────────────────────────────────────────────────────────
|
|
444
1277
|
async list(userId, folderId) {
|
|
445
|
-
if (folderId !== null)
|
|
446
|
-
const folder = await this.folders.findById(folderId);
|
|
447
|
-
if (!folder || folder.userId !== userId) throw new NotFoundError("Folder not found");
|
|
448
|
-
}
|
|
1278
|
+
if (folderId !== null) await this.assertFolder(userId, folderId);
|
|
449
1279
|
return this.files.listByFolder(userId, folderId);
|
|
450
1280
|
}
|
|
1281
|
+
async query(userId, input, page) {
|
|
1282
|
+
if (input.folderId != null) await this.assertFolder(userId, input.folderId);
|
|
1283
|
+
const query = await resolveItemQuery(this.folders, userId, input);
|
|
1284
|
+
return this.files.query(userId, query, page);
|
|
1285
|
+
}
|
|
451
1286
|
async get(userId, id) {
|
|
452
1287
|
const record = await this.files.findById(id);
|
|
453
|
-
if (!record || record.userId !== userId) throw new NotFoundError("File not found");
|
|
1288
|
+
if (!record || record.userId !== userId) throw new NotFoundError("File not found", "file");
|
|
454
1289
|
return record;
|
|
455
1290
|
}
|
|
456
|
-
|
|
1291
|
+
/** Like get(), but rejects trashed files (for download/open). */
|
|
1292
|
+
async getActive(userId, id) {
|
|
1293
|
+
const record = await this.get(userId, id);
|
|
1294
|
+
if (record.deletedAt) throw new NotFoundError("File not found", "file");
|
|
1295
|
+
return record;
|
|
1296
|
+
}
|
|
1297
|
+
async listVersions(userId, id) {
|
|
457
1298
|
await this.get(userId, id);
|
|
1299
|
+
return this.files.listVersions(id);
|
|
1300
|
+
}
|
|
1301
|
+
// ── Mutations ────────────────────────────────────────────────────────────
|
|
1302
|
+
async rename(userId, id, name) {
|
|
1303
|
+
const existing = await this.get(userId, id);
|
|
458
1304
|
const clean = sanitizeFileName(name);
|
|
459
1305
|
if (!clean) throw new ValidationError("Invalid file name");
|
|
460
|
-
|
|
1306
|
+
const updated = await this.files.update(id, { name: clean });
|
|
1307
|
+
await this.activity.record({
|
|
1308
|
+
userId,
|
|
1309
|
+
action: "rename",
|
|
1310
|
+
resourceType: "file",
|
|
1311
|
+
resourceId: id,
|
|
1312
|
+
resourceName: clean,
|
|
1313
|
+
metadata: { previousName: existing.name }
|
|
1314
|
+
});
|
|
1315
|
+
return updated;
|
|
461
1316
|
}
|
|
462
1317
|
async move(userId, id, folderId) {
|
|
1318
|
+
const existing = await this.get(userId, id);
|
|
1319
|
+
if (folderId !== null) await this.assertFolder(userId, folderId);
|
|
1320
|
+
const updated = await this.files.update(id, { folderId });
|
|
1321
|
+
await this.activity.record({
|
|
1322
|
+
userId,
|
|
1323
|
+
action: "move",
|
|
1324
|
+
resourceType: "file",
|
|
1325
|
+
resourceId: id,
|
|
1326
|
+
resourceName: updated.name,
|
|
1327
|
+
metadata: { from: existing.folderId, to: folderId }
|
|
1328
|
+
});
|
|
1329
|
+
return updated;
|
|
1330
|
+
}
|
|
1331
|
+
async setStarred(userId, id, starred) {
|
|
463
1332
|
await this.get(userId, id);
|
|
1333
|
+
const updated = await this.files.update(id, { starred });
|
|
1334
|
+
await this.activity.record({
|
|
1335
|
+
userId,
|
|
1336
|
+
action: starred ? "star" : "unstar",
|
|
1337
|
+
resourceType: "file",
|
|
1338
|
+
resourceId: id,
|
|
1339
|
+
resourceName: updated.name
|
|
1340
|
+
});
|
|
1341
|
+
return updated;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Moves a file to Trash (soft delete). The remote object is untouched, so
|
|
1345
|
+
* restore is lossless.
|
|
1346
|
+
*/
|
|
1347
|
+
async trash(userId, id) {
|
|
1348
|
+
const record = await this.get(userId, id);
|
|
1349
|
+
if (record.deletedAt) return record;
|
|
1350
|
+
const updated = await this.files.update(id, {
|
|
1351
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
1352
|
+
trashBatchId: `solo-${id}`
|
|
1353
|
+
});
|
|
1354
|
+
await this.activity.record({
|
|
1355
|
+
userId,
|
|
1356
|
+
action: "trash",
|
|
1357
|
+
resourceType: "file",
|
|
1358
|
+
resourceId: id,
|
|
1359
|
+
resourceName: updated.name
|
|
1360
|
+
});
|
|
1361
|
+
return updated;
|
|
1362
|
+
}
|
|
1363
|
+
/** Restores a trashed file to its previous folder (or root). */
|
|
1364
|
+
async restore(userId, id) {
|
|
1365
|
+
const record = await this.get(userId, id);
|
|
1366
|
+
let folderId = record.folderId;
|
|
464
1367
|
if (folderId !== null) {
|
|
465
1368
|
const folder = await this.folders.findById(folderId);
|
|
466
|
-
if (!folder || folder.userId !== userId
|
|
1369
|
+
if (!folder || folder.userId !== userId || folder.deletedAt) folderId = null;
|
|
467
1370
|
}
|
|
468
|
-
|
|
1371
|
+
const updated = await this.files.update(id, { deletedAt: null, trashBatchId: null, folderId });
|
|
1372
|
+
await this.activity.record({
|
|
1373
|
+
userId,
|
|
1374
|
+
action: "restore",
|
|
1375
|
+
resourceType: "file",
|
|
1376
|
+
resourceId: id,
|
|
1377
|
+
resourceName: updated.name
|
|
1378
|
+
});
|
|
1379
|
+
return updated;
|
|
469
1380
|
}
|
|
470
|
-
|
|
1381
|
+
/**
|
|
1382
|
+
* Permanently deletes a file: the Telegram object is removed first, then the
|
|
1383
|
+
* metadata. If the remote delete fails we keep the metadata so the user can
|
|
1384
|
+
* retry, rather than silently orphaning the storage object.
|
|
1385
|
+
*/
|
|
1386
|
+
async deletePermanently(userId, id) {
|
|
471
1387
|
const record = await this.get(userId, id);
|
|
472
1388
|
const engine = await this.engineFor(userId);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1389
|
+
await engine.remove({ messageId: String(record.telegramMessageId) });
|
|
1390
|
+
const versions = await this.files.listVersions(id);
|
|
1391
|
+
for (const version of versions) {
|
|
1392
|
+
if (version.telegramMessageId === record.telegramMessageId) continue;
|
|
1393
|
+
try {
|
|
1394
|
+
await engine.remove({ messageId: String(version.telegramMessageId) });
|
|
1395
|
+
} catch {
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
await this.files.deleteVersionsByFileIds([id]);
|
|
1399
|
+
await this.files.deleteMany([id]);
|
|
1400
|
+
await this.activity.record({
|
|
1401
|
+
userId,
|
|
1402
|
+
action: "delete",
|
|
1403
|
+
resourceType: "file",
|
|
1404
|
+
resourceId: id,
|
|
1405
|
+
resourceName: record.name
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* v0.1-compatible hard delete used by the current API: permanently removes
|
|
1410
|
+
* the file. Retained so existing clients keep working.
|
|
1411
|
+
*/
|
|
1412
|
+
async delete(userId, id) {
|
|
1413
|
+
await this.deletePermanently(userId, id);
|
|
1414
|
+
}
|
|
1415
|
+
// ── Download ─────────────────────────────────────────────────────────────
|
|
1416
|
+
async download(userId, id, control = {}) {
|
|
1417
|
+
const record = await this.getActive(userId, id);
|
|
1418
|
+
const engine = await this.engineFor(userId);
|
|
1419
|
+
const data = await engine.download({ messageId: String(record.telegramMessageId) }, control);
|
|
1420
|
+
const integrityVerified = await engine.verifyIntegrity(data, record.sha256);
|
|
1421
|
+
await this.activity.record({
|
|
1422
|
+
userId,
|
|
1423
|
+
action: "download",
|
|
1424
|
+
resourceType: "file",
|
|
1425
|
+
resourceId: id,
|
|
1426
|
+
resourceName: record.name,
|
|
1427
|
+
metadata: { size: record.size, integrityVerified }
|
|
1428
|
+
});
|
|
1429
|
+
return { record, data, integrityVerified };
|
|
1430
|
+
}
|
|
1431
|
+
/** Records an "open" event (the UI opening the details panel, say). */
|
|
1432
|
+
async touch(userId, id, action = "open") {
|
|
1433
|
+
const record = await this.get(userId, id);
|
|
1434
|
+
await this.activity.record({
|
|
1435
|
+
userId,
|
|
1436
|
+
action,
|
|
1437
|
+
resourceType: "file",
|
|
1438
|
+
resourceId: id,
|
|
1439
|
+
resourceName: record.name
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
// ── Batch operations ─────────────────────────────────────────────────────
|
|
1443
|
+
/**
|
|
1444
|
+
* Applies an operation to many files, collecting per-item failures instead
|
|
1445
|
+
* of aborting the whole batch.
|
|
1446
|
+
*/
|
|
1447
|
+
async batch(userId, ids, operation, options = {}) {
|
|
1448
|
+
const errors = [];
|
|
1449
|
+
let succeeded = 0;
|
|
1450
|
+
if (ids.length === 0) throw new ValidationError("No items selected");
|
|
1451
|
+
if (operation === "move") {
|
|
1452
|
+
const target = options.folderId ?? null;
|
|
1453
|
+
if (target !== null) await this.assertFolder(userId, target);
|
|
1454
|
+
}
|
|
1455
|
+
for (const id of ids) {
|
|
1456
|
+
try {
|
|
1457
|
+
switch (operation) {
|
|
1458
|
+
case "trash":
|
|
1459
|
+
await this.trash(userId, id);
|
|
1460
|
+
break;
|
|
1461
|
+
case "restore":
|
|
1462
|
+
await this.restore(userId, id);
|
|
1463
|
+
break;
|
|
1464
|
+
case "delete":
|
|
1465
|
+
await this.deletePermanently(userId, id);
|
|
1466
|
+
break;
|
|
1467
|
+
case "star":
|
|
1468
|
+
await this.setStarred(userId, id, true);
|
|
1469
|
+
break;
|
|
1470
|
+
case "unstar":
|
|
1471
|
+
await this.setStarred(userId, id, false);
|
|
1472
|
+
break;
|
|
1473
|
+
case "move":
|
|
1474
|
+
await this.move(userId, id, options.folderId ?? null);
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1477
|
+
succeeded += 1;
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
errors.push({ id, message: error instanceof Error ? error.message : "Operation failed" });
|
|
1480
|
+
}
|
|
476
1481
|
}
|
|
477
|
-
|
|
1482
|
+
return { requested: ids.length, succeeded, failed: errors.length, errors };
|
|
478
1483
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1484
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
1485
|
+
async assertFolder(userId, folderId) {
|
|
1486
|
+
const folder = await this.folders.findById(folderId);
|
|
1487
|
+
if (!folder || folder.userId !== userId) {
|
|
1488
|
+
throw new NotFoundError("Folder not found", "folder");
|
|
1489
|
+
}
|
|
1490
|
+
if (folder.deletedAt) throw new ConflictError("The target folder is in the Trash");
|
|
485
1491
|
}
|
|
486
1492
|
};
|
|
487
1493
|
|
|
488
1494
|
// ../core/src/services/folder-service.ts
|
|
489
1495
|
var FolderService = class {
|
|
490
|
-
constructor(folders, files, engineFor) {
|
|
1496
|
+
constructor(folders, files, engineFor, activity = noopActivityRecorder) {
|
|
491
1497
|
this.folders = folders;
|
|
492
1498
|
this.files = files;
|
|
493
1499
|
this.engineFor = engineFor;
|
|
1500
|
+
this.activity = activity;
|
|
494
1501
|
}
|
|
495
1502
|
folders;
|
|
496
1503
|
files;
|
|
497
1504
|
engineFor;
|
|
1505
|
+
activity;
|
|
1506
|
+
// ── Create / read ────────────────────────────────────────────────────────
|
|
498
1507
|
async create(userId, input) {
|
|
499
1508
|
const name = sanitizeFileName(input.name);
|
|
500
1509
|
if (!name) throw new ValidationError("Invalid folder name");
|
|
501
1510
|
if (input.parentId !== null) {
|
|
502
|
-
const parent = await this.
|
|
503
|
-
if (
|
|
1511
|
+
const parent = await this.get(userId, input.parentId);
|
|
1512
|
+
if (parent.deletedAt) throw new ConflictError("The parent folder is in the Trash");
|
|
504
1513
|
}
|
|
505
|
-
|
|
1514
|
+
const folder = await this.folders.create({ userId, parentId: input.parentId, name });
|
|
1515
|
+
await this.activity.record({
|
|
1516
|
+
userId,
|
|
1517
|
+
action: "create_folder",
|
|
1518
|
+
resourceType: "folder",
|
|
1519
|
+
resourceId: folder.id,
|
|
1520
|
+
resourceName: folder.name
|
|
1521
|
+
});
|
|
1522
|
+
return folder;
|
|
506
1523
|
}
|
|
1524
|
+
/** Active folders directly inside a folder (null = root). */
|
|
507
1525
|
async listChildren(userId, parentId) {
|
|
508
|
-
if (parentId !== null)
|
|
509
|
-
const parent = await this.folders.findById(parentId);
|
|
510
|
-
if (!parent || parent.userId !== userId) throw new NotFoundError("Folder not found");
|
|
511
|
-
}
|
|
1526
|
+
if (parentId !== null) await this.get(userId, parentId);
|
|
512
1527
|
return this.folders.listChildren(userId, parentId);
|
|
513
1528
|
}
|
|
1529
|
+
async query(userId, input, page) {
|
|
1530
|
+
const query = await resolveItemQuery(this.folders, userId, input);
|
|
1531
|
+
return this.folders.query(userId, query, page);
|
|
1532
|
+
}
|
|
1533
|
+
/** Every active folder for the user (used to build trees). */
|
|
514
1534
|
async tree(userId) {
|
|
515
|
-
|
|
1535
|
+
const all = await this.folders.listByUser(userId);
|
|
1536
|
+
return all.filter((folder) => folder.deletedAt === null);
|
|
516
1537
|
}
|
|
517
1538
|
async get(userId, id) {
|
|
518
1539
|
const record = await this.folders.findById(id);
|
|
519
|
-
if (!record || record.userId !== userId) throw new NotFoundError("Folder not found");
|
|
1540
|
+
if (!record || record.userId !== userId) throw new NotFoundError("Folder not found", "folder");
|
|
520
1541
|
return record;
|
|
521
1542
|
}
|
|
1543
|
+
// ── Mutations ────────────────────────────────────────────────────────────
|
|
522
1544
|
async rename(userId, id, name) {
|
|
523
|
-
await this.get(userId, id);
|
|
1545
|
+
const existing = await this.get(userId, id);
|
|
524
1546
|
const clean = sanitizeFileName(name);
|
|
525
1547
|
if (!clean) throw new ValidationError("Invalid folder name");
|
|
526
|
-
|
|
1548
|
+
const updated = await this.folders.update(id, { name: clean });
|
|
1549
|
+
await this.activity.record({
|
|
1550
|
+
userId,
|
|
1551
|
+
action: "rename",
|
|
1552
|
+
resourceType: "folder",
|
|
1553
|
+
resourceId: id,
|
|
1554
|
+
resourceName: clean,
|
|
1555
|
+
metadata: { previousName: existing.name }
|
|
1556
|
+
});
|
|
1557
|
+
return updated;
|
|
527
1558
|
}
|
|
528
1559
|
async move(userId, id, newParentId) {
|
|
529
|
-
await this.get(userId, id);
|
|
1560
|
+
const existing = await this.get(userId, id);
|
|
530
1561
|
if (newParentId === id) throw new ValidationError("Cannot move a folder into itself");
|
|
531
1562
|
if (newParentId !== null) {
|
|
1563
|
+
const target = await this.get(userId, newParentId);
|
|
1564
|
+
if (target.deletedAt) throw new ConflictError("The target folder is in the Trash");
|
|
532
1565
|
let cursor = newParentId;
|
|
533
1566
|
while (cursor !== null) {
|
|
534
1567
|
if (cursor === id) {
|
|
535
1568
|
throw new ValidationError("Cannot move a folder into one of its subfolders");
|
|
536
1569
|
}
|
|
537
1570
|
const parent = await this.folders.findById(cursor);
|
|
538
|
-
if (!parent || parent.userId !== userId)
|
|
1571
|
+
if (!parent || parent.userId !== userId)
|
|
1572
|
+
throw new NotFoundError("Folder not found", "folder");
|
|
539
1573
|
cursor = parent.parentId;
|
|
540
1574
|
}
|
|
541
1575
|
}
|
|
542
|
-
|
|
1576
|
+
const updated = await this.folders.update(id, { parentId: newParentId });
|
|
1577
|
+
await this.activity.record({
|
|
1578
|
+
userId,
|
|
1579
|
+
action: "move",
|
|
1580
|
+
resourceType: "folder",
|
|
1581
|
+
resourceId: id,
|
|
1582
|
+
resourceName: updated.name,
|
|
1583
|
+
metadata: { from: existing.parentId, to: newParentId }
|
|
1584
|
+
});
|
|
1585
|
+
return updated;
|
|
543
1586
|
}
|
|
544
|
-
async
|
|
1587
|
+
async setStarred(userId, id, starred) {
|
|
1588
|
+
await this.get(userId, id);
|
|
1589
|
+
const updated = await this.folders.update(id, { starred });
|
|
1590
|
+
await this.activity.record({
|
|
1591
|
+
userId,
|
|
1592
|
+
action: starred ? "star" : "unstar",
|
|
1593
|
+
resourceType: "folder",
|
|
1594
|
+
resourceId: id,
|
|
1595
|
+
resourceName: updated.name
|
|
1596
|
+
});
|
|
1597
|
+
return updated;
|
|
1598
|
+
}
|
|
1599
|
+
// ── Trash lifecycle ──────────────────────────────────────────────────────
|
|
1600
|
+
/**
|
|
1601
|
+
* Moves a folder and its entire subtree to the Trash. Nothing is deleted
|
|
1602
|
+
* from Telegram, so restore is lossless.
|
|
1603
|
+
*/
|
|
1604
|
+
async trash(userId, id) {
|
|
1605
|
+
const record = await this.get(userId, id);
|
|
1606
|
+
if (record.deletedAt) {
|
|
1607
|
+
return { affectedFolders: 1, affectedFiles: 0 };
|
|
1608
|
+
}
|
|
1609
|
+
const subtree = await this.collectSubtree(userId, id);
|
|
1610
|
+
const fileIds = await this.collectFileIds(userId, subtree);
|
|
1611
|
+
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1612
|
+
const stampedAt = /* @__PURE__ */ new Date();
|
|
1613
|
+
await this.folders.updateMany(
|
|
1614
|
+
subtree.map((folder) => folder.id),
|
|
1615
|
+
{ deletedAt: stampedAt, trashBatchId: batchId }
|
|
1616
|
+
);
|
|
1617
|
+
await this.files.updateMany(fileIds, { deletedAt: stampedAt, trashBatchId: batchId });
|
|
1618
|
+
await this.activity.record({
|
|
1619
|
+
userId,
|
|
1620
|
+
action: "trash",
|
|
1621
|
+
resourceType: "folder",
|
|
1622
|
+
resourceId: id,
|
|
1623
|
+
resourceName: record.name,
|
|
1624
|
+
metadata: { folders: subtree.length, files: fileIds.length }
|
|
1625
|
+
});
|
|
1626
|
+
return { affectedFolders: subtree.length, affectedFiles: fileIds.length };
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Restores a folder subtree. Only nodes trashed at the same instant as the
|
|
1630
|
+
* folder itself are restored, so items the user trashed separately stay put.
|
|
1631
|
+
*/
|
|
1632
|
+
async restore(userId, id) {
|
|
1633
|
+
const record = await this.get(userId, id);
|
|
1634
|
+
let parentId = record.parentId;
|
|
1635
|
+
if (parentId !== null) {
|
|
1636
|
+
const parent = await this.folders.findById(parentId);
|
|
1637
|
+
if (!parent || parent.deletedAt) parentId = null;
|
|
1638
|
+
}
|
|
1639
|
+
const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
|
|
1640
|
+
const batchId = record.trashBatchId;
|
|
1641
|
+
const belongsToBatch = (deletedAt, marker) => deletedAt !== null && (batchId !== null ? marker === batchId : record.deletedAt !== null && deletedAt.getTime() === record.deletedAt.getTime());
|
|
1642
|
+
const restorableFolders = subtree.filter(
|
|
1643
|
+
(folder) => belongsToBatch(folder.deletedAt, folder.trashBatchId)
|
|
1644
|
+
);
|
|
1645
|
+
const restorableFolderIds = new Set(restorableFolders.map((folder) => folder.id));
|
|
1646
|
+
const allFiles = await this.files.listByUser(userId);
|
|
1647
|
+
const restorableFiles = allFiles.filter(
|
|
1648
|
+
(file) => file.folderId !== null && restorableFolderIds.has(file.folderId) && belongsToBatch(file.deletedAt, file.trashBatchId)
|
|
1649
|
+
);
|
|
1650
|
+
const restorableFileIds = restorableFiles.map((file) => file.id);
|
|
1651
|
+
if (restorableFolders.length > 0) {
|
|
1652
|
+
await this.folders.updateMany(
|
|
1653
|
+
restorableFolders.map((folder) => folder.id),
|
|
1654
|
+
{ deletedAt: null, trashBatchId: null }
|
|
1655
|
+
);
|
|
1656
|
+
await this.folders.update(id, { parentId });
|
|
1657
|
+
}
|
|
1658
|
+
if (restorableFileIds.length > 0) {
|
|
1659
|
+
await this.files.updateMany(restorableFileIds, { deletedAt: null, trashBatchId: null });
|
|
1660
|
+
}
|
|
1661
|
+
await this.activity.record({
|
|
1662
|
+
userId,
|
|
1663
|
+
action: "restore",
|
|
1664
|
+
resourceType: "folder",
|
|
1665
|
+
resourceId: id,
|
|
1666
|
+
resourceName: record.name,
|
|
1667
|
+
metadata: { folders: restorableFolders.length, files: restorableFiles.length }
|
|
1668
|
+
});
|
|
1669
|
+
return { affectedFolders: restorableFolders.length, affectedFiles: restorableFiles.length };
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Permanently deletes a folder subtree: remote objects first, then
|
|
1673
|
+
* metadata. Files whose remote delete fails are left in the Trash so the
|
|
1674
|
+
* user can retry rather than losing data silently.
|
|
1675
|
+
*/
|
|
1676
|
+
async deletePermanently(userId, id) {
|
|
545
1677
|
await this.get(userId, id);
|
|
1678
|
+
const subtree = await this.collectSubtree(userId, id, { includeTrashed: true });
|
|
1679
|
+
const folderIds = subtree.map((folder) => folder.id);
|
|
1680
|
+
const allFiles = await this.files.listByUser(userId);
|
|
1681
|
+
const affected = allFiles.filter(
|
|
1682
|
+
(file) => file.folderId !== null && folderIds.includes(file.folderId)
|
|
1683
|
+
);
|
|
1684
|
+
const engine = await this.engineFor(userId);
|
|
1685
|
+
const removedFileIds = [];
|
|
1686
|
+
for (const file of affected) {
|
|
1687
|
+
try {
|
|
1688
|
+
await engine.remove({ messageId: String(file.telegramMessageId) });
|
|
1689
|
+
removedFileIds.push(file.id);
|
|
1690
|
+
} catch {
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (removedFileIds.length > 0) {
|
|
1694
|
+
await this.files.deleteVersionsByFileIds(removedFileIds);
|
|
1695
|
+
await this.files.deleteMany(removedFileIds);
|
|
1696
|
+
}
|
|
1697
|
+
const remainingFolderIds = new Set(
|
|
1698
|
+
affected.filter((file) => !removedFileIds.includes(file.id)).map((file) => file.folderId)
|
|
1699
|
+
);
|
|
1700
|
+
const deletable = folderIds.filter((folderId) => !remainingFolderIds.has(folderId));
|
|
1701
|
+
if (deletable.length > 0) {
|
|
1702
|
+
await this.folders.deleteMany(deletable);
|
|
1703
|
+
}
|
|
1704
|
+
await this.activity.record({
|
|
1705
|
+
userId,
|
|
1706
|
+
action: "delete",
|
|
1707
|
+
resourceType: "folder",
|
|
1708
|
+
resourceId: id,
|
|
1709
|
+
resourceName: subtree[0]?.name ?? "folder",
|
|
1710
|
+
metadata: { folders: deletable.length, files: removedFileIds.length }
|
|
1711
|
+
});
|
|
1712
|
+
return { affectedFolders: deletable.length, affectedFiles: removedFileIds.length };
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* v0.1-compatible recursive delete. Now routes through the v0.2 permanent
|
|
1716
|
+
* delete so remote cleanup and orphan prevention are consistent.
|
|
1717
|
+
*/
|
|
1718
|
+
async delete(userId, id) {
|
|
1719
|
+
return this.deletePermanently(userId, id);
|
|
1720
|
+
}
|
|
1721
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
1722
|
+
/** The folder plus every descendant (BFS), optionally including trashed. */
|
|
1723
|
+
async collectSubtree(userId, rootId, options = {}) {
|
|
546
1724
|
const all = await this.folders.listByUser(userId);
|
|
547
1725
|
const childrenOf = /* @__PURE__ */ new Map();
|
|
548
1726
|
for (const folder of all) {
|
|
549
1727
|
const list = childrenOf.get(folder.parentId) ?? [];
|
|
550
|
-
list.push(folder
|
|
1728
|
+
list.push(folder);
|
|
551
1729
|
childrenOf.set(folder.parentId, list);
|
|
552
1730
|
}
|
|
553
|
-
const
|
|
554
|
-
|
|
1731
|
+
const root = all.find((folder) => folder.id === rootId);
|
|
1732
|
+
if (!root) throw new NotFoundError("Folder not found", "folder");
|
|
1733
|
+
const collected = [root];
|
|
1734
|
+
const queue = [rootId];
|
|
555
1735
|
while (queue.length > 0) {
|
|
556
1736
|
const current = queue.shift();
|
|
557
1737
|
for (const child of childrenOf.get(current) ?? []) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
const doomedSet = new Set(doomed);
|
|
563
|
-
const userFiles = await this.files.listByUser(userId);
|
|
564
|
-
const affectedFiles = userFiles.filter((f) => f.folderId !== null && doomedSet.has(f.folderId));
|
|
565
|
-
const engine = await this.engineFor(userId);
|
|
566
|
-
for (const file of affectedFiles) {
|
|
567
|
-
try {
|
|
568
|
-
await engine.remove({ messageId: String(file.telegramMessageId) });
|
|
569
|
-
} catch {
|
|
1738
|
+
if (!options.includeTrashed && child.deletedAt) continue;
|
|
1739
|
+
collected.push(child);
|
|
1740
|
+
queue.push(child.id);
|
|
570
1741
|
}
|
|
571
1742
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1743
|
+
return collected;
|
|
1744
|
+
}
|
|
1745
|
+
/** Ids of files inside the given folders that are not already trashed. */
|
|
1746
|
+
async collectFileIds(userId, folders) {
|
|
1747
|
+
const ids = new Set(folders.map((folder) => folder.id));
|
|
1748
|
+
const allFiles = await this.files.listByUser(userId);
|
|
1749
|
+
return allFiles.filter((file) => file.deletedAt === null && file.folderId !== null && ids.has(file.folderId)).map((file) => file.id);
|
|
575
1750
|
}
|
|
576
1751
|
};
|
|
577
1752
|
|
|
578
1753
|
// ../core/src/services/search-service.ts
|
|
579
|
-
var MAX_QUERY_LENGTH =
|
|
1754
|
+
var MAX_QUERY_LENGTH = 200;
|
|
580
1755
|
var SearchService = class {
|
|
581
1756
|
constructor(files, folders) {
|
|
582
1757
|
this.files = files;
|
|
@@ -584,23 +1759,316 @@ var SearchService = class {
|
|
|
584
1759
|
}
|
|
585
1760
|
files;
|
|
586
1761
|
folders;
|
|
587
|
-
async search(userId,
|
|
588
|
-
const
|
|
589
|
-
if (!
|
|
590
|
-
if (
|
|
1762
|
+
async search(userId, rawQuery, page, overrides = {}) {
|
|
1763
|
+
const trimmed = rawQuery.trim();
|
|
1764
|
+
if (!trimmed) throw new ValidationError("Search query must not be empty");
|
|
1765
|
+
if (trimmed.length > MAX_QUERY_LENGTH) {
|
|
591
1766
|
throw new ValidationError(`Search query must be at most ${MAX_QUERY_LENGTH} characters`);
|
|
592
1767
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
1768
|
+
const { query } = parseSearchQuery(trimmed, overrides);
|
|
1769
|
+
if (query.status === void 0) query.status = "all";
|
|
1770
|
+
const itemQuery = await resolveItemQuery(this.folders, userId, query);
|
|
1771
|
+
const [filePage, folderPage] = await Promise.all([
|
|
1772
|
+
this.files.query(userId, itemQuery, page),
|
|
1773
|
+
this.folders.query(userId, itemQuery, page)
|
|
1774
|
+
]);
|
|
1775
|
+
const total = filePage.total + folderPage.total;
|
|
1776
|
+
return {
|
|
1777
|
+
files: filePage.items,
|
|
1778
|
+
folders: folderPage.items,
|
|
1779
|
+
pagination: paginationMeta(page.page, page.limit, total)
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
/** Convenience wrapper returning the shared DTO shape. */
|
|
1783
|
+
async searchDTO(userId, rawQuery, page) {
|
|
1784
|
+
return this.search(userId, rawQuery, page);
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
// ../core/src/services/stats-service.ts
|
|
1789
|
+
var LARGEST_FILES = 10;
|
|
1790
|
+
var StatsService = class {
|
|
1791
|
+
constructor(files, folders) {
|
|
1792
|
+
this.files = files;
|
|
1793
|
+
this.folders = folders;
|
|
1794
|
+
}
|
|
1795
|
+
files;
|
|
1796
|
+
folders;
|
|
1797
|
+
async stats(userId, quotaBytes = null) {
|
|
1798
|
+
const [counters, allFiles] = await Promise.all([
|
|
1799
|
+
this.files.statsByUser(userId),
|
|
1800
|
+
// Metadata-only aggregation; v0.2 datasets fit comfortably in memory.
|
|
1801
|
+
this.files.listByUser(userId)
|
|
1802
|
+
]);
|
|
1803
|
+
const active = allFiles.filter((file) => file.deletedAt === null);
|
|
1804
|
+
const categoryBytes = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const file of active) {
|
|
1806
|
+
const category = fileCategory(file.mimeType);
|
|
1807
|
+
const bucket = categoryBytes.get(category) ?? { bytes: 0, count: 0 };
|
|
1808
|
+
bucket.bytes += file.size;
|
|
1809
|
+
bucket.count += 1;
|
|
1810
|
+
categoryBytes.set(category, bucket);
|
|
1811
|
+
}
|
|
1812
|
+
const byType = [...categoryBytes.entries()].map(([category, bucket]) => ({ category, bytes: bucket.bytes, count: bucket.count })).sort((a, b) => b.bytes - a.bytes);
|
|
1813
|
+
const largestFiles = [...active].sort((a, b) => b.size - a.size).slice(0, LARGEST_FILES);
|
|
1814
|
+
return {
|
|
1815
|
+
fileCount: counters.fileCount,
|
|
1816
|
+
folderCount: counters.folderCount,
|
|
1817
|
+
totalBytes: counters.totalBytes,
|
|
1818
|
+
trashBytes: counters.trashBytes,
|
|
1819
|
+
trashFileCount: counters.trashFileCount,
|
|
1820
|
+
starredCount: counters.starredCount,
|
|
1821
|
+
byType,
|
|
1822
|
+
largestFiles,
|
|
1823
|
+
quotaBytes
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// ../core/src/services/trash-service.ts
|
|
1829
|
+
var TrashService = class {
|
|
1830
|
+
constructor(files, folders, engineFor) {
|
|
1831
|
+
this.files = files;
|
|
1832
|
+
this.folders = folders;
|
|
1833
|
+
this.engineFor = engineFor;
|
|
1834
|
+
}
|
|
1835
|
+
files;
|
|
1836
|
+
folders;
|
|
1837
|
+
engineFor;
|
|
1838
|
+
async list(userId, page, extra = {}) {
|
|
1839
|
+
const itemQuery = await resolveItemQuery(this.folders, userId, {
|
|
1840
|
+
...extra,
|
|
1841
|
+
status: "trashed",
|
|
1842
|
+
folderId: void 0
|
|
1843
|
+
});
|
|
1844
|
+
const [filePage, folderPage] = await Promise.all([
|
|
1845
|
+
this.files.query(userId, itemQuery, page),
|
|
1846
|
+
this.folders.query(userId, itemQuery, page)
|
|
596
1847
|
]);
|
|
597
|
-
|
|
1848
|
+
const total = filePage.total + folderPage.total;
|
|
1849
|
+
return {
|
|
1850
|
+
files: filePage.items,
|
|
1851
|
+
folders: folderPage.items,
|
|
1852
|
+
pagination: paginationMeta(page.page, page.limit, total)
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Permanently removes everything in the Trash for a user. Folders are
|
|
1857
|
+
* processed root-first so removing a parent also removes its contents.
|
|
1858
|
+
*/
|
|
1859
|
+
async empty(userId) {
|
|
1860
|
+
const engine = await this.engineFor(userId);
|
|
1861
|
+
const allFiles = await this.files.listByUser(userId);
|
|
1862
|
+
const trashedFiles = allFiles.filter((file) => file.deletedAt !== null);
|
|
1863
|
+
let deletedFiles = 0;
|
|
1864
|
+
let failedFiles = 0;
|
|
1865
|
+
const deletedFileIds = [];
|
|
1866
|
+
for (const file of trashedFiles) {
|
|
1867
|
+
try {
|
|
1868
|
+
await engine.remove({ messageId: String(file.telegramMessageId) });
|
|
1869
|
+
deletedFileIds.push(file.id);
|
|
1870
|
+
deletedFiles += 1;
|
|
1871
|
+
} catch {
|
|
1872
|
+
failedFiles += 1;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (deletedFileIds.length > 0) {
|
|
1876
|
+
await this.files.deleteVersionsByFileIds(deletedFileIds);
|
|
1877
|
+
await this.files.deleteMany(deletedFileIds);
|
|
1878
|
+
}
|
|
1879
|
+
const allFolders = await this.folders.listByUser(userId);
|
|
1880
|
+
const trashedFolders = allFolders.filter((folder) => folder.deletedAt !== null);
|
|
1881
|
+
const depth = (folder) => {
|
|
1882
|
+
let level = 0;
|
|
1883
|
+
let cursor = folder.parentId;
|
|
1884
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1885
|
+
while (cursor !== null && !seen.has(cursor)) {
|
|
1886
|
+
seen.add(cursor);
|
|
1887
|
+
const parent = allFolders.find((candidate) => candidate.id === cursor);
|
|
1888
|
+
if (!parent) break;
|
|
1889
|
+
level += 1;
|
|
1890
|
+
cursor = parent.parentId;
|
|
1891
|
+
}
|
|
1892
|
+
return level;
|
|
1893
|
+
};
|
|
1894
|
+
trashedFolders.sort((a, b) => depth(a) - depth(b));
|
|
1895
|
+
let deletedFolders = 0;
|
|
1896
|
+
for (const folder of trashedFolders) {
|
|
1897
|
+
const stillHasFiles = await this.files.query(
|
|
1898
|
+
userId,
|
|
1899
|
+
{ folderId: folder.id, trashed: true },
|
|
1900
|
+
{ page: 1, limit: 1 }
|
|
1901
|
+
);
|
|
1902
|
+
if (stillHasFiles.total > 0) continue;
|
|
1903
|
+
await this.folders.deleteMany([folder.id]);
|
|
1904
|
+
deletedFolders += 1;
|
|
1905
|
+
}
|
|
1906
|
+
return { deletedFiles, deletedFolders, failedFiles };
|
|
1907
|
+
}
|
|
1908
|
+
/** Restores a single trashed item (used by the batch restore endpoint). */
|
|
1909
|
+
async verifyTrashedFile(userId, id) {
|
|
1910
|
+
const file = await this.files.findById(id);
|
|
1911
|
+
if (!file || file.userId !== userId || file.deletedAt === null) {
|
|
1912
|
+
throw new NotFoundError("File is not in the Trash", "file");
|
|
1913
|
+
}
|
|
1914
|
+
return file;
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
// ../core/src/services/integrity-service.ts
|
|
1919
|
+
var IntegrityService = class {
|
|
1920
|
+
constructor(files, engineFor) {
|
|
1921
|
+
this.files = files;
|
|
1922
|
+
this.engineFor = engineFor;
|
|
1923
|
+
}
|
|
1924
|
+
files;
|
|
1925
|
+
engineFor;
|
|
1926
|
+
async check(userId, options = {}) {
|
|
1927
|
+
const startedAt = Date.now();
|
|
1928
|
+
const all = await this.files.listByUser(userId);
|
|
1929
|
+
const active = all.filter((file) => file.deletedAt === null);
|
|
1930
|
+
const targets = options.limit ? active.slice(0, options.limit) : active;
|
|
1931
|
+
const engine = await this.engineFor(userId);
|
|
1932
|
+
const issues = [];
|
|
1933
|
+
let healthy = 0;
|
|
1934
|
+
for (const file of targets) {
|
|
1935
|
+
const issue = await this.inspect(file, options.deep ?? false, engine);
|
|
1936
|
+
if (issue) issues.push(issue);
|
|
1937
|
+
else healthy += 1;
|
|
1938
|
+
}
|
|
1939
|
+
const missing = issues.filter((issue) => issue.kind === "missing").length;
|
|
1940
|
+
const inconsistent = issues.filter(
|
|
1941
|
+
(issue) => issue.kind === "size_mismatch" || issue.kind === "hash_mismatch"
|
|
1942
|
+
).length;
|
|
1943
|
+
const unreadable = issues.filter((issue) => issue.kind === "unreadable").length;
|
|
1944
|
+
return {
|
|
1945
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1946
|
+
filesChecked: targets.length,
|
|
1947
|
+
healthy,
|
|
1948
|
+
missing,
|
|
1949
|
+
inconsistent,
|
|
1950
|
+
unreadable,
|
|
1951
|
+
issues,
|
|
1952
|
+
durationMs: Date.now() - startedAt
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
async inspect(file, deep, engine) {
|
|
1956
|
+
const ref = { messageId: String(file.telegramMessageId) };
|
|
1957
|
+
let stat;
|
|
1958
|
+
try {
|
|
1959
|
+
stat = await engine.stat(ref);
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
return {
|
|
1962
|
+
fileId: file.id,
|
|
1963
|
+
name: file.name,
|
|
1964
|
+
kind: "unreadable",
|
|
1965
|
+
detail: error instanceof Error ? error.message : "Storage lookup failed"
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
if (!stat) {
|
|
1969
|
+
return {
|
|
1970
|
+
fileId: file.id,
|
|
1971
|
+
name: file.name,
|
|
1972
|
+
kind: "missing",
|
|
1973
|
+
detail: "No message with this id exists in the storage channel"
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
if (stat.size !== file.size) {
|
|
1977
|
+
return {
|
|
1978
|
+
fileId: file.id,
|
|
1979
|
+
name: file.name,
|
|
1980
|
+
kind: "size_mismatch",
|
|
1981
|
+
detail: `metadata=${file.size} storage=${stat.size}`
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
if (deep) {
|
|
1985
|
+
try {
|
|
1986
|
+
const data = await engine.download(ref);
|
|
1987
|
+
const ok = await engine.verifyIntegrity(data, file.sha256);
|
|
1988
|
+
if (!ok) {
|
|
1989
|
+
return {
|
|
1990
|
+
fileId: file.id,
|
|
1991
|
+
name: file.name,
|
|
1992
|
+
kind: "hash_mismatch",
|
|
1993
|
+
detail: "Downloaded bytes do not match the stored SHA-256"
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
return {
|
|
1998
|
+
fileId: file.id,
|
|
1999
|
+
name: file.name,
|
|
2000
|
+
kind: "unreadable",
|
|
2001
|
+
detail: error instanceof Error ? error.message : "Download failed"
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
return null;
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
// ../core/src/services/recent-service.ts
|
|
2010
|
+
var RECENT_ACTIONS = [
|
|
2011
|
+
"upload",
|
|
2012
|
+
"download",
|
|
2013
|
+
"open",
|
|
2014
|
+
"rename",
|
|
2015
|
+
"move",
|
|
2016
|
+
"replace"
|
|
2017
|
+
];
|
|
2018
|
+
var RecentService = class {
|
|
2019
|
+
constructor(activity, files) {
|
|
2020
|
+
this.activity = activity;
|
|
2021
|
+
this.files = files;
|
|
2022
|
+
}
|
|
2023
|
+
activity;
|
|
2024
|
+
files;
|
|
2025
|
+
async list(userId, page) {
|
|
2026
|
+
const window = Math.min(page.page * page.limit * 3, 500);
|
|
2027
|
+
const events = await this.activity.recentFiles(userId, RECENT_ACTIONS, window);
|
|
2028
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2029
|
+
for (const event of events) {
|
|
2030
|
+
seen.add(event.resourceId);
|
|
2031
|
+
}
|
|
2032
|
+
const fileIds = [...seen];
|
|
2033
|
+
const records = fileIds.length > 0 ? await this.files.listByIds(userId, fileIds) : [];
|
|
2034
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
2035
|
+
const items = [];
|
|
2036
|
+
for (const fileId of fileIds) {
|
|
2037
|
+
const record = byId.get(fileId);
|
|
2038
|
+
if (!record || record.deletedAt !== null) continue;
|
|
2039
|
+
const event = events.find((candidate) => candidate.resourceId === fileId);
|
|
2040
|
+
items.push({
|
|
2041
|
+
file: {
|
|
2042
|
+
id: record.id,
|
|
2043
|
+
name: record.name,
|
|
2044
|
+
size: record.size,
|
|
2045
|
+
mimeType: record.mimeType,
|
|
2046
|
+
sha256: record.sha256,
|
|
2047
|
+
folderId: record.folderId,
|
|
2048
|
+
starred: record.starred,
|
|
2049
|
+
trashed: false,
|
|
2050
|
+
deletedAt: null,
|
|
2051
|
+
versionCount: record.versionCount,
|
|
2052
|
+
currentVersionId: record.currentVersionId,
|
|
2053
|
+
createdAt: record.createdAt.toISOString(),
|
|
2054
|
+
updatedAt: record.updatedAt.toISOString()
|
|
2055
|
+
},
|
|
2056
|
+
lastAction: event.action,
|
|
2057
|
+
lastActionAt: event.createdAt.toISOString()
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
const start = (page.page - 1) * page.limit;
|
|
2061
|
+
const pageItems = items.slice(start, start + page.limit);
|
|
2062
|
+
return {
|
|
2063
|
+
items: pageItems,
|
|
2064
|
+
pagination: paginationMeta(page.page, page.limit, items.length)
|
|
2065
|
+
};
|
|
598
2066
|
}
|
|
599
2067
|
};
|
|
600
2068
|
|
|
601
2069
|
// ../telegram/src/client.ts
|
|
602
2070
|
import { Api, TelegramClient } from "telegram";
|
|
603
|
-
import { StringSession } from "telegram/sessions";
|
|
2071
|
+
import { StringSession } from "telegram/sessions/index.js";
|
|
604
2072
|
|
|
605
2073
|
// ../telegram/src/errors.ts
|
|
606
2074
|
import { RPCError } from "telegram/errors/index.js";
|
|
@@ -636,26 +2104,44 @@ function mapTelegramError(error, fallbackMessage) {
|
|
|
636
2104
|
case "PHONE_NUMBER_BANNED":
|
|
637
2105
|
return new ForbiddenError("This phone number is banned from Telegram");
|
|
638
2106
|
case "AUTH_KEY_UNREGISTERED":
|
|
2107
|
+
case "AUTH_KEY_DUPLICATED":
|
|
639
2108
|
case "SESSION_REVOKED":
|
|
2109
|
+
case "SESSION_EXPIRED":
|
|
640
2110
|
case "USER_DEACTIVATED":
|
|
641
|
-
|
|
2111
|
+
case "USER_DEACTIVATED_BAN":
|
|
2112
|
+
return new TelegramAuthRequiredError();
|
|
642
2113
|
case "CHANNEL_PRIVATE":
|
|
2114
|
+
case "CHANNEL_INVALID":
|
|
643
2115
|
case "CHAT_ID_INVALID":
|
|
644
2116
|
return new StorageNotInitializedError();
|
|
2117
|
+
case "MSG_ID_INVALID":
|
|
2118
|
+
case "MESSAGE_ID_INVALID":
|
|
2119
|
+
return new TelegramFileNotFoundError();
|
|
2120
|
+
case "FILE_REFERENCE_EXPIRED":
|
|
2121
|
+
case "FILE_REFERENCE_INVALID":
|
|
2122
|
+
return new TelegramFileNotFoundError(
|
|
2123
|
+
"The Telegram file reference expired \u2014 re-upload the file to refresh it"
|
|
2124
|
+
);
|
|
645
2125
|
default:
|
|
646
|
-
return new
|
|
2126
|
+
return new TelegramConnectionError(fallbackMessage, {
|
|
647
2127
|
code: error.code,
|
|
648
2128
|
telegramError: msg
|
|
649
2129
|
});
|
|
650
2130
|
}
|
|
651
2131
|
}
|
|
652
|
-
|
|
653
|
-
|
|
2132
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2133
|
+
if (/timeout|ECONNRESET|ENOTFOUND|socket|network|connect/i.test(detail)) {
|
|
2134
|
+
return new TelegramConnectionError(`${fallbackMessage} (network error)`, error);
|
|
2135
|
+
}
|
|
2136
|
+
return new TelegramConnectionError(
|
|
2137
|
+
detail ? `${fallbackMessage}: ${detail}` : fallbackMessage,
|
|
654
2138
|
error
|
|
655
2139
|
);
|
|
656
2140
|
}
|
|
657
2141
|
|
|
658
2142
|
// ../telegram/src/client.ts
|
|
2143
|
+
var HEALTH_TTL_MS = 3e4;
|
|
2144
|
+
var RECONNECT_COOLDOWN_MS = 5e3;
|
|
659
2145
|
var TelegramClientManager = class {
|
|
660
2146
|
constructor(credentials, sessions) {
|
|
661
2147
|
this.credentials = credentials;
|
|
@@ -663,52 +2149,208 @@ var TelegramClientManager = class {
|
|
|
663
2149
|
}
|
|
664
2150
|
credentials;
|
|
665
2151
|
sessions;
|
|
666
|
-
|
|
2152
|
+
connections = /* @__PURE__ */ new Map();
|
|
2153
|
+
lastReconnectAt = /* @__PURE__ */ new Map();
|
|
2154
|
+
/** Returns a connected client, (re)connecting when needed. */
|
|
667
2155
|
async getForUser(userId) {
|
|
668
|
-
const
|
|
669
|
-
if (
|
|
2156
|
+
const existing = this.connections.get(userId);
|
|
2157
|
+
if (existing) {
|
|
2158
|
+
if (existing.state === "CONNECTED" && this.isConnected(existing.client)) {
|
|
2159
|
+
return existing.client;
|
|
2160
|
+
}
|
|
2161
|
+
if (existing.pending) return existing.pending;
|
|
2162
|
+
if (existing.state === "AUTH_REQUIRED") {
|
|
2163
|
+
throw new TelegramAuthRequiredError();
|
|
2164
|
+
}
|
|
2165
|
+
return this.reconnect(userId, existing);
|
|
2166
|
+
}
|
|
2167
|
+
return this.connectUser(userId);
|
|
2168
|
+
}
|
|
2169
|
+
/** Connection status for health reporting and the storage dashboard. */
|
|
2170
|
+
statusForUser(userId) {
|
|
2171
|
+
const connection = this.connections.get(userId);
|
|
2172
|
+
if (!connection) {
|
|
2173
|
+
return { state: "DISCONNECTED", lastError: null, lastCheckAt: null, healthy: null };
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
state: connection.state,
|
|
2177
|
+
lastError: connection.lastError,
|
|
2178
|
+
lastCheckAt: connection.lastCheckAt ? new Date(connection.lastCheckAt) : null,
|
|
2179
|
+
healthy: connection.lastCheckOk
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Probes a user's connection. Cheap by default (connection state only);
|
|
2184
|
+
* `deep` performs a real Telegram round-trip to validate the session.
|
|
2185
|
+
*/
|
|
2186
|
+
async healthCheck(userId, deep = false) {
|
|
2187
|
+
const connection = this.connections.get(userId);
|
|
2188
|
+
if (!connection) {
|
|
670
2189
|
try {
|
|
671
|
-
|
|
672
|
-
} catch {
|
|
673
|
-
|
|
2190
|
+
await this.getForUser(userId);
|
|
2191
|
+
} catch (error) {
|
|
2192
|
+
const state = error instanceof TelegramAuthRequiredError ? "AUTH_REQUIRED" : "ERROR";
|
|
2193
|
+
const current = this.connections.get(userId);
|
|
2194
|
+
if (current) current.state = state;
|
|
2195
|
+
return {
|
|
2196
|
+
healthy: false,
|
|
2197
|
+
latencyMs: null,
|
|
2198
|
+
message: error instanceof Error ? error.message : "Telegram is not connected",
|
|
2199
|
+
targetTitle: null
|
|
2200
|
+
};
|
|
674
2201
|
}
|
|
675
2202
|
}
|
|
676
|
-
const
|
|
677
|
-
if (!
|
|
678
|
-
|
|
2203
|
+
const active = this.connections.get(userId);
|
|
2204
|
+
if (!deep && active.lastCheckAt && Date.now() - active.lastCheckAt < HEALTH_TTL_MS) {
|
|
2205
|
+
return {
|
|
2206
|
+
healthy: active.lastCheckOk ?? false,
|
|
2207
|
+
latencyMs: null,
|
|
2208
|
+
message: active.lastError,
|
|
2209
|
+
targetTitle: null
|
|
2210
|
+
};
|
|
679
2211
|
}
|
|
680
|
-
const
|
|
681
|
-
this.clients.set(userId, promise);
|
|
2212
|
+
const startedAt = Date.now();
|
|
682
2213
|
try {
|
|
683
|
-
|
|
2214
|
+
await active.client.getMe();
|
|
2215
|
+
active.lastCheckAt = Date.now();
|
|
2216
|
+
active.lastCheckOk = true;
|
|
2217
|
+
active.lastError = null;
|
|
2218
|
+
active.state = "CONNECTED";
|
|
2219
|
+
return {
|
|
2220
|
+
healthy: true,
|
|
2221
|
+
latencyMs: Date.now() - startedAt,
|
|
2222
|
+
message: null,
|
|
2223
|
+
targetTitle: null
|
|
2224
|
+
};
|
|
684
2225
|
} catch (error) {
|
|
685
|
-
|
|
686
|
-
|
|
2226
|
+
const mapped = mapTelegramError(error, "Telegram health check failed");
|
|
2227
|
+
active.lastCheckAt = Date.now();
|
|
2228
|
+
active.lastCheckOk = false;
|
|
2229
|
+
active.lastError = mapped.message;
|
|
2230
|
+
active.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
|
|
2231
|
+
return {
|
|
2232
|
+
healthy: false,
|
|
2233
|
+
latencyMs: Date.now() - startedAt,
|
|
2234
|
+
message: mapped.message,
|
|
2235
|
+
targetTitle: null
|
|
2236
|
+
};
|
|
687
2237
|
}
|
|
688
2238
|
}
|
|
689
|
-
/** Registers an already-authenticated client (
|
|
2239
|
+
/** Registers an already-authenticated client (from the login flow). */
|
|
690
2240
|
cacheClient(userId, client) {
|
|
691
|
-
this.
|
|
2241
|
+
this.connections.set(userId, {
|
|
2242
|
+
client,
|
|
2243
|
+
state: "CONNECTED",
|
|
2244
|
+
pending: null,
|
|
2245
|
+
lastError: null,
|
|
2246
|
+
lastCheckAt: Date.now(),
|
|
2247
|
+
lastCheckOk: true
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
/** Drops a user's connection (e.g. after logout) without touching storage. */
|
|
2251
|
+
async disconnectUser(userId) {
|
|
2252
|
+
const connection = this.connections.get(userId);
|
|
2253
|
+
if (!connection) return;
|
|
2254
|
+
this.connections.delete(userId);
|
|
2255
|
+
try {
|
|
2256
|
+
await connection.client.disconnect();
|
|
2257
|
+
} catch {
|
|
2258
|
+
}
|
|
692
2259
|
}
|
|
693
2260
|
async disconnectAll() {
|
|
694
|
-
|
|
695
|
-
|
|
2261
|
+
const ids = [...this.connections.keys()];
|
|
2262
|
+
await Promise.all(ids.map((userId) => this.disconnectUser(userId)));
|
|
2263
|
+
}
|
|
2264
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
2265
|
+
isConnected(client) {
|
|
2266
|
+
try {
|
|
2267
|
+
return client.connected === true;
|
|
2268
|
+
} catch {
|
|
2269
|
+
return false;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
async connectUser(userId) {
|
|
2273
|
+
const session = await this.sessions.get(userId);
|
|
2274
|
+
if (!session) {
|
|
2275
|
+
throw new TelegramAuthRequiredError("Telegram account is not connected");
|
|
2276
|
+
}
|
|
2277
|
+
const connection = {
|
|
2278
|
+
client: null,
|
|
2279
|
+
state: "CONNECTING",
|
|
2280
|
+
pending: null,
|
|
2281
|
+
lastError: null,
|
|
2282
|
+
lastCheckAt: null,
|
|
2283
|
+
lastCheckOk: null
|
|
2284
|
+
};
|
|
2285
|
+
const attempt = this.establish(session.stringSession).then((client) => {
|
|
2286
|
+
connection.client = client;
|
|
2287
|
+
connection.state = "CONNECTED";
|
|
2288
|
+
connection.pending = null;
|
|
2289
|
+
connection.lastError = null;
|
|
2290
|
+
return client;
|
|
2291
|
+
});
|
|
2292
|
+
connection.pending = attempt;
|
|
2293
|
+
this.connections.set(userId, connection);
|
|
2294
|
+
try {
|
|
2295
|
+
return await attempt;
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
connection.pending = null;
|
|
2298
|
+
const mapped = mapTelegramError(error, "Could not connect to Telegram");
|
|
2299
|
+
connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
|
|
2300
|
+
connection.lastError = mapped.message;
|
|
2301
|
+
throw mapped;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
async reconnect(userId, connection) {
|
|
2305
|
+
const lastAttempt = this.lastReconnectAt.get(userId) ?? 0;
|
|
2306
|
+
if (Date.now() - lastAttempt < RECONNECT_COOLDOWN_MS && connection.pending) {
|
|
2307
|
+
return connection.pending;
|
|
2308
|
+
}
|
|
2309
|
+
this.lastReconnectAt.set(userId, Date.now());
|
|
2310
|
+
connection.state = "RECONNECTING";
|
|
2311
|
+
const session = await this.sessions.get(userId);
|
|
2312
|
+
if (!session) {
|
|
2313
|
+
connection.state = "AUTH_REQUIRED";
|
|
2314
|
+
throw new TelegramAuthRequiredError("Telegram account is not connected");
|
|
2315
|
+
}
|
|
2316
|
+
const attempt = (async () => {
|
|
696
2317
|
try {
|
|
697
|
-
|
|
698
|
-
await client.disconnect();
|
|
2318
|
+
await connection.client.disconnect().catch(() => void 0);
|
|
699
2319
|
} catch {
|
|
700
2320
|
}
|
|
701
|
-
|
|
2321
|
+
return this.establish(session.stringSession);
|
|
2322
|
+
})().then((client) => {
|
|
2323
|
+
connection.client = client;
|
|
2324
|
+
connection.state = "CONNECTED";
|
|
2325
|
+
connection.pending = null;
|
|
2326
|
+
connection.lastError = null;
|
|
2327
|
+
return client;
|
|
2328
|
+
}).catch((error) => {
|
|
2329
|
+
connection.pending = null;
|
|
2330
|
+
const mapped = mapTelegramError(error, "Could not reconnect to Telegram");
|
|
2331
|
+
connection.state = mapped instanceof TelegramAuthRequiredError || mapped instanceof UnauthorizedError ? "AUTH_REQUIRED" : "ERROR";
|
|
2332
|
+
connection.lastError = mapped.message;
|
|
2333
|
+
throw mapped;
|
|
2334
|
+
});
|
|
2335
|
+
connection.pending = attempt;
|
|
2336
|
+
return attempt;
|
|
702
2337
|
}
|
|
703
|
-
async
|
|
2338
|
+
async establish(sessionString) {
|
|
704
2339
|
const client = new TelegramClient(
|
|
705
2340
|
new StringSession(sessionString),
|
|
706
2341
|
this.credentials.apiId,
|
|
707
2342
|
this.credentials.apiHash,
|
|
708
2343
|
{ connectionRetries: 3 }
|
|
709
2344
|
);
|
|
710
|
-
|
|
711
|
-
|
|
2345
|
+
try {
|
|
2346
|
+
await client.connect();
|
|
2347
|
+
if (!client.connected) {
|
|
2348
|
+
throw new TelegramConnectionError("Telegram client did not connect");
|
|
2349
|
+
}
|
|
2350
|
+
return client;
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
throw mapTelegramError(error, "Could not connect to Telegram");
|
|
2353
|
+
}
|
|
712
2354
|
}
|
|
713
2355
|
};
|
|
714
2356
|
function newTelegramClient(credentials) {
|
|
@@ -721,13 +2363,15 @@ function extractCreatedChannel(result) {
|
|
|
721
2363
|
for (const chat of chats) {
|
|
722
2364
|
if (chat instanceof Api.Channel) return chat;
|
|
723
2365
|
}
|
|
724
|
-
throw new
|
|
2366
|
+
throw new TelegramConnectionError("Telegram did not return the created storage channel");
|
|
725
2367
|
}
|
|
726
2368
|
|
|
727
2369
|
// ../telegram/src/provider.ts
|
|
2370
|
+
import { Readable as Readable2 } from "stream";
|
|
728
2371
|
import { Api as Api2 } from "telegram";
|
|
729
2372
|
import bigInt from "big-integer";
|
|
730
|
-
import { CustomFile } from "telegram/client/uploads";
|
|
2373
|
+
import { CustomFile } from "telegram/client/uploads.js";
|
|
2374
|
+
var MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 * 1024;
|
|
731
2375
|
var TelegramStorageProvider = class {
|
|
732
2376
|
constructor(client, options) {
|
|
733
2377
|
this.client = client;
|
|
@@ -735,48 +2379,100 @@ var TelegramStorageProvider = class {
|
|
|
735
2379
|
channelId: bigInt(options.chatId),
|
|
736
2380
|
accessHash: bigInt(options.accessHash)
|
|
737
2381
|
});
|
|
2382
|
+
this.title = options.title ?? null;
|
|
738
2383
|
}
|
|
739
2384
|
client;
|
|
740
2385
|
name = "telegram";
|
|
741
2386
|
peer;
|
|
742
|
-
|
|
2387
|
+
title;
|
|
2388
|
+
// ── Upload ───────────────────────────────────────────────────────────────
|
|
2389
|
+
async put(input, control = {}) {
|
|
2390
|
+
const data = await this.materialize(input);
|
|
2391
|
+
if (data.byteLength > MAX_DOCUMENT_BYTES) {
|
|
2392
|
+
throw new UploadFailedError(
|
|
2393
|
+
`Telegram does not accept documents larger than ${MAX_DOCUMENT_BYTES / (1024 * 1024 * 1024)} GB`
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
this.throwIfAborted(control.signal);
|
|
2397
|
+
const total = data.byteLength;
|
|
2398
|
+
control.onProgress?.({ transferred: 0, total, percent: 0 });
|
|
743
2399
|
try {
|
|
744
|
-
const file = new CustomFile(input.name,
|
|
2400
|
+
const file = new CustomFile(input.name, total, "", data);
|
|
745
2401
|
const message = await this.client.sendFile(this.peer, {
|
|
746
2402
|
file,
|
|
747
2403
|
forceDocument: true,
|
|
748
|
-
workers: 1
|
|
2404
|
+
workers: 1,
|
|
2405
|
+
// Surface transport-level progress to the caller. GramJS reports a
|
|
2406
|
+
// 0..1 fraction. `isCanceled` lets us abort an in-flight upload.
|
|
2407
|
+
progressCallback: Object.assign(
|
|
2408
|
+
(fraction) => {
|
|
2409
|
+
const clamped = Math.max(0, Math.min(1, fraction));
|
|
2410
|
+
control.onProgress?.({
|
|
2411
|
+
transferred: Math.round(clamped * total),
|
|
2412
|
+
total,
|
|
2413
|
+
percent: Math.round(clamped * 100)
|
|
2414
|
+
});
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
get isCanceled() {
|
|
2418
|
+
return control.signal?.aborted === true;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
)
|
|
749
2422
|
});
|
|
750
2423
|
if (!(message instanceof Api2.Message)) {
|
|
751
|
-
throw new
|
|
2424
|
+
throw new UploadFailedError("Telegram returned an unexpected upload response");
|
|
752
2425
|
}
|
|
2426
|
+
control.onProgress?.({ transferred: total, total, percent: 100 });
|
|
753
2427
|
return {
|
|
754
2428
|
messageId: message.id.toString(),
|
|
755
2429
|
name: input.name,
|
|
756
|
-
size:
|
|
2430
|
+
size: total,
|
|
757
2431
|
mimeType: input.mimeType
|
|
758
2432
|
};
|
|
759
2433
|
} catch (error) {
|
|
2434
|
+
if (error instanceof OperationCancelledError) throw error;
|
|
760
2435
|
throw mapTelegramError(error, "Telegram upload failed");
|
|
761
2436
|
}
|
|
762
2437
|
}
|
|
763
|
-
|
|
2438
|
+
// ── Download ─────────────────────────────────────────────────────────────
|
|
2439
|
+
async get(ref, control = {}) {
|
|
2440
|
+
const message = await this.requireMessage(ref);
|
|
2441
|
+
this.throwIfAborted(control.signal);
|
|
2442
|
+
const size = this.documentSize(message);
|
|
2443
|
+
control.onProgress?.({ transferred: 0, total: size, percent: 0 });
|
|
764
2444
|
let data;
|
|
765
2445
|
try {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
2446
|
+
data = await this.client.downloadMedia(message, {
|
|
2447
|
+
progressCallback: (downloaded, total) => {
|
|
2448
|
+
const transferred = Number(downloaded.toString());
|
|
2449
|
+
const totalBytes = Number(total.toString());
|
|
2450
|
+
control.onProgress?.({
|
|
2451
|
+
transferred,
|
|
2452
|
+
total: totalBytes,
|
|
2453
|
+
percent: totalBytes > 0 ? Math.round(transferred / totalBytes * 100) : null
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
771
2457
|
} catch (error) {
|
|
772
|
-
if (error instanceof StorageProviderError) throw error;
|
|
773
2458
|
throw mapTelegramError(error, "Telegram download failed");
|
|
774
2459
|
}
|
|
775
2460
|
if (!data || typeof data === "string") {
|
|
776
|
-
throw new
|
|
2461
|
+
throw new TelegramFileNotFoundError("Telegram did not return file content");
|
|
777
2462
|
}
|
|
778
|
-
|
|
2463
|
+
const buffer = Buffer.from(data);
|
|
2464
|
+
control.onProgress?.({
|
|
2465
|
+
transferred: buffer.byteLength,
|
|
2466
|
+
total: buffer.byteLength,
|
|
2467
|
+
percent: 100
|
|
2468
|
+
});
|
|
2469
|
+
return buffer;
|
|
2470
|
+
}
|
|
2471
|
+
async getStream(ref, control = {}) {
|
|
2472
|
+
const data = await this.get(ref, control);
|
|
2473
|
+
return Readable2.from([data]);
|
|
779
2474
|
}
|
|
2475
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
780
2476
|
async delete(ref) {
|
|
781
2477
|
try {
|
|
782
2478
|
await this.client.deleteMessages(this.peer, [Number(ref.messageId)], { revoke: true });
|
|
@@ -792,12 +2488,7 @@ var TelegramStorageProvider = class {
|
|
|
792
2488
|
}
|
|
793
2489
|
}
|
|
794
2490
|
async stat(ref) {
|
|
795
|
-
|
|
796
|
-
try {
|
|
797
|
-
message = await this.getMessage(ref.messageId);
|
|
798
|
-
} catch (error) {
|
|
799
|
-
throw mapTelegramError(error, "Telegram lookup failed");
|
|
800
|
-
}
|
|
2491
|
+
const message = await this.getMessage(ref.messageId);
|
|
801
2492
|
if (!message?.media) return null;
|
|
802
2493
|
const document = message.document;
|
|
803
2494
|
if (!(document instanceof Api2.Document)) return null;
|
|
@@ -815,16 +2506,87 @@ var TelegramStorageProvider = class {
|
|
|
815
2506
|
mimeType: document.mimeType
|
|
816
2507
|
};
|
|
817
2508
|
}
|
|
2509
|
+
// ── Health ───────────────────────────────────────────────────────────────
|
|
2510
|
+
/**
|
|
2511
|
+
* Validates that the client is connected and the storage channel is
|
|
2512
|
+
* reachable (a real Telegram round-trip on the channel peer).
|
|
2513
|
+
*/
|
|
2514
|
+
async healthCheck() {
|
|
2515
|
+
const startedAt = Date.now();
|
|
2516
|
+
try {
|
|
2517
|
+
if (!this.client.connected) {
|
|
2518
|
+
return {
|
|
2519
|
+
healthy: false,
|
|
2520
|
+
latencyMs: null,
|
|
2521
|
+
message: "Telegram client is not connected",
|
|
2522
|
+
targetTitle: this.title
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
const result = await this.client.invoke(
|
|
2526
|
+
new Api2.channels.GetFullChannel({ channel: this.peer })
|
|
2527
|
+
);
|
|
2528
|
+
const title = result instanceof Api2.messages.ChatFull && result.chats[0] instanceof Api2.Channel ? result.chats[0].title : this.title;
|
|
2529
|
+
return {
|
|
2530
|
+
healthy: true,
|
|
2531
|
+
latencyMs: Date.now() - startedAt,
|
|
2532
|
+
message: null,
|
|
2533
|
+
targetTitle: title ?? null
|
|
2534
|
+
};
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
const mapped = mapTelegramError(error, "Telegram storage health check failed");
|
|
2537
|
+
return {
|
|
2538
|
+
healthy: false,
|
|
2539
|
+
latencyMs: Date.now() - startedAt,
|
|
2540
|
+
message: mapped.message,
|
|
2541
|
+
targetTitle: this.title
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
2546
|
+
throwIfAborted(signal) {
|
|
2547
|
+
if (signal?.aborted) throw new OperationCancelledError();
|
|
2548
|
+
if (!this.client.connected) {
|
|
2549
|
+
throw new TelegramConnectionError("Telegram client is not connected");
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
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
|
+
documentSize(message) {
|
|
2564
|
+
const document = message.document;
|
|
2565
|
+
if (document instanceof Api2.Document) return Number(document.size.toString());
|
|
2566
|
+
return null;
|
|
2567
|
+
}
|
|
2568
|
+
async requireMessage(ref) {
|
|
2569
|
+
const message = await this.getMessage(ref.messageId);
|
|
2570
|
+
if (!message?.media) {
|
|
2571
|
+
throw new TelegramFileNotFoundError();
|
|
2572
|
+
}
|
|
2573
|
+
return message;
|
|
2574
|
+
}
|
|
818
2575
|
async getMessage(id) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
2576
|
+
try {
|
|
2577
|
+
const messages = await this.client.getMessages(this.peer, { ids: [Number(id)] });
|
|
2578
|
+
const first = messages[0];
|
|
2579
|
+
return first instanceof Api2.Message ? first : void 0;
|
|
2580
|
+
} catch (error) {
|
|
2581
|
+
if (error instanceof TelegramAuthRequiredError) throw error;
|
|
2582
|
+
throw mapTelegramError(error, "Telegram lookup failed");
|
|
2583
|
+
}
|
|
822
2584
|
}
|
|
823
2585
|
};
|
|
824
2586
|
|
|
825
2587
|
// ../telegram/src/connection.ts
|
|
826
2588
|
import { Api as Api3 } from "telegram";
|
|
827
|
-
import { computeCheck } from "telegram/Password";
|
|
2589
|
+
import { computeCheck } from "telegram/Password.js";
|
|
828
2590
|
var TELEGRAM_PROVIDER = "telegram";
|
|
829
2591
|
var LOGIN_FLOW_TTL_MS = 5 * 60 * 1e3;
|
|
830
2592
|
var TelegramConnectionService = class {
|
|
@@ -921,7 +2683,7 @@ var TelegramConnectionService = class {
|
|
|
921
2683
|
}
|
|
922
2684
|
const accessHash = channel.accessHash?.toString();
|
|
923
2685
|
if (!accessHash) {
|
|
924
|
-
throw new
|
|
2686
|
+
throw new TelegramConnectionError("Telegram did not provide the channel access hash");
|
|
925
2687
|
}
|
|
926
2688
|
return this.repos.storages.create({
|
|
927
2689
|
userId,
|
|
@@ -987,21 +2749,32 @@ var TelegramConnectionService = class {
|
|
|
987
2749
|
}
|
|
988
2750
|
};
|
|
989
2751
|
export {
|
|
2752
|
+
ActivityService,
|
|
990
2753
|
ERROR_CODES,
|
|
991
2754
|
FileService,
|
|
992
2755
|
FolderService,
|
|
2756
|
+
IntegrityService,
|
|
993
2757
|
OmniCloudClient,
|
|
994
2758
|
OmniCloudError,
|
|
2759
|
+
RecentService,
|
|
995
2760
|
SearchService,
|
|
2761
|
+
StatsService,
|
|
996
2762
|
StorageEngine,
|
|
997
2763
|
TELEGRAM_PROVIDER,
|
|
998
2764
|
TelegramClientManager,
|
|
999
2765
|
TelegramConnectionService,
|
|
1000
2766
|
TelegramStorageProvider,
|
|
2767
|
+
TrashService,
|
|
1001
2768
|
fileCategory,
|
|
2769
|
+
isFileCategory,
|
|
1002
2770
|
mapProviderError,
|
|
1003
2771
|
mapTelegramError,
|
|
1004
2772
|
mimeFromFilename,
|
|
2773
|
+
mimeMatchersForType,
|
|
2774
|
+
noopActivityRecorder,
|
|
2775
|
+
paginationMeta,
|
|
2776
|
+
parseSearchQuery,
|
|
2777
|
+
parseSize,
|
|
1005
2778
|
sanitizeFileName,
|
|
1006
2779
|
sha256Hex
|
|
1007
2780
|
};
|