@nightowne/tas-cli 2.0.0 → 2.3.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/README.md +326 -132
- package/package.json +4 -2
- package/src/cli.js +199 -24
- package/src/crypto/encryption.js +153 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +253 -155
- package/src/index.js +209 -112
- package/src/share/server.js +100 -32
- package/src/sync/sync.js +211 -56
- package/src/telegram/client.js +102 -26
- package/src/utils/branding.js +10 -3
- package/src/utils/chunker.js +15 -3
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/compression.js +30 -0
- package/src/utils/progress.js +3 -3
- package/src/utils/throttle.js +26 -0
package/src/sync/sync.js
CHANGED
|
@@ -13,6 +13,10 @@ import { processFile } from '../index.js';
|
|
|
13
13
|
// Debounce time in ms to batch rapid file changes
|
|
14
14
|
const DEBOUNCE_MS = 1000;
|
|
15
15
|
|
|
16
|
+
// Exponential backoff limits for upload errors
|
|
17
|
+
const INITIAL_BACKOFF_MS = 2000;
|
|
18
|
+
const MAX_BACKOFF_MS = 60000;
|
|
19
|
+
|
|
16
20
|
// Ignore patterns
|
|
17
21
|
const IGNORE_PATTERNS = [
|
|
18
22
|
/^\./, // Hidden files
|
|
@@ -29,7 +33,9 @@ export class SyncEngine extends EventEmitter {
|
|
|
29
33
|
this.dataDir = options.dataDir;
|
|
30
34
|
this.password = options.password;
|
|
31
35
|
this.config = options.config;
|
|
36
|
+
this.limitRate = options.limitRate || null;
|
|
32
37
|
this.watchers = new Map(); // path -> FSWatcher
|
|
38
|
+
this.rootWatchers = new Map(); // rootPath -> Set of watched subpaths (for Linux recursive watch fallback)
|
|
33
39
|
this.pendingChanges = new Map(); // path -> timeout
|
|
34
40
|
this.db = null;
|
|
35
41
|
this.running = false;
|
|
@@ -98,53 +104,76 @@ export class SyncEngine extends EventEmitter {
|
|
|
98
104
|
let uploaded = 0;
|
|
99
105
|
let skipped = 0;
|
|
100
106
|
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
// Process files with concurrency limit
|
|
108
|
+
const CONCURRENCY = 4;
|
|
109
|
+
const queue = [...files];
|
|
110
|
+
const promises = [];
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
skipped++;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
112
|
+
const worker = async () => {
|
|
113
|
+
let consecutiveErrors = 0;
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
while (queue.length > 0) {
|
|
116
|
+
const file = queue.shift();
|
|
117
|
+
const existing = stateMap.get(file.relativePath);
|
|
112
118
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
+
// Check if file has changed (by mtime)
|
|
120
|
+
if (existing && existing.mtime >= file.mtime) {
|
|
121
|
+
skipped++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
119
124
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.emit('file-upload-start', { file: file.relativePath });
|
|
125
|
+
// Calculate hash to detect actual changes
|
|
126
|
+
const hash = await hashFile(file.path);
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
128
|
+
if (existing && existing.file_hash === hash) {
|
|
129
|
+
// File unchanged, just update mtime
|
|
130
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
131
|
+
skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
131
134
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// File is new or changed - upload it
|
|
136
|
+
try {
|
|
137
|
+
this.emit('file-upload-start', { file: file.relativePath });
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
await processFile(file.path, {
|
|
140
|
+
password: this.password,
|
|
141
|
+
dataDir: this.dataDir,
|
|
142
|
+
customName: file.relativePath, // Use relative path as name
|
|
143
|
+
config: this.config,
|
|
144
|
+
limitRate: this.limitRate ? Math.floor(this.limitRate / CONCURRENCY) : null,
|
|
145
|
+
onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Update sync state
|
|
140
149
|
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
150
|
+
uploaded++;
|
|
151
|
+
consecutiveErrors = 0; // Reset on success
|
|
152
|
+
|
|
153
|
+
this.emit('file-upload-complete', { file: file.relativePath });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// File might already exist, skip
|
|
156
|
+
if (err.message.includes('duplicate')) {
|
|
157
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
158
|
+
skipped++;
|
|
159
|
+
consecutiveErrors = 0;
|
|
160
|
+
} else {
|
|
161
|
+
consecutiveErrors++;
|
|
162
|
+
// Exponential backoff: 2s → 4s → 8s → 16s → ... → 60s max
|
|
163
|
+
const delay = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, consecutiveErrors - 1), MAX_BACKOFF_MS);
|
|
164
|
+
await new Promise(r => setTimeout(r, delay));
|
|
165
|
+
this.emit('file-upload-error', { file: file.relativePath, error: err.message });
|
|
166
|
+
}
|
|
144
167
|
}
|
|
145
168
|
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < CONCURRENCY; i++) {
|
|
172
|
+
promises.push(worker());
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
await Promise.all(promises);
|
|
176
|
+
|
|
148
177
|
this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
|
|
149
178
|
|
|
150
179
|
return { uploaded, skipped };
|
|
@@ -206,6 +235,7 @@ export class SyncEngine extends EventEmitter {
|
|
|
206
235
|
dataDir: this.dataDir,
|
|
207
236
|
customName: filename,
|
|
208
237
|
config: this.config,
|
|
238
|
+
limitRate: this.limitRate,
|
|
209
239
|
onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
|
|
210
240
|
});
|
|
211
241
|
|
|
@@ -219,37 +249,148 @@ export class SyncEngine extends EventEmitter {
|
|
|
219
249
|
}
|
|
220
250
|
}
|
|
221
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Recursively list all subdirectories of a directory
|
|
254
|
+
*/
|
|
255
|
+
getSubdirectories(dirPath) {
|
|
256
|
+
const subdirs = [];
|
|
257
|
+
try {
|
|
258
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (entry.isDirectory()) {
|
|
261
|
+
if (this.shouldIgnore(entry.name)) continue;
|
|
262
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
263
|
+
subdirs.push(fullPath);
|
|
264
|
+
subdirs.push(...this.getSubdirectories(fullPath));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
// Ignore folder reading errors
|
|
269
|
+
}
|
|
270
|
+
return subdirs;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Watch a single directory (non-recursively) and dynamically watch new folders
|
|
275
|
+
*/
|
|
276
|
+
watchSingleDir(rootPath, dir) {
|
|
277
|
+
if (this.watchers.has(dir)) return;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const watcher = fs.watch(dir, { recursive: false }, (event, filename) => {
|
|
281
|
+
if (!filename) return;
|
|
282
|
+
|
|
283
|
+
const fullPath = path.join(dir, filename);
|
|
284
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
285
|
+
|
|
286
|
+
// If a new directory is created, watch it recursively
|
|
287
|
+
try {
|
|
288
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
289
|
+
if (!this.shouldIgnore(filename)) {
|
|
290
|
+
this.watchDirRecursively(rootPath, fullPath);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch (e) {
|
|
294
|
+
// Ignore stats errors
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.handleFileChange(rootPath, relativePath);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
watcher.on('error', (err) => {
|
|
301
|
+
this.emit('watch-error', { folder: rootPath, error: err.message });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
this.watchers.set(dir, watcher);
|
|
305
|
+
|
|
306
|
+
if (!this.rootWatchers.has(rootPath)) {
|
|
307
|
+
this.rootWatchers.set(rootPath, new Set());
|
|
308
|
+
}
|
|
309
|
+
this.rootWatchers.get(rootPath).add(dir);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
this.emit('watch-error', { folder: rootPath, error: err.message });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Recursively watch a newly created directory and its subdirectories
|
|
317
|
+
*/
|
|
318
|
+
watchDirRecursively(rootPath, dirPath) {
|
|
319
|
+
this.watchSingleDir(rootPath, dirPath);
|
|
320
|
+
const subdirs = this.getSubdirectories(dirPath);
|
|
321
|
+
for (const subdir of subdirs) {
|
|
322
|
+
this.watchSingleDir(rootPath, subdir);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
222
326
|
/**
|
|
223
327
|
* Start watching a folder
|
|
224
328
|
*/
|
|
225
329
|
watchFolder(folderPath) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
330
|
+
const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
|
|
331
|
+
|
|
332
|
+
if (isRecursiveSupported) {
|
|
333
|
+
if (this.watchers.has(folderPath)) {
|
|
334
|
+
return; // Already watching
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
|
|
338
|
+
if (filename) {
|
|
339
|
+
this.handleFileChange(folderPath, filename);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
229
342
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
343
|
+
watcher.on('error', (err) => {
|
|
344
|
+
this.emit('watch-error', { folder: folderPath, error: err.message });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
this.watchers.set(folderPath, watcher);
|
|
348
|
+
this.emit('watch-start', { folder: folderPath });
|
|
349
|
+
} else {
|
|
350
|
+
// Manual recursive watch for Linux/other platforms
|
|
351
|
+
if (this.rootWatchers.has(folderPath)) {
|
|
352
|
+
return; // Already watching
|
|
233
353
|
}
|
|
234
|
-
});
|
|
235
354
|
|
|
236
|
-
|
|
237
|
-
this.
|
|
238
|
-
|
|
355
|
+
// Watch root
|
|
356
|
+
this.watchSingleDir(folderPath, folderPath);
|
|
357
|
+
|
|
358
|
+
// Watch all existing subdirectories
|
|
359
|
+
const subdirs = this.getSubdirectories(folderPath);
|
|
360
|
+
for (const subdir of subdirs) {
|
|
361
|
+
this.watchSingleDir(folderPath, subdir);
|
|
362
|
+
}
|
|
239
363
|
|
|
240
|
-
|
|
241
|
-
|
|
364
|
+
this.emit('watch-start', { folder: folderPath });
|
|
365
|
+
}
|
|
242
366
|
}
|
|
243
367
|
|
|
244
368
|
/**
|
|
245
369
|
* Stop watching a folder
|
|
246
370
|
*/
|
|
247
371
|
unwatchFolder(folderPath) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
this.watchers.
|
|
252
|
-
|
|
372
|
+
const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
|
|
373
|
+
|
|
374
|
+
if (isRecursiveSupported) {
|
|
375
|
+
const watcher = this.watchers.get(folderPath);
|
|
376
|
+
if (watcher) {
|
|
377
|
+
watcher.close();
|
|
378
|
+
this.watchers.delete(folderPath);
|
|
379
|
+
this.emit('watch-stop', { folder: folderPath });
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
if (this.rootWatchers.has(folderPath)) {
|
|
383
|
+
const dirs = this.rootWatchers.get(folderPath);
|
|
384
|
+
for (const dir of dirs) {
|
|
385
|
+
const watcher = this.watchers.get(dir);
|
|
386
|
+
if (watcher) {
|
|
387
|
+
watcher.close();
|
|
388
|
+
this.watchers.delete(dir);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
this.rootWatchers.delete(folderPath);
|
|
392
|
+
this.emit('watch-stop', { folder: folderPath });
|
|
393
|
+
}
|
|
253
394
|
}
|
|
254
395
|
}
|
|
255
396
|
|
|
@@ -283,11 +424,25 @@ export class SyncEngine extends EventEmitter {
|
|
|
283
424
|
this.pendingChanges.clear();
|
|
284
425
|
|
|
285
426
|
// Close all watchers
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
427
|
+
const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
|
|
428
|
+
if (isRecursiveSupported) {
|
|
429
|
+
for (const [folderPath, watcher] of this.watchers) {
|
|
430
|
+
watcher.close();
|
|
431
|
+
this.emit('watch-stop', { folder: folderPath });
|
|
432
|
+
}
|
|
433
|
+
this.watchers.clear();
|
|
434
|
+
} else {
|
|
435
|
+
for (const rootPath of this.rootWatchers.keys()) {
|
|
436
|
+
this.unwatchFolder(rootPath);
|
|
437
|
+
}
|
|
438
|
+
this.rootWatchers.clear();
|
|
439
|
+
|
|
440
|
+
// Just in case, close any stray watchers
|
|
441
|
+
for (const watcher of this.watchers.values()) {
|
|
442
|
+
try { watcher.close(); } catch (e) {}
|
|
443
|
+
}
|
|
444
|
+
this.watchers.clear();
|
|
289
445
|
}
|
|
290
|
-
this.watchers.clear();
|
|
291
446
|
|
|
292
447
|
if (this.db) {
|
|
293
448
|
this.db.close();
|
package/src/telegram/client.js
CHANGED
|
@@ -1,25 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram Bot client wrapper
|
|
3
3
|
* Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
|
|
4
|
+
* Includes exponential backoff retry and rate limiting for production reliability.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import TelegramBot from 'node-telegram-bot-api';
|
|
7
8
|
import fs from 'fs';
|
|
8
9
|
import path from 'path';
|
|
10
|
+
import { pipeline } from 'stream/promises';
|
|
11
|
+
|
|
12
|
+
const MAX_RETRIES = 5;
|
|
13
|
+
const BASE_DELAY_MS = 1000;
|
|
14
|
+
const MAX_DELAY_MS = 60000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Retry an async function with exponential backoff + jitter.
|
|
18
|
+
* Handles Telegram 429 (rate limit) errors by respecting retry_after.
|
|
19
|
+
*/
|
|
20
|
+
async function withRetry(fn, label = 'operation', retries = MAX_RETRIES) {
|
|
21
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const isRateLimit = err?.response?.statusCode === 429 || err?.message?.includes('429');
|
|
26
|
+
const isTransient = err?.code === 'ETIMEOUT' || err?.code === 'ECONNRESET' ||
|
|
27
|
+
err?.code === 'ENOTFOUND' || err?.message?.includes('ETIMEDOUT');
|
|
28
|
+
|
|
29
|
+
if (attempt >= retries) throw err;
|
|
30
|
+
if (!isRateLimit && !isTransient) throw err;
|
|
31
|
+
|
|
32
|
+
let delay;
|
|
33
|
+
if (isRateLimit && err?.response?.body?.parameters?.retry_after) {
|
|
34
|
+
delay = err.response.body.parameters.retry_after * 1000 + 500;
|
|
35
|
+
} else {
|
|
36
|
+
delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000, MAX_DELAY_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sec = (delay / 1000).toFixed(1);
|
|
40
|
+
console.log(`⏳ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${sec}s...`);
|
|
41
|
+
await new Promise(r => setTimeout(r, delay));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
9
45
|
|
|
10
46
|
export class TelegramClient {
|
|
11
47
|
constructor(dataDir) {
|
|
12
48
|
this.dataDir = dataDir;
|
|
13
49
|
this.bot = null;
|
|
14
50
|
this.chatId = null;
|
|
51
|
+
this._lastSendTime = 0;
|
|
15
52
|
}
|
|
16
53
|
|
|
17
54
|
/**
|
|
18
55
|
* Initialize with bot token
|
|
19
56
|
* Get token from @BotFather on Telegram
|
|
20
57
|
*/
|
|
21
|
-
async initialize(token) {
|
|
22
|
-
|
|
58
|
+
async initialize(token, customApiUrl = null) {
|
|
59
|
+
const options = { polling: false };
|
|
60
|
+
if (customApiUrl) {
|
|
61
|
+
options.baseApiUrl = customApiUrl;
|
|
62
|
+
}
|
|
63
|
+
this.bot = new TelegramBot(token, options);
|
|
23
64
|
|
|
24
65
|
// Verify the token works
|
|
25
66
|
try {
|
|
@@ -71,49 +112,84 @@ export class TelegramClient {
|
|
|
71
112
|
});
|
|
72
113
|
}
|
|
73
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Rate-limit: ensure at least 1s between sends to same chat (Telegram limit)
|
|
117
|
+
*/
|
|
118
|
+
async _rateLimit() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const elapsed = now - this._lastSendTime;
|
|
121
|
+
if (elapsed < 1000) {
|
|
122
|
+
await new Promise(r => setTimeout(r, 1000 - elapsed));
|
|
123
|
+
}
|
|
124
|
+
this._lastSendTime = Date.now();
|
|
125
|
+
}
|
|
126
|
+
|
|
74
127
|
/**
|
|
75
128
|
* Send a file to the storage chat
|
|
76
129
|
* Telegram supports up to 2GB for documents!
|
|
130
|
+
* Includes automatic retry with exponential backoff.
|
|
77
131
|
*/
|
|
78
|
-
async sendFile(filePath, caption = '') {
|
|
132
|
+
async sendFile(filePath, caption = '', options = {}) {
|
|
79
133
|
if (!this.chatId) {
|
|
80
134
|
throw new Error('Chat ID not set. Run init first.');
|
|
81
135
|
}
|
|
82
136
|
|
|
83
|
-
|
|
137
|
+
if (!fs.existsSync(filePath)) {
|
|
138
|
+
throw new Error(`File not found: ${filePath}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
84
141
|
const filename = path.basename(filePath);
|
|
85
142
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
143
|
+
return withRetry(async () => {
|
|
144
|
+
await this._rateLimit();
|
|
145
|
+
|
|
146
|
+
let fileStream = fs.createReadStream(filePath);
|
|
147
|
+
|
|
148
|
+
if (options.limitRate) {
|
|
149
|
+
const { Throttle } = await import('../utils/throttle.js');
|
|
150
|
+
fileStream = fileStream.pipe(new Throttle(options.limitRate));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const message = await this.bot.sendDocument(this.chatId, fileStream, {
|
|
154
|
+
caption: caption
|
|
155
|
+
}, {
|
|
156
|
+
filename: filename,
|
|
157
|
+
contentType: 'application/octet-stream'
|
|
158
|
+
});
|
|
92
159
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
160
|
+
return {
|
|
161
|
+
messageId: message.message_id,
|
|
162
|
+
fileId: message.document.file_id,
|
|
163
|
+
timestamp: message.date
|
|
164
|
+
};
|
|
165
|
+
}, `Upload ${filename}`);
|
|
98
166
|
}
|
|
99
167
|
|
|
100
168
|
/**
|
|
101
|
-
* Download a file from Telegram
|
|
169
|
+
* Download a file from Telegram (In-Memory buffer)
|
|
170
|
+
* Includes automatic retry with exponential backoff.
|
|
102
171
|
*/
|
|
103
172
|
async downloadFile(fileId) {
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
return withRetry(async () => {
|
|
174
|
+
const fileStream = await this.bot.getFileStream(fileId);
|
|
106
175
|
|
|
107
|
-
|
|
108
|
-
|
|
176
|
+
const chunks = [];
|
|
177
|
+
for await (const chunk of fileStream) {
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
}
|
|
109
180
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
chunks.push(chunk);
|
|
114
|
-
}
|
|
181
|
+
return Buffer.concat(chunks);
|
|
182
|
+
}, `Download ${fileId.substring(0, 12)}...`);
|
|
183
|
+
}
|
|
115
184
|
|
|
116
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Efficiently download a file from Telegram straight to disk
|
|
187
|
+
*/
|
|
188
|
+
async downloadFileToPath(fileId, destPath) {
|
|
189
|
+
const fileStream = await this.bot.getFileStream(fileId);
|
|
190
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
191
|
+
await pipeline(fileStream, writeStream);
|
|
192
|
+
return destPath;
|
|
117
193
|
}
|
|
118
194
|
|
|
119
195
|
/**
|
package/src/utils/branding.js
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
6
13
|
|
|
7
14
|
export const LOGO = `
|
|
8
15
|
████████╗ █████╗ ███████╗
|
|
@@ -14,7 +21,7 @@ export const LOGO = `
|
|
|
14
21
|
`;
|
|
15
22
|
|
|
16
23
|
export const TAGLINE = 'Telegram as Storage';
|
|
17
|
-
export const VERSION =
|
|
24
|
+
export const VERSION = pkg.version;
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Print the TAS banner
|
|
@@ -57,10 +64,10 @@ export function warn(msg) {
|
|
|
57
64
|
* Format file size
|
|
58
65
|
*/
|
|
59
66
|
export function formatSize(bytes) {
|
|
60
|
-
if (bytes
|
|
67
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
61
68
|
const k = 1024;
|
|
62
69
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
63
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
70
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
64
71
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
65
72
|
}
|
|
66
73
|
|
package/src/utils/chunker.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File chunking utilities for large files
|
|
3
|
-
*
|
|
3
|
+
* Telegram Bot API limit is 50 MB for bot uploads (sendDocument).
|
|
4
|
+
* We use 49 MB to leave room for the 64-byte WAS1 header.
|
|
5
|
+
* See: https://core.telegram.org/bots/api#senddocument
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
const MAX_CHUNK_SIZE =
|
|
8
|
+
const MAX_CHUNK_SIZE = 49 * 1024 * 1024; // 49 MB — Telegram Bot API safe limit
|
|
7
9
|
|
|
8
10
|
export class Chunker {
|
|
9
11
|
/**
|
|
@@ -91,7 +93,17 @@ export function createHeader(filename, originalSize, chunkIndex, totalChunks, fl
|
|
|
91
93
|
offset += 2;
|
|
92
94
|
|
|
93
95
|
// Filename length
|
|
94
|
-
|
|
96
|
+
let filenameBytes = Buffer.from(filename, 'utf-8');
|
|
97
|
+
if (filenameBytes.length > 42) {
|
|
98
|
+
// We carefully truncate by chars instead of bytes to avoid splitting a UTF-8 character in half!
|
|
99
|
+
let truncated = filename;
|
|
100
|
+
while (Buffer.from(truncated, 'utf-8').length > 42) {
|
|
101
|
+
// Remove one character at a time from the end
|
|
102
|
+
truncated = truncated.slice(0, -1);
|
|
103
|
+
}
|
|
104
|
+
filenameBytes = Buffer.from(truncated, 'utf-8');
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
header.writeUInt16LE(filenameBytes.length, offset);
|
|
96
108
|
offset += 2;
|
|
97
109
|
|
package/src/utils/cli-helpers.js
CHANGED
|
@@ -39,14 +39,13 @@ export async function getPassword(passwordOption, allowCache = true) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Verify password against config
|
|
42
|
+
* Verify password against config (supports both legacy and new hash formats)
|
|
43
43
|
* @param {string} password - Password to verify
|
|
44
44
|
* @param {Object} config - Config object with passwordHash
|
|
45
45
|
* @returns {boolean}
|
|
46
46
|
*/
|
|
47
47
|
export function verifyPassword(password, config) {
|
|
48
|
-
|
|
49
|
-
return encryptor.getPasswordHash() === config.passwordHash;
|
|
48
|
+
return Encryptor.verifyPasswordHash(password, config.passwordHash);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
/**
|
|
@@ -62,11 +61,13 @@ export function validateConfig(config) {
|
|
|
62
61
|
return { valid: false, errors };
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// v2: encrypted token, v1: plaintext token
|
|
65
|
+
const hasToken = config.encryptedBotToken || config.botToken;
|
|
66
|
+
if (!hasToken) {
|
|
67
|
+
errors.push('Missing bot token (botToken or encryptedBotToken)');
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
if (!config.botToken
|
|
70
|
+
if (config.botToken && !config.botToken.includes(':')) {
|
|
70
71
|
errors.push('Invalid bot token format (should contain :)');
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -84,6 +85,29 @@ export function validateConfig(config) {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Decrypt bot token from config using password
|
|
90
|
+
* Supports both v1 (plaintext) and v2 (encrypted) configs
|
|
91
|
+
* @param {Object} config - Config object
|
|
92
|
+
* @param {string} password - User's password
|
|
93
|
+
* @returns {string} - Decrypted bot token
|
|
94
|
+
*/
|
|
95
|
+
export function decryptBotToken(config, password) {
|
|
96
|
+
// v1: plaintext token (backward compatibility)
|
|
97
|
+
if (config.botToken) {
|
|
98
|
+
return config.botToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// v2: encrypted token
|
|
102
|
+
if (config.encryptedBotToken) {
|
|
103
|
+
const encryptor = new Encryptor(password);
|
|
104
|
+
const encryptedBuffer = Buffer.from(config.encryptedBotToken, 'base64');
|
|
105
|
+
return encryptor.decrypt(encryptedBuffer).toString('utf-8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw new Error('No bot token found in config');
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
/**
|
|
88
112
|
* Load and validate config
|
|
89
113
|
* @param {string} dataDir - Data directory path
|
|
@@ -143,3 +167,17 @@ export async function getAndVerifyPassword(passwordOption, dataDir) {
|
|
|
143
167
|
|
|
144
168
|
return password;
|
|
145
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve config by decrypting bot token if needed.
|
|
173
|
+
* Returns a config object with a guaranteed plaintext `botToken` field.
|
|
174
|
+
* @param {Object} config - Raw config from disk
|
|
175
|
+
* @param {string} password - Verified password
|
|
176
|
+
* @returns {Object} - Config with decrypted botToken
|
|
177
|
+
*/
|
|
178
|
+
export function resolveConfig(config, password) {
|
|
179
|
+
return {
|
|
180
|
+
...config,
|
|
181
|
+
botToken: decryptBotToken(config, password)
|
|
182
|
+
};
|
|
183
|
+
}
|