@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/src/index.js CHANGED
@@ -5,9 +5,10 @@
5
5
 
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
+ import { pipeline } from 'stream/promises';
8
9
  import { Encryptor, hashFile } from './crypto/encryption.js';
9
10
  import { Compressor } from './utils/compression.js';
10
- import { Chunker, createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
11
+ import { createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
11
12
  import { TelegramClient } from './telegram/client.js';
12
13
  import { FileIndex } from './db/index.js';
13
14
 
@@ -23,10 +24,10 @@ export async function processFile(filePath, options) {
23
24
 
24
25
  onProgress?.('Reading file...');
25
26
 
26
- // Read file
27
+ // Read file initially just to get size
27
28
  const filename = customName || path.basename(filePath);
28
- const fileData = fs.readFileSync(filePath);
29
- const originalSize = fileData.length;
29
+ const stats = fs.statSync(filePath);
30
+ const originalSize = stats.size;
30
31
 
31
32
  // Calculate hash
32
33
  onProgress?.('Calculating hash...');
@@ -41,115 +42,169 @@ export async function processFile(filePath, options) {
41
42
  throw new Error('File already uploaded (duplicate hash)');
42
43
  }
43
44
 
44
- // Compress
45
- onProgress?.('Compressing...');
45
+ // Prepare processing components
46
46
  const compressor = new Compressor();
47
- const { data: compressedData, compressed } = await compressor.compress(fileData, filename);
47
+ const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
48
+ const flags = compressed ? 1 : 0;
48
49
 
49
- // Encrypt
50
- onProgress?.('Encrypting...');
51
50
  const encryptor = new Encryptor(password);
52
- const encryptedData = encryptor.encrypt(compressedData);
53
-
54
- // Chunk if needed (Telegram bot limit ~50MB per file)
55
- onProgress?.('Preparing chunks...');
56
- const chunks = [];
57
- const numChunks = Math.ceil(encryptedData.length / TELEGRAM_CHUNK_SIZE);
58
-
59
- for (let i = 0; i < numChunks; i++) {
60
- const start = i * TELEGRAM_CHUNK_SIZE;
61
- const end = Math.min(start + TELEGRAM_CHUNK_SIZE, encryptedData.length);
62
- chunks.push({
63
- index: i,
64
- total: numChunks,
65
- data: encryptedData.subarray(start, end)
66
- });
67
- }
51
+ const encryptStream = encryptor.getEncryptStream();
68
52
 
69
- // Prepare files with headers
70
- const tempDir = path.join(dataDir, 'tmp');
53
+ const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
71
54
  if (!fs.existsSync(tempDir)) {
72
55
  fs.mkdirSync(tempDir, { recursive: true });
73
56
  }
74
57
 
75
- const chunkFiles = [];
76
- const flags = compressed ? 1 : 0;
77
-
78
- for (const chunk of chunks) {
79
- const header = createHeader(filename, originalSize, chunk.index, chunk.total, flags);
80
- const chunkData = Buffer.concat([header, chunk.data]);
81
-
82
- const chunkFilename = chunks.length > 1
83
- ? `${hash.substring(0, 12)}.part${chunk.index}.tas`
84
- : `${hash.substring(0, 12)}.tas`;
85
-
86
- const chunkPath = path.join(tempDir, chunkFilename);
87
- fs.writeFileSync(chunkPath, chunkData);
88
-
89
- chunkFiles.push({
90
- index: chunk.index,
91
- path: chunkPath,
92
- size: chunkData.length
93
- });
94
- }
95
-
96
58
  // Connect to Telegram
97
59
  onProgress?.('Connecting to Telegram...');
98
60
  const client = new TelegramClient(dataDir);
99
61
  await client.initialize(config.botToken);
100
62
  client.setChatId(config.chatId);
101
63
 
102
- // Upload chunks
103
- const fileId = db.addFile({
104
- filename,
105
- hash,
106
- originalSize,
107
- storedSize: encryptedData.length,
108
- chunks: chunks.length,
109
- compressed
110
- });
64
+ // We will stream through a custom Writable chunker
65
+ const { Writable } = await import('stream');
66
+
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;
74
+
75
+ // Register file in DB inside a transaction for atomicity
76
+ db.db.exec('BEGIN');
77
+ let fileId;
78
+ try {
79
+ fileId = db.addFile({
80
+ filename,
81
+ hash,
82
+ originalSize,
83
+ storedSize: 0, // Will update later
84
+ chunks: estimatedChunks,
85
+ compressed
86
+ });
87
+ } catch (err) {
88
+ db.db.exec('ROLLBACK');
89
+ db.close();
90
+ throw err;
91
+ }
111
92
 
93
+ onProgress?.('Processing and uploading streams...');
112
94
  let uploadedBytes = 0;
113
- const totalBytes = chunkFiles.reduce((acc, c) => acc + c.size, 0);
95
+ let chunkIndex = 0;
96
+
97
+ let currentChunkBuffer = Buffer.alloc(0);
98
+ let totalStoredSize = 0;
99
+
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
104
+
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);
114
107
 
115
- for (const chunk of chunkFiles) {
116
- onProgress?.(`Uploading chunk ${chunk.index + 1}/${chunkFiles.length}...`);
108
+ const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
109
+ const chunkData = Buffer.concat([header, currentChunkBuffer]);
117
110
 
118
- const caption = chunks.length > 1
119
- ? `📦 ${filename} (${chunk.index + 1}/${chunks.length})`
111
+ const chunkFilename = totalChunks > 1
112
+ ? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
113
+ : `${hash.substring(0, 12)}.tas`;
114
+
115
+ const chunkPath = path.join(tempDir, chunkFilename);
116
+ fs.writeFileSync(chunkPath, chunkData);
117
+
118
+ const caption = totalChunks > 1
119
+ ? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
120
120
  : `📦 ${filename}`;
121
121
 
122
- const result = await client.sendFile(chunk.path, caption);
122
+ onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
123
+
124
+ const result = await client.sendFile(chunkPath, caption);
123
125
 
124
- uploadedBytes += chunk.size;
125
- onByteProgress?.({ uploaded: uploadedBytes, total: totalBytes, chunk: chunk.index + 1, totalChunks: chunkFiles.length });
126
+ uploadedBytes += chunkData.length;
127
+ totalStoredSize += currentChunkBuffer.length;
126
128
 
127
- // Store file_id instead of message_id for downloads
128
- db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
129
+ onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
129
130
 
130
- // Also store file_id for easier retrieval
131
+ // Store file_id
132
+ db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
131
133
  db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
132
- .run(result.fileId, fileId, chunk.index);
134
+ .run(result.fileId, fileId, chunkIndex);
135
+
136
+ // Clean up temp file immediately to save disk space
137
+ fs.unlinkSync(chunkPath);
138
+
139
+ chunkIndex++;
140
+ currentChunkBuffer = Buffer.alloc(0);
141
+ };
142
+
143
+ const chunkingStream = new Writable({
144
+ async write(chunk, encoding, callback) {
145
+ currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
146
+
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);
151
+
152
+ try {
153
+ await uploadCurrentChunk(false);
154
+ currentChunkBuffer = overflow; // carry over
155
+ callback();
156
+ } catch (err) {
157
+ callback(err);
158
+ }
159
+ } else {
160
+ callback();
161
+ }
162
+ },
163
+ async final(callback) {
164
+ try {
165
+ await uploadCurrentChunk(true);
166
+ callback();
167
+ } catch (err) {
168
+ callback(err);
169
+ }
170
+ }
171
+ });
172
+
173
+ try {
174
+ const readStream = fs.createReadStream(filePath);
133
175
 
134
- // Clean up temp file
135
- fs.unlinkSync(chunk.path);
176
+ // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
177
+ 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;
136
183
  }
137
184
 
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);
188
+
189
+ // Commit the transaction — all DB rows are now permanent
190
+ db.db.exec('COMMIT');
191
+
138
192
  db.close();
139
193
 
140
- // Clean up temp dir
194
+ // Clean up temp dir (only if empty)
141
195
  try {
142
- fs.rmdirSync(tempDir);
196
+ const remaining = fs.readdirSync(tempDir);
197
+ if (remaining.length === 0) fs.rmdirSync(tempDir);
143
198
  } catch (e) {
144
- // Ignore if not empty
199
+ // Ignore cleanup errors
145
200
  }
146
201
 
147
202
  return {
148
203
  filename,
149
204
  hash,
150
205
  originalSize,
151
- storedSize: encryptedData.length,
152
- chunks: chunks.length,
206
+ storedSize: totalStoredSize,
207
+ chunks: chunkIndex,
153
208
  compressed
154
209
  };
155
210
  }
@@ -173,57 +228,99 @@ export async function retrieveFile(fileRecord, options) {
173
228
  throw new Error('No chunk metadata found for this file');
174
229
  }
175
230
 
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
+
176
240
  // Connect to Telegram
177
241
  const client = new TelegramClient(dataDir);
178
242
  await client.initialize(config.botToken);
179
243
  client.setChatId(config.chatId);
180
244
 
181
- // Download all chunks
182
- const downloadedChunks = [];
183
- let downloadedBytes = 0;
184
- const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
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);
185
248
 
186
- for (const chunk of chunks) {
187
- onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
249
+ // Total original uncompressed size
250
+ let expectedOriginalSize = header.originalSize;
251
+ let wasCompressed = header.compressed;
188
252
 
189
- const data = await client.downloadFile(chunk.file_telegram_id);
190
- downloadedBytes += data.length;
191
- onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
253
+ const compressor = new Compressor();
254
+ const decompressStream = compressor.getDecompressStream(wasCompressed);
192
255
 
193
- // Parse header
194
- const header = parseHeader(data);
195
- const payload = data.subarray(HEADER_SIZE);
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');
196
259
 
197
- downloadedChunks.push({
198
- index: header.chunkIndex,
199
- total: header.totalChunks,
200
- data: payload,
201
- compressed: header.compressed
202
- });
203
- }
260
+ const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
261
+ let downloadedBytes = 0;
204
262
 
205
- // Reassemble
206
- onProgress?.('Reassembling...');
207
- downloadedChunks.sort((a, b) => a.index - b.index);
208
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
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
+
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
+ }
278
+
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
+ }
301
+ }
302
+ });
209
303
 
210
- // Decrypt
211
- onProgress?.('Decrypting...');
212
- const encryptor = new Encryptor(password);
213
- const compressedData = encryptor.decrypt(encryptedData);
304
+ const writeStream = fs.createWriteStream(outputPath);
305
+ const { pipeline } = await import('stream/promises');
214
306
 
215
- // Decompress
216
- onProgress?.('Decompressing...');
217
- const compressor = new Compressor();
218
- const wasCompressed = downloadedChunks[0].compressed;
219
- const originalData = await compressor.decompress(compressedData, wasCompressed);
307
+ onProgress?.('Decrypting, decompressing, and writing file...');
220
308
 
221
- // Write to output
222
- onProgress?.('Writing file...');
223
- fs.writeFileSync(outputPath, originalData);
309
+ // Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
310
+ await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
311
+
312
+ const finalStats = fs.statSync(outputPath);
313
+
314
+ // Verify file integrity by comparing hash
315
+ onProgress?.('Verifying file integrity...');
316
+ const downloadedHash = await hashFile(outputPath);
317
+ if (fileRecord.hash && downloadedHash !== fileRecord.hash) {
318
+ throw new Error(`Integrity check failed: expected hash ${fileRecord.hash.substring(0, 12)}..., got ${downloadedHash.substring(0, 12)}...`);
319
+ }
224
320
 
225
321
  return {
226
322
  path: outputPath,
227
- size: originalData.length
323
+ size: finalStats.size,
324
+ verified: true
228
325
  };
229
326
  }
@@ -57,6 +57,25 @@ function formatTimeLeft(expiresAt) {
57
57
  return `${minutes}m`;
58
58
  }
59
59
 
60
+ /**
61
+ * Escape HTML special characters to prevent XSS
62
+ */
63
+ function escapeHtml(str) {
64
+ return String(str)
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;')
69
+ .replace(/'/g, '&#x27;');
70
+ }
71
+
72
+ /**
73
+ * Sanitize filename for Content-Disposition header (RFC 6266)
74
+ */
75
+ function sanitizeFilenameForHeader(filename) {
76
+ return filename.replace(/["\\\r\n]/g, '_');
77
+ }
78
+
60
79
  /**
61
80
  * Generate the download HTML page
62
81
  */
@@ -69,6 +88,7 @@ function generateDownloadPage(share, fileRecord) {
69
88
  : fileSize > 1024
70
89
  ? `${(fileSize / 1024).toFixed(1)} KB`
71
90
  : `${fileSize} B`;
91
+ const safeFilename = escapeHtml(fileRecord.filename);
72
92
 
73
93
  return `<!DOCTYPE html>
74
94
  <html lang="en">
@@ -146,7 +166,7 @@ function generateDownloadPage(share, fileRecord) {
146
166
  <div class="card">
147
167
  <div class="icon">🔐</div>
148
168
  <h1>Secure File Share</h1>
149
- <div class="filename">${fileRecord.filename}</div>
169
+ <div class="filename">${safeFilename}</div>
150
170
  <div class="meta">
151
171
  <span>📦 ${sizeStr}</span>
152
172
  <span>⏳ ${timeLeft}</span>
@@ -241,33 +261,64 @@ export class ShareServer {
241
261
  }
242
262
 
243
263
  /**
244
- * Download and decrypt a file from Telegram
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 }
245
269
  */
246
- async downloadAndDecrypt(fileRecord) {
270
+ async prepareDownloadStreams(fileRecord) {
247
271
  const chunks = this.db.getChunks(fileRecord.id);
248
272
  if (chunks.length === 0) throw new Error('No chunks found');
249
273
 
250
- const downloadedChunks = [];
251
-
252
- for (const chunk of chunks) {
253
- const data = await this.client.downloadFile(chunk.file_telegram_id);
254
- const header = parseHeader(data);
255
- const payload = data.subarray(HEADER_SIZE);
256
-
257
- downloadedChunks.push({
258
- index: header.chunkIndex,
259
- data: payload,
260
- compressed: header.compressed
261
- });
262
- }
263
-
264
- downloadedChunks.sort((a, b) => a.index - b.index);
265
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
266
-
267
- const compressedData = this.encryptor.decrypt(encryptedData);
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
+ }
319
+ });
268
320
 
269
- const wasCompressed = downloadedChunks[0].compressed;
270
- return await this.compressor.decompress(compressedData, wasCompressed);
321
+ return { downloadStream, decryptStream, decompressStream };
271
322
  }
272
323
 
273
324
  /**
@@ -330,9 +381,6 @@ export class ShareServer {
330
381
  return;
331
382
  }
332
383
 
333
- // Download the file from Telegram, decrypt, and serve
334
- const data = await this.downloadAndDecrypt(fileRecord);
335
-
336
384
  // Increment download count
337
385
  this.db.incrementShareDownload(token);
338
386
 
@@ -351,13 +399,33 @@ export class ShareServer {
351
399
  '.mp3': 'audio/mpeg'
352
400
  };
353
401
  const contentType = contentTypes[ext] || 'application/octet-stream';
402
+ const safeName = sanitizeFilenameForHeader(fileRecord.filename);
403
+
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
+ });
354
415
 
355
- res.writeHead(200, {
356
- 'Content-Type': contentType,
357
- 'Content-Disposition': `attachment; filename="${fileRecord.filename}"`,
358
- 'Content-Length': data.length
359
- });
360
- res.end(data);
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
+ }
361
429
 
362
430
  } catch (err) {
363
431
  console.error('Share server error:', err.message);