@nightowne/tas-cli 1.1.1 → 2.1.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 +102 -97
- package/package.json +3 -3
- package/src/cli.js +212 -147
- package/src/crypto/encryption.js +128 -0
- package/src/db/index.js +86 -0
- package/src/fuse/mount.js +104 -40
- package/src/index.js +173 -103
- package/src/share/server.js +441 -0
- package/src/sync/sync.js +54 -35
- package/src/telegram/client.js +45 -17
- package/src/utils/chunker.js +11 -1
- package/src/utils/cli-helpers.js +145 -0
- package/src/utils/compression.js +30 -0
- package/src/utils/throttle.js +26 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share Server — Temporary file sharing via HTTP
|
|
3
|
+
* Spins up a lightweight server that serves one-time download links
|
|
4
|
+
* for files stored in Telegram. Files are decrypted on-the-fly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { FileIndex } from '../db/index.js';
|
|
11
|
+
import { TelegramClient } from '../telegram/client.js';
|
|
12
|
+
import { Encryptor } from '../crypto/encryption.js';
|
|
13
|
+
import { Compressor } from '../utils/compression.js';
|
|
14
|
+
import { parseHeader, HEADER_SIZE } from '../utils/chunker.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate a secure random share token
|
|
18
|
+
*/
|
|
19
|
+
export function generateToken() {
|
|
20
|
+
return crypto.randomBytes(16).toString('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse duration string to milliseconds
|
|
25
|
+
* Supports: 1h, 24h, 7d, 30m, etc.
|
|
26
|
+
*/
|
|
27
|
+
export function parseDuration(str) {
|
|
28
|
+
const match = str.match(/^(\d+)(m|h|d)$/);
|
|
29
|
+
if (!match) throw new Error(`Invalid duration: ${str}. Use format like 1h, 24h, 7d, 30m`);
|
|
30
|
+
|
|
31
|
+
const value = parseInt(match[1]);
|
|
32
|
+
const unit = match[2];
|
|
33
|
+
|
|
34
|
+
const multipliers = {
|
|
35
|
+
'm': 60 * 1000,
|
|
36
|
+
'h': 60 * 60 * 1000,
|
|
37
|
+
'd': 24 * 60 * 60 * 1000
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return value * multipliers[unit];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format remaining time human-readable
|
|
45
|
+
*/
|
|
46
|
+
function formatTimeLeft(expiresAt) {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const diff = new Date(expiresAt).getTime() - now;
|
|
49
|
+
|
|
50
|
+
if (diff <= 0) return 'expired';
|
|
51
|
+
|
|
52
|
+
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
53
|
+
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
54
|
+
|
|
55
|
+
if (hours > 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
56
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
57
|
+
return `${minutes}m`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate the download HTML page
|
|
62
|
+
*/
|
|
63
|
+
function generateDownloadPage(share, fileRecord) {
|
|
64
|
+
const timeLeft = formatTimeLeft(share.expires_at);
|
|
65
|
+
const downloadsLeft = share.max_downloads - share.download_count;
|
|
66
|
+
const fileSize = fileRecord.original_size;
|
|
67
|
+
const sizeStr = fileSize > 1048576
|
|
68
|
+
? `${(fileSize / 1048576).toFixed(1)} MB`
|
|
69
|
+
: fileSize > 1024
|
|
70
|
+
? `${(fileSize / 1024).toFixed(1)} KB`
|
|
71
|
+
: `${fileSize} B`;
|
|
72
|
+
|
|
73
|
+
return `<!DOCTYPE html>
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="UTF-8">
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
78
|
+
<title>TAS — Secure File Download</title>
|
|
79
|
+
<style>
|
|
80
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
81
|
+
body {
|
|
82
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
83
|
+
background: #0a0a0a;
|
|
84
|
+
color: #e0e0e0;
|
|
85
|
+
min-height: 100vh;
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
}
|
|
90
|
+
.card {
|
|
91
|
+
background: #1a1a2e;
|
|
92
|
+
border: 1px solid #2a2a4a;
|
|
93
|
+
border-radius: 16px;
|
|
94
|
+
padding: 40px;
|
|
95
|
+
max-width: 420px;
|
|
96
|
+
width: 90%;
|
|
97
|
+
text-align: center;
|
|
98
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
99
|
+
}
|
|
100
|
+
.icon { font-size: 48px; margin-bottom: 16px; }
|
|
101
|
+
h1 { font-size: 20px; margin-bottom: 8px; color: #fff; }
|
|
102
|
+
.filename {
|
|
103
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
104
|
+
background: #0f0f23;
|
|
105
|
+
padding: 8px 16px;
|
|
106
|
+
border-radius: 8px;
|
|
107
|
+
margin: 16px 0;
|
|
108
|
+
font-size: 14px;
|
|
109
|
+
color: #7c83ff;
|
|
110
|
+
word-break: break-all;
|
|
111
|
+
}
|
|
112
|
+
.meta {
|
|
113
|
+
display: flex;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
gap: 24px;
|
|
116
|
+
margin: 16px 0;
|
|
117
|
+
font-size: 13px;
|
|
118
|
+
color: #888;
|
|
119
|
+
}
|
|
120
|
+
.meta span { display: flex; align-items: center; gap: 4px; }
|
|
121
|
+
.btn {
|
|
122
|
+
display: inline-block;
|
|
123
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
124
|
+
color: #fff;
|
|
125
|
+
text-decoration: none;
|
|
126
|
+
padding: 14px 40px;
|
|
127
|
+
border-radius: 10px;
|
|
128
|
+
font-size: 16px;
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
margin-top: 20px;
|
|
131
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
132
|
+
}
|
|
133
|
+
.btn:hover {
|
|
134
|
+
transform: translateY(-2px);
|
|
135
|
+
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
|
|
136
|
+
}
|
|
137
|
+
.footer {
|
|
138
|
+
margin-top: 24px;
|
|
139
|
+
font-size: 11px;
|
|
140
|
+
color: #555;
|
|
141
|
+
}
|
|
142
|
+
.footer a { color: #667eea; text-decoration: none; }
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<div class="card">
|
|
147
|
+
<div class="icon">🔐</div>
|
|
148
|
+
<h1>Secure File Share</h1>
|
|
149
|
+
<div class="filename">${fileRecord.filename}</div>
|
|
150
|
+
<div class="meta">
|
|
151
|
+
<span>📦 ${sizeStr}</span>
|
|
152
|
+
<span>⏳ ${timeLeft}</span>
|
|
153
|
+
<span>⬇️ ${downloadsLeft} left</span>
|
|
154
|
+
</div>
|
|
155
|
+
<a href="/d/${share.token}?download=1" class="btn">⬇ Download</a>
|
|
156
|
+
<div class="footer">
|
|
157
|
+
Encrypted with AES-256-GCM · Powered by <a href="https://github.com/ixchio/tas">TAS</a>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</body>
|
|
161
|
+
</html>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Generate expired/invalid page
|
|
166
|
+
*/
|
|
167
|
+
function generateExpiredPage(reason = 'expired') {
|
|
168
|
+
const messages = {
|
|
169
|
+
expired: { icon: '⏰', title: 'Link Expired', desc: 'This download link has expired.' },
|
|
170
|
+
used: { icon: '✅', title: 'Already Downloaded', desc: 'This file has reached its download limit.' },
|
|
171
|
+
invalid: { icon: '❌', title: 'Invalid Link', desc: 'This download link is invalid or has been revoked.' }
|
|
172
|
+
};
|
|
173
|
+
const msg = messages[reason] || messages.invalid;
|
|
174
|
+
|
|
175
|
+
return `<!DOCTYPE html>
|
|
176
|
+
<html lang="en">
|
|
177
|
+
<head>
|
|
178
|
+
<meta charset="UTF-8">
|
|
179
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
180
|
+
<title>TAS — ${msg.title}</title>
|
|
181
|
+
<style>
|
|
182
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
183
|
+
body {
|
|
184
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
185
|
+
background: #0a0a0a;
|
|
186
|
+
color: #e0e0e0;
|
|
187
|
+
min-height: 100vh;
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
justify-content: center;
|
|
191
|
+
}
|
|
192
|
+
.card {
|
|
193
|
+
background: #1a1a2e;
|
|
194
|
+
border: 1px solid #2a2a4a;
|
|
195
|
+
border-radius: 16px;
|
|
196
|
+
padding: 40px;
|
|
197
|
+
max-width: 420px;
|
|
198
|
+
width: 90%;
|
|
199
|
+
text-align: center;
|
|
200
|
+
}
|
|
201
|
+
.icon { font-size: 48px; margin-bottom: 16px; }
|
|
202
|
+
h1 { font-size: 20px; margin-bottom: 8px; color: #fff; }
|
|
203
|
+
p { color: #888; font-size: 14px; }
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<div class="card">
|
|
208
|
+
<div class="icon">${msg.icon}</div>
|
|
209
|
+
<h1>${msg.title}</h1>
|
|
210
|
+
<p>${msg.desc}</p>
|
|
211
|
+
</div>
|
|
212
|
+
</body>
|
|
213
|
+
</html>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class ShareServer {
|
|
217
|
+
constructor(options) {
|
|
218
|
+
this.dataDir = options.dataDir;
|
|
219
|
+
this.password = options.password;
|
|
220
|
+
this.config = options.config;
|
|
221
|
+
this.port = options.port || 3000;
|
|
222
|
+
this.host = options.host || '0.0.0.0';
|
|
223
|
+
|
|
224
|
+
this.db = null;
|
|
225
|
+
this.client = null;
|
|
226
|
+
this.encryptor = null;
|
|
227
|
+
this.compressor = null;
|
|
228
|
+
this.server = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async initialize() {
|
|
232
|
+
this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
|
|
233
|
+
this.db.init();
|
|
234
|
+
|
|
235
|
+
this.client = new TelegramClient(this.dataDir);
|
|
236
|
+
await this.client.initialize(this.config.botToken);
|
|
237
|
+
this.client.setChatId(this.config.chatId);
|
|
238
|
+
|
|
239
|
+
this.encryptor = new Encryptor(this.password);
|
|
240
|
+
this.compressor = new Compressor();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Stream a decrypted file from Telegram directly to the HTTP response
|
|
245
|
+
*/
|
|
246
|
+
async streamToResponse(fileRecord, res) {
|
|
247
|
+
const chunks = this.db.getChunks(fileRecord.id);
|
|
248
|
+
if (chunks.length === 0) throw new Error('No chunks found');
|
|
249
|
+
|
|
250
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
251
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
252
|
+
|
|
253
|
+
// Get total size from first chunk's header
|
|
254
|
+
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
255
|
+
const header = parseHeader(firstChunkData);
|
|
256
|
+
let wasCompressed = header.compressed;
|
|
257
|
+
|
|
258
|
+
// Prepare streams
|
|
259
|
+
const decryptStream = this.encryptor.getDecryptStream();
|
|
260
|
+
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
261
|
+
|
|
262
|
+
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
263
|
+
const { Readable } = await import('stream');
|
|
264
|
+
const { pipeline } = await import('stream/promises');
|
|
265
|
+
|
|
266
|
+
const self = this;
|
|
267
|
+
let currentChunkIndex = 0;
|
|
268
|
+
let preloadedFirstChunk = firstChunkData;
|
|
269
|
+
|
|
270
|
+
const downloadStream = new Readable({
|
|
271
|
+
async read() {
|
|
272
|
+
try {
|
|
273
|
+
if (currentChunkIndex >= chunks.length) {
|
|
274
|
+
this.push(null); // End of stream
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const chunk = chunks[currentChunkIndex];
|
|
279
|
+
let data;
|
|
280
|
+
|
|
281
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
282
|
+
data = preloadedFirstChunk;
|
|
283
|
+
preloadedFirstChunk = null;
|
|
284
|
+
} else {
|
|
285
|
+
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Strip header before pushing
|
|
289
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
290
|
+
this.push(payload);
|
|
291
|
+
|
|
292
|
+
currentChunkIndex++;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.destroy(err);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Pipeline: Download from Telegram -> Decrypt -> Decompress -> HTTP Response
|
|
300
|
+
await pipeline(downloadStream, decryptStream, decompressStream, res);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Handle incoming HTTP requests
|
|
305
|
+
*/
|
|
306
|
+
async handleRequest(req, res) {
|
|
307
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
308
|
+
|
|
309
|
+
// Route: GET /d/:token
|
|
310
|
+
const downloadMatch = url.pathname.match(/^\/d\/([a-f0-9]+)$/);
|
|
311
|
+
|
|
312
|
+
if (!downloadMatch) {
|
|
313
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
314
|
+
res.end(generateExpiredPage('invalid'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const token = downloadMatch[1];
|
|
319
|
+
const wantDownload = url.searchParams.get('download') === '1';
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
// Clean expired shares first
|
|
323
|
+
this.db.cleanExpiredShares();
|
|
324
|
+
|
|
325
|
+
// Look up share
|
|
326
|
+
const share = this.db.getShare(token);
|
|
327
|
+
|
|
328
|
+
if (!share) {
|
|
329
|
+
res.writeHead(410, { 'Content-Type': 'text/html' });
|
|
330
|
+
res.end(generateExpiredPage('invalid'));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check expiry
|
|
335
|
+
if (new Date(share.expires_at) < new Date()) {
|
|
336
|
+
res.writeHead(410, { 'Content-Type': 'text/html' });
|
|
337
|
+
res.end(generateExpiredPage('expired'));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check download limit
|
|
342
|
+
if (share.download_count >= share.max_downloads) {
|
|
343
|
+
res.writeHead(410, { 'Content-Type': 'text/html' });
|
|
344
|
+
res.end(generateExpiredPage('used'));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Get file record
|
|
349
|
+
const fileRecord = this.db.db.prepare('SELECT * FROM files WHERE id = ?').get(share.file_id);
|
|
350
|
+
if (!fileRecord) {
|
|
351
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
352
|
+
res.end(generateExpiredPage('invalid'));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!wantDownload) {
|
|
357
|
+
// Show download page
|
|
358
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
359
|
+
res.end(generateDownloadPage(share, fileRecord));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Increment download count
|
|
364
|
+
this.db.incrementShareDownload(token);
|
|
365
|
+
|
|
366
|
+
// Determine content type
|
|
367
|
+
const ext = path.extname(fileRecord.filename).toLowerCase();
|
|
368
|
+
const contentTypes = {
|
|
369
|
+
'.pdf': 'application/pdf',
|
|
370
|
+
'.png': 'image/png',
|
|
371
|
+
'.jpg': 'image/jpeg',
|
|
372
|
+
'.jpeg': 'image/jpeg',
|
|
373
|
+
'.gif': 'image/gif',
|
|
374
|
+
'.txt': 'text/plain',
|
|
375
|
+
'.json': 'application/json',
|
|
376
|
+
'.zip': 'application/zip',
|
|
377
|
+
'.mp4': 'video/mp4',
|
|
378
|
+
'.mp3': 'audio/mpeg'
|
|
379
|
+
};
|
|
380
|
+
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
381
|
+
|
|
382
|
+
res.writeHead(200, {
|
|
383
|
+
'Content-Type': contentType,
|
|
384
|
+
'Content-Disposition': `attachment; filename="${fileRecord.filename}"`,
|
|
385
|
+
'Content-Length': fileRecord.original_size
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Download the file from Telegram, decrypt, decompress and stream directly to 'res'
|
|
389
|
+
await this.streamToResponse(fileRecord, res);
|
|
390
|
+
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error('Share server error:', err.message);
|
|
393
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
394
|
+
res.end('Internal server error');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Start the HTTP server
|
|
400
|
+
*/
|
|
401
|
+
start() {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
this.server = http.createServer((req, res) => {
|
|
404
|
+
this.handleRequest(req, res).catch(err => {
|
|
405
|
+
console.error('Request error:', err);
|
|
406
|
+
res.writeHead(500);
|
|
407
|
+
res.end('Internal error');
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
this.server.on('error', (err) => {
|
|
412
|
+
if (err.code === 'EADDRINUSE') {
|
|
413
|
+
reject(new Error(`Port ${this.port} is already in use. Try --port <other-port>`));
|
|
414
|
+
} else {
|
|
415
|
+
reject(err);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
this.server.listen(this.port, this.host, () => {
|
|
420
|
+
resolve();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Stop the HTTP server
|
|
427
|
+
*/
|
|
428
|
+
stop() {
|
|
429
|
+
return new Promise((resolve) => {
|
|
430
|
+
if (this.server) {
|
|
431
|
+
this.server.close(() => {
|
|
432
|
+
if (this.db) this.db.close();
|
|
433
|
+
resolve();
|
|
434
|
+
});
|
|
435
|
+
} else {
|
|
436
|
+
if (this.db) this.db.close();
|
|
437
|
+
resolve();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
package/src/sync/sync.js
CHANGED
|
@@ -29,6 +29,7 @@ export class SyncEngine extends EventEmitter {
|
|
|
29
29
|
this.dataDir = options.dataDir;
|
|
30
30
|
this.password = options.password;
|
|
31
31
|
this.config = options.config;
|
|
32
|
+
this.limitRate = options.limitRate || null;
|
|
32
33
|
this.watchers = new Map(); // path -> FSWatcher
|
|
33
34
|
this.pendingChanges = new Map(); // path -> timeout
|
|
34
35
|
this.db = null;
|
|
@@ -98,53 +99,70 @@ export class SyncEngine extends EventEmitter {
|
|
|
98
99
|
let uploaded = 0;
|
|
99
100
|
let skipped = 0;
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
// Process files with concurrency limit
|
|
103
|
+
const CONCURRENCY = 4;
|
|
104
|
+
const queue = [...files];
|
|
105
|
+
const promises = [];
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
107
|
+
const worker = async () => {
|
|
108
|
+
while (queue.length > 0) {
|
|
109
|
+
const file = queue.shift();
|
|
110
|
+
const existing = stateMap.get(file.relativePath);
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
// Check if file has changed (by mtime)
|
|
113
|
+
if (existing && existing.mtime >= file.mtime) {
|
|
114
|
+
skipped++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
112
117
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
116
|
-
skipped++;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
118
|
+
// Calculate hash to detect actual changes
|
|
119
|
+
const hash = await hashFile(file.path);
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (existing && existing.file_hash === hash) {
|
|
122
|
+
// File unchanged, just update mtime
|
|
123
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
124
|
+
skipped++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
customName: file.relativePath, // Use relative path as name
|
|
128
|
-
config: this.config,
|
|
129
|
-
onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
|
|
130
|
-
});
|
|
128
|
+
// File is new or changed - upload it
|
|
129
|
+
try {
|
|
130
|
+
this.emit('file-upload-start', { file: file.relativePath });
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
await processFile(file.path, {
|
|
133
|
+
password: this.password,
|
|
134
|
+
dataDir: this.dataDir,
|
|
135
|
+
customName: file.relativePath, // Use relative path as name
|
|
136
|
+
config: this.config,
|
|
137
|
+
limitRate: this.limitRate ? Math.floor(this.limitRate / CONCURRENCY) : null,
|
|
138
|
+
onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
|
|
139
|
+
});
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
} catch (err) {
|
|
138
|
-
// File might already exist, skip
|
|
139
|
-
if (err.message.includes('duplicate')) {
|
|
141
|
+
// Update sync state
|
|
140
142
|
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.emit('file-upload-
|
|
143
|
+
uploaded++;
|
|
144
|
+
|
|
145
|
+
this.emit('file-upload-complete', { file: file.relativePath });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// File might already exist, skip
|
|
148
|
+
if (err.message.includes('duplicate')) {
|
|
149
|
+
this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
|
|
150
|
+
skipped++;
|
|
151
|
+
} else {
|
|
152
|
+
// Sleep briefly on non-duplicate error (potential rate limits)
|
|
153
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
154
|
+
this.emit('file-upload-error', { file: file.relativePath, error: err.message });
|
|
155
|
+
}
|
|
144
156
|
}
|
|
145
157
|
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < CONCURRENCY; i++) {
|
|
161
|
+
promises.push(worker());
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
await Promise.all(promises);
|
|
165
|
+
|
|
148
166
|
this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
|
|
149
167
|
|
|
150
168
|
return { uploaded, skipped };
|
|
@@ -206,6 +224,7 @@ export class SyncEngine extends EventEmitter {
|
|
|
206
224
|
dataDir: this.dataDir,
|
|
207
225
|
customName: filename,
|
|
208
226
|
config: this.config,
|
|
227
|
+
limitRate: this.limitRate,
|
|
209
228
|
onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
|
|
210
229
|
});
|
|
211
230
|
|
package/src/telegram/client.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import TelegramBot from 'node-telegram-bot-api';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { pipeline } from 'stream/promises';
|
|
9
10
|
|
|
10
11
|
export class TelegramClient {
|
|
11
12
|
constructor(dataDir) {
|
|
@@ -18,8 +19,12 @@ export class TelegramClient {
|
|
|
18
19
|
* Initialize with bot token
|
|
19
20
|
* Get token from @BotFather on Telegram
|
|
20
21
|
*/
|
|
21
|
-
async initialize(token) {
|
|
22
|
-
|
|
22
|
+
async initialize(token, customApiUrl = null) {
|
|
23
|
+
const options = { polling: false };
|
|
24
|
+
if (customApiUrl) {
|
|
25
|
+
options.baseApiUrl = customApiUrl;
|
|
26
|
+
}
|
|
27
|
+
this.bot = new TelegramBot(token, options);
|
|
23
28
|
|
|
24
29
|
// Verify the token works
|
|
25
30
|
try {
|
|
@@ -75,30 +80,43 @@ export class TelegramClient {
|
|
|
75
80
|
* Send a file to the storage chat
|
|
76
81
|
* Telegram supports up to 2GB for documents!
|
|
77
82
|
*/
|
|
78
|
-
async sendFile(filePath, caption = '') {
|
|
83
|
+
async sendFile(filePath, caption = '', options = {}) {
|
|
79
84
|
if (!this.chatId) {
|
|
80
85
|
throw new Error('Chat ID not set. Run init first.');
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
if (!fs.existsSync(filePath)) {
|
|
89
|
+
throw new Error(`File not found: ${filePath}`);
|
|
90
|
+
}
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
try {
|
|
93
|
+
let fileStream = fs.createReadStream(filePath);
|
|
94
|
+
const filename = path.basename(filePath);
|
|
95
|
+
|
|
96
|
+
if (options.limitRate) {
|
|
97
|
+
const { Throttle } = await import('../utils/throttle.js');
|
|
98
|
+
fileStream = fileStream.pipe(new Throttle(options.limitRate));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const message = await this.bot.sendDocument(this.chatId, fileStream, {
|
|
102
|
+
caption: caption
|
|
103
|
+
}, {
|
|
104
|
+
filename: filename,
|
|
105
|
+
contentType: 'application/octet-stream'
|
|
106
|
+
});
|
|
92
107
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
return {
|
|
109
|
+
messageId: message.message_id,
|
|
110
|
+
fileId: message.document.file_id,
|
|
111
|
+
timestamp: message.date
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new Error(`Failed to upload to Telegram: ${err.message}`);
|
|
115
|
+
}
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
/**
|
|
101
|
-
* Download a file from Telegram
|
|
119
|
+
* Download a file from Telegram (In-Memory buffer)
|
|
102
120
|
*/
|
|
103
121
|
async downloadFile(fileId) {
|
|
104
122
|
// Get file path from Telegram servers
|
|
@@ -116,6 +134,16 @@ export class TelegramClient {
|
|
|
116
134
|
return Buffer.concat(chunks);
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Efficiently download a file from Telegram straight to disk
|
|
139
|
+
*/
|
|
140
|
+
async downloadFileToPath(fileId, destPath) {
|
|
141
|
+
const fileStream = await this.bot.getFileStream(fileId);
|
|
142
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
143
|
+
await pipeline(fileStream, writeStream);
|
|
144
|
+
return destPath;
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
/**
|
|
120
148
|
* Delete a message (optional cleanup)
|
|
121
149
|
*/
|
package/src/utils/chunker.js
CHANGED
|
@@ -91,7 +91,17 @@ export function createHeader(filename, originalSize, chunkIndex, totalChunks, fl
|
|
|
91
91
|
offset += 2;
|
|
92
92
|
|
|
93
93
|
// Filename length
|
|
94
|
-
|
|
94
|
+
let filenameBytes = Buffer.from(filename, 'utf-8');
|
|
95
|
+
if (filenameBytes.length > 42) {
|
|
96
|
+
// We carefully truncate by chars instead of bytes to avoid splitting a UTF-8 character in half!
|
|
97
|
+
let truncated = filename;
|
|
98
|
+
while (Buffer.from(truncated, 'utf-8').length > 42) {
|
|
99
|
+
// Remove one character at a time from the end
|
|
100
|
+
truncated = truncated.slice(0, -1);
|
|
101
|
+
}
|
|
102
|
+
filenameBytes = Buffer.from(truncated, 'utf-8');
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
header.writeUInt16LE(filenameBytes.length, offset);
|
|
96
106
|
offset += 2;
|
|
97
107
|
|