@nightowne/tas-cli 2.3.0 → 2.4.1

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/src/index.js CHANGED
@@ -8,7 +8,7 @@ import path from 'path';
8
8
  import { pipeline } from 'stream/promises';
9
9
  import { Encryptor, hashFile } from './crypto/encryption.js';
10
10
  import { Compressor } from './utils/compression.js';
11
- import { createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
11
+ import { createHeader, HEADER_SIZE } from './utils/chunker.js';
12
12
  import { TelegramClient } from './telegram/client.js';
13
13
  import { FileIndex } from './db/index.js';
14
14
 
@@ -37,46 +37,47 @@ export async function processFile(filePath, options) {
37
37
  const db = new FileIndex(path.join(dataDir, 'index.db'));
38
38
  db.init();
39
39
 
40
- if (db.exists(hash)) {
41
- db.close();
42
- throw new Error('File already uploaded (duplicate hash)');
43
- }
44
-
45
- // Prepare processing components
46
- const compressor = new Compressor();
47
- const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
48
- const flags = compressed ? 1 : 0;
49
-
50
- const encryptor = new Encryptor(password);
51
- const encryptStream = encryptor.getEncryptStream();
52
-
53
- const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
54
- if (!fs.existsSync(tempDir)) {
55
- fs.mkdirSync(tempDir, { recursive: true });
56
- }
40
+ try {
41
+ if (db.exists(hash)) {
42
+ throw new Error('File already uploaded (duplicate hash)');
43
+ }
57
44
 
58
- // Connect to Telegram
59
- onProgress?.('Connecting to Telegram...');
60
- const client = new TelegramClient(dataDir);
61
- await client.initialize(config.botToken);
62
- client.setChatId(config.chatId);
45
+ // Prepare processing components
46
+ const compressor = new Compressor();
47
+ const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
48
+ const flags = compressed ? 1 : 0;
63
49
 
64
- // We will stream through a custom Writable chunker
65
- const { Writable } = await import('stream');
50
+ const encryptor = new Encryptor(password);
51
+ const encryptStream = encryptor.getEncryptStream();
66
52
 
67
- // First pass estimation (for calculating total chunks and progress)
68
- // We don't know the exact final size due to compression and encryption overhead,
69
- // so we'll estimate total chunks and update it if needed.
70
- // For small files < 49MB we assume 1 chunk.
71
- let estimatedSize = compressed ? originalSize : originalSize + 128; // Add encryption overhead
72
- if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
73
- let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
53
+ const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
54
+ if (!fs.existsSync(tempDir)) {
55
+ try {
56
+ fs.mkdirSync(tempDir, { recursive: true });
57
+ } catch (err) {
58
+ throw new Error(`Failed to create temporary directory at ${tempDir}: ${err.message}. Check permissions or set TAS_TMP_DIR to a writable location.`);
59
+ }
60
+ }
74
61
 
75
- // Register file in DB inside a transaction for atomicity
76
- db.db.exec('BEGIN');
77
- let fileId;
78
- try {
79
- fileId = db.addFile({
62
+ // Connect to Telegram
63
+ onProgress?.('Connecting to Telegram...');
64
+ const client = new TelegramClient(dataDir);
65
+ await client.initialize(config.botToken);
66
+ client.setChatId(config.chatId);
67
+
68
+ // We will stream through a custom Writable chunker
69
+ const { Writable } = await import('stream');
70
+
71
+ // First pass estimation (for calculating total chunks and progress)
72
+ // We don't know the exact final size due to compression and encryption overhead,
73
+ // so we'll estimate total chunks and update it if needed.
74
+ // For small files < 49MB we assume 1 chunk.
75
+ let estimatedSize = compressed ? originalSize : originalSize + 128; // Add encryption overhead
76
+ if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
77
+ let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
78
+
79
+ // Register file in DB
80
+ const fileId = db.addFile({
80
81
  filename,
81
82
  hash,
82
83
  originalSize,
@@ -84,129 +85,116 @@ export async function processFile(filePath, options) {
84
85
  chunks: estimatedChunks,
85
86
  compressed
86
87
  });
87
- } catch (err) {
88
- db.db.exec('ROLLBACK');
89
- db.close();
90
- throw err;
91
- }
92
88
 
93
- onProgress?.('Processing and uploading streams...');
94
- let uploadedBytes = 0;
95
- let chunkIndex = 0;
89
+ onProgress?.('Processing and uploading streams...');
90
+ let uploadedBytes = 0;
91
+ let chunkIndex = 0;
96
92
 
97
- let currentChunkBuffer = Buffer.alloc(0);
98
- let totalStoredSize = 0;
93
+ let currentChunkBuffer = Buffer.alloc(0);
94
+ let totalStoredSize = 0;
99
95
 
100
- // Helper to upload a single chunk
101
- const uploadCurrentChunk = async (isFinal = false) => {
102
- if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
103
- if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
96
+ // Helper to upload a single chunk
97
+ const uploadCurrentChunk = async (isFinal = false) => {
98
+ if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
99
+ if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
104
100
 
105
- // At this point we know if it's the final chunk, so we know the total chunks
106
- const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
101
+ // At this point we know if it's the final chunk, so we know the total chunks
102
+ const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
107
103
 
108
- const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
109
- const chunkData = Buffer.concat([header, currentChunkBuffer]);
104
+ const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
105
+ const chunkData = Buffer.concat([header, currentChunkBuffer]);
110
106
 
111
- const chunkFilename = totalChunks > 1
112
- ? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
113
- : `${hash.substring(0, 12)}.tas`;
107
+ const chunkFilename = totalChunks > 1
108
+ ? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
109
+ : `${hash.substring(0, 12)}.tas`;
114
110
 
115
- const chunkPath = path.join(tempDir, chunkFilename);
116
- fs.writeFileSync(chunkPath, chunkData);
111
+ const chunkPath = path.join(tempDir, chunkFilename);
112
+ fs.writeFileSync(chunkPath, chunkData);
117
113
 
118
- const caption = totalChunks > 1
119
- ? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
120
- : `📦 ${filename}`;
114
+ const caption = totalChunks > 1
115
+ ? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
116
+ : `📦 ${filename}`;
121
117
 
122
- onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
118
+ onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
123
119
 
124
- const result = await client.sendFile(chunkPath, caption);
120
+ const result = await client.sendFile(chunkPath, caption);
125
121
 
126
- uploadedBytes += chunkData.length;
127
- totalStoredSize += currentChunkBuffer.length;
122
+ uploadedBytes += chunkData.length;
123
+ totalStoredSize += currentChunkBuffer.length;
128
124
 
129
- onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
125
+ onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
130
126
 
131
- // Store file_id
132
- db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
133
- db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
134
- .run(result.fileId, fileId, chunkIndex);
127
+ // Store file_id
128
+ db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
129
+ db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
130
+ .run(result.fileId, fileId, chunkIndex);
135
131
 
136
- // Clean up temp file immediately to save disk space
137
- fs.unlinkSync(chunkPath);
132
+ // Clean up temp file immediately to save disk space
133
+ fs.unlinkSync(chunkPath);
138
134
 
139
- chunkIndex++;
140
- currentChunkBuffer = Buffer.alloc(0);
141
- };
135
+ chunkIndex++;
136
+ currentChunkBuffer = Buffer.alloc(0);
137
+ };
142
138
 
143
- const chunkingStream = new Writable({
144
- async write(chunk, encoding, callback) {
145
- currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
139
+ const chunkingStream = new Writable({
140
+ async write(chunk, encoding, callback) {
141
+ currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
146
142
 
147
- // If we exceeded the chunk limit, flush it
148
- if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
149
- const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
150
- currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
143
+ // If we exceeded the chunk limit, flush it
144
+ if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
145
+ const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
146
+ currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
151
147
 
148
+ try {
149
+ await uploadCurrentChunk(false);
150
+ currentChunkBuffer = overflow; // carry over
151
+ callback();
152
+ } catch (err) {
153
+ callback(err);
154
+ }
155
+ } else {
156
+ callback();
157
+ }
158
+ },
159
+ async final(callback) {
152
160
  try {
153
- await uploadCurrentChunk(false);
154
- currentChunkBuffer = overflow; // carry over
161
+ await uploadCurrentChunk(true);
155
162
  callback();
156
163
  } catch (err) {
157
164
  callback(err);
158
165
  }
159
- } else {
160
- callback();
161
166
  }
162
- },
163
- async final(callback) {
164
- try {
165
- await uploadCurrentChunk(true);
166
- callback();
167
- } catch (err) {
168
- callback(err);
169
- }
170
- }
171
- });
167
+ });
172
168
 
173
- try {
174
169
  const readStream = fs.createReadStream(filePath);
175
170
 
176
171
  // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
177
172
  await pipeline(readStream, compressStream, encryptStream, chunkingStream);
178
- } catch (pipelineErr) {
179
- // Pipeline failed — roll back the DB transaction so no orphaned rows remain
180
- try { db.db.exec('ROLLBACK'); } catch (e) { /* already rolled back */ }
181
- db.close();
182
- throw pipelineErr;
183
- }
184
173
 
185
- // Update the DB with the final accurate values
186
- db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
187
- .run(totalStoredSize, chunkIndex, fileId);
174
+ // Update the DB with the final accurate values
175
+ db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
176
+ .run(totalStoredSize, chunkIndex, fileId);
188
177
 
189
- // Commit the transaction — all DB rows are now permanent
190
- db.db.exec('COMMIT');
191
-
192
- db.close();
178
+ // Clean up temp dir (only if empty)
179
+ try {
180
+ const remaining = fs.readdirSync(tempDir);
181
+ if (remaining.length === 0) fs.rmdirSync(tempDir);
182
+ } catch (e) {
183
+ // Ignore cleanup errors
184
+ }
193
185
 
194
- // Clean up temp dir (only if empty)
195
- try {
196
- const remaining = fs.readdirSync(tempDir);
197
- if (remaining.length === 0) fs.rmdirSync(tempDir);
198
- } catch (e) {
199
- // Ignore cleanup errors
186
+ return {
187
+ filename,
188
+ hash,
189
+ originalSize,
190
+ storedSize: totalStoredSize,
191
+ chunks: chunkIndex,
192
+ compressed
193
+ };
194
+ } finally {
195
+ // Always close DB connection, even on error
196
+ db.close();
200
197
  }
201
-
202
- return {
203
- filename,
204
- hash,
205
- originalSize,
206
- storedSize: totalStoredSize,
207
- chunks: chunkIndex,
208
- compressed
209
- };
210
198
  }
211
199
 
212
200
  /**
@@ -228,86 +216,32 @@ export async function retrieveFile(fileRecord, options) {
228
216
  throw new Error('No chunk metadata found for this file');
229
217
  }
230
218
 
231
- // Prepare components
232
- const encryptor = new Encryptor(password);
233
- const decryptStream = encryptor.getDecryptStream();
234
-
235
- const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
236
- if (!fs.existsSync(tempDir)) {
237
- fs.mkdirSync(tempDir, { recursive: true });
238
- }
239
-
240
219
  // Connect to Telegram
241
220
  const client = new TelegramClient(dataDir);
242
221
  await client.initialize(config.botToken);
243
222
  client.setChatId(config.chatId);
244
223
 
245
- // Get total size from first chunk's header, or from DB
246
- const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
247
- const header = parseHeader(firstChunkData);
248
-
249
- // Total original uncompressed size
250
- let expectedOriginalSize = header.originalSize;
251
- let wasCompressed = header.compressed;
252
-
224
+ const encryptor = new Encryptor(password);
253
225
  const compressor = new Compressor();
254
- const decompressStream = compressor.getDecompressStream(wasCompressed);
255
-
256
- // We need a Readable stream that will lazily fetch chunks from Telegram
257
- // and push them into the decryption pipeline.
258
- const { Readable } = await import('stream');
259
-
260
- const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
261
- let downloadedBytes = 0;
262
-
263
- // Pre-sort chunks by index so we download them in correct order
264
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
265
226
 
266
- let currentChunkIndex = 0;
267
-
268
- // We already downloaded the first chunk to inspect its header, we shouldn't discard it.
269
- let preloadedFirstChunk = firstChunkData;
270
-
271
- const downloadStream = new Readable({
272
- async read() {
273
- try {
274
- if (currentChunkIndex >= chunks.length) {
275
- this.push(null); // End of stream
276
- return;
277
- }
227
+ const { createDownloadPipeline } = await import('./utils/download-stream.js');
278
228
 
279
- const chunk = chunks[currentChunkIndex];
280
- onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
281
-
282
- let data;
283
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
284
- data = preloadedFirstChunk;
285
- preloadedFirstChunk = null;
286
- } else {
287
- data = await client.downloadFile(chunk.file_telegram_id);
288
- }
289
-
290
- downloadedBytes += data.length;
291
- onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
292
-
293
- // Strip header before pushing
294
- const payload = data.subarray(HEADER_SIZE);
295
- this.push(payload);
296
-
297
- currentChunkIndex++;
298
- } catch (err) {
299
- this.destroy(err);
300
- }
229
+ const { readable } = await createDownloadPipeline({
230
+ client,
231
+ chunks,
232
+ encryptor,
233
+ compressor,
234
+ onChunkDownloaded({ chunkIndex, totalChunks, bytesDownloaded, totalBytes }) {
235
+ onProgress?.(`Downloading chunk ${chunkIndex + 1}/${totalChunks}...`);
236
+ onByteProgress?.({ downloaded: bytesDownloaded, total: totalBytes, chunk: chunkIndex + 1, totalChunks });
301
237
  }
302
238
  });
303
239
 
304
240
  const writeStream = fs.createWriteStream(outputPath);
305
- const { pipeline } = await import('stream/promises');
306
241
 
307
242
  onProgress?.('Decrypting, decompressing, and writing file...');
308
243
 
309
- // Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
310
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
244
+ await pipeline(readable, writeStream);
311
245
 
312
246
  const finalStats = fs.statSync(outputPath);
313
247
 
@@ -7,11 +7,12 @@
7
7
  import http from 'http';
8
8
  import crypto from 'crypto';
9
9
  import path from 'path';
10
+ import { pipeline } from 'stream/promises';
10
11
  import { FileIndex } from '../db/index.js';
11
12
  import { TelegramClient } from '../telegram/client.js';
12
13
  import { Encryptor } from '../crypto/encryption.js';
13
14
  import { Compressor } from '../utils/compression.js';
14
- import { parseHeader, HEADER_SIZE } from '../utils/chunker.js';
15
+ import { createDownloadPipeline } from '../utils/download-stream.js';
15
16
 
16
17
  /**
17
18
  * Generate a secure random share token
@@ -239,7 +240,7 @@ export class ShareServer {
239
240
  this.password = options.password;
240
241
  this.config = options.config;
241
242
  this.port = options.port || 3000;
242
- this.host = options.host || '0.0.0.0';
243
+ this.host = options.host || '127.0.0.1';
243
244
 
244
245
  this.db = null;
245
246
  this.client = null;
@@ -261,64 +262,19 @@ export class ShareServer {
261
262
  }
262
263
 
263
264
  /**
264
- * Prepare download streams from Telegram — fetches and verifies
265
- * the first chunk before returning, so the caller can decide
266
- * whether to commit to a 200 response.
267
- *
268
- * Returns: { downloadStream, decryptStream, decompressStream }
265
+ * Stream a decrypted file from Telegram directly to the HTTP response
269
266
  */
270
- async prepareDownloadStreams(fileRecord) {
267
+ async streamToResponse(fileRecord, res) {
271
268
  const chunks = this.db.getChunks(fileRecord.id);
272
- if (chunks.length === 0) throw new Error('No chunks found');
273
-
274
- // Pre-sort chunks by index so we download them in correct order
275
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
276
-
277
- // Fetch and verify the first chunk eagerly
278
- const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
279
- const header = parseHeader(firstChunkData);
280
- let wasCompressed = header.compressed;
281
-
282
- // Prepare streams
283
- const decryptStream = this.encryptor.getDecryptStream();
284
- const decompressStream = this.compressor.getDecompressStream(wasCompressed);
285
-
286
- const { Readable } = await import('stream');
287
-
288
- const self = this;
289
- let currentChunkIndex = 0;
290
- let preloadedFirstChunk = firstChunkData;
291
-
292
- const downloadStream = new Readable({
293
- async read() {
294
- try {
295
- if (currentChunkIndex >= chunks.length) {
296
- this.push(null); // End of stream
297
- return;
298
- }
299
-
300
- const chunk = chunks[currentChunkIndex];
301
- let data;
302
-
303
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
304
- data = preloadedFirstChunk;
305
- preloadedFirstChunk = null;
306
- } else {
307
- data = await self.client.downloadFile(chunk.file_telegram_id);
308
- }
309
-
310
- // Strip header before pushing
311
- const payload = data.subarray(HEADER_SIZE);
312
- this.push(payload);
313
-
314
- currentChunkIndex++;
315
- } catch (err) {
316
- this.destroy(err);
317
- }
318
- }
269
+
270
+ const { readable } = await createDownloadPipeline({
271
+ client: this.client,
272
+ chunks,
273
+ encryptor: this.encryptor,
274
+ compressor: this.compressor
319
275
  });
320
276
 
321
- return { downloadStream, decryptStream, decompressStream };
277
+ await pipeline(readable, res);
322
278
  }
323
279
 
324
280
  /**
@@ -401,31 +357,14 @@ export class ShareServer {
401
357
  const contentType = contentTypes[ext] || 'application/octet-stream';
402
358
  const safeName = sanitizeFilenameForHeader(fileRecord.filename);
403
359
 
404
- try {
405
- // Verify first chunk can be fetched and decrypted BEFORE sending 200 OK.
406
- // This prevents sending a truncated/corrupt file on Telegram or decrypt failure.
407
- const { downloadStream, decryptStream, decompressStream } = await this.prepareDownloadStreams(fileRecord);
408
-
409
- // First chunk verified — commit to the 200 response
410
- res.writeHead(200, {
411
- 'Content-Type': contentType,
412
- 'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
413
- 'Transfer-Encoding': 'chunked'
414
- });
360
+ res.writeHead(200, {
361
+ 'Content-Type': contentType,
362
+ 'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
363
+ 'Transfer-Encoding': 'chunked'
364
+ });
415
365
 
416
- const { pipeline } = await import('stream/promises');
417
- await pipeline(downloadStream, decryptStream, decompressStream, res);
418
- } catch (streamErr) {
419
- console.error('Share stream error:', streamErr.message);
420
- // Headers not yet sent — we can still return a proper error
421
- if (!res.headersSent) {
422
- res.writeHead(500, { 'Content-Type': 'text/plain' });
423
- res.end('Download failed — file could not be decrypted or fetched.');
424
- } else {
425
- // Headers already sent (shouldn't happen now, but safety net)
426
- res.end();
427
- }
428
- }
366
+ // Download the file from Telegram, decrypt, decompress and stream directly to 'res'
367
+ await this.streamToResponse(fileRecord, res);
429
368
 
430
369
  } catch (err) {
431
370
  console.error('Share server error:', err.message);