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