@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/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import { Compressor } from './utils/compression.js';
15
15
  import { FileIndex } from './db/index.js';
16
16
  import { processFile, retrieveFile } from './index.js';
17
17
  import { printBanner, LOGO, TAGLINE, VERSION } from './utils/branding.js';
18
+ import { getPassword, verifyPassword, loadConfig, requireConfig, getAndVerifyPassword } from './utils/cli-helpers.js';
18
19
  import fs from 'fs';
19
20
  import path from 'path';
20
21
  import { fileURLToPath } from 'url';
@@ -140,6 +141,7 @@ program
140
141
  .command('push <file>')
141
142
  .description('Upload a file to Telegram storage')
142
143
  .option('-n, --name <name>', 'Custom name for the file')
144
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
143
145
  .action(async (file, options) => {
144
146
  const spinner = ora('Preparing...').start();
145
147
 
@@ -150,32 +152,11 @@ program
150
152
  process.exit(1);
151
153
  }
152
154
 
153
- // Load config
154
- const configPath = path.join(DATA_DIR, 'config.json');
155
- if (!fs.existsSync(configPath)) {
156
- spinner.fail('TAS not initialized. Run `tas init` first.');
157
- process.exit(1);
158
- }
159
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
160
-
155
+ const config = requireConfig(DATA_DIR);
161
156
  spinner.stop();
162
157
 
163
- // Get password
164
- const { password } = await inquirer.prompt([
165
- {
166
- type: 'password',
167
- name: 'password',
168
- message: 'Enter your encryption password:',
169
- mask: '*'
170
- }
171
- ]);
172
-
173
- // Verify password
174
- const encryptor = new Encryptor(password);
175
- if (encryptor.getPasswordHash() !== config.passwordHash) {
176
- console.log(chalk.red('✗ Incorrect password'));
177
- process.exit(1);
178
- }
158
+ // Get and verify password
159
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
179
160
 
180
161
  spinner.start('Processing file...');
181
162
 
@@ -221,17 +202,12 @@ program
221
202
  .command('pull <identifier> [output]')
222
203
  .description('Download a file from Telegram storage (by filename or hash)')
223
204
  .option('-o, --output <path>', 'Output path for the file')
205
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
224
206
  .action(async (identifier, output, options) => {
225
207
  const spinner = ora('Looking up file...').start();
226
208
 
227
209
  try {
228
- // Load config
229
- const configPath = path.join(DATA_DIR, 'config.json');
230
- if (!fs.existsSync(configPath)) {
231
- spinner.fail('TAS not initialized. Run `tas init` first.');
232
- process.exit(1);
233
- }
234
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
210
+ const config = requireConfig(DATA_DIR);
235
211
 
236
212
  // Find file in index
237
213
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -245,22 +221,8 @@ program
245
221
 
246
222
  spinner.stop();
247
223
 
248
- // Get password
249
- const { password } = await inquirer.prompt([
250
- {
251
- type: 'password',
252
- name: 'password',
253
- message: 'Enter your encryption password:',
254
- mask: '*'
255
- }
256
- ]);
257
-
258
- // Verify password
259
- const encryptor = new Encryptor(password);
260
- if (encryptor.getPasswordHash() !== config.passwordHash) {
261
- console.log(chalk.red('✗ Incorrect password'));
262
- process.exit(1);
263
- }
224
+ // Get and verify password
225
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
264
226
 
265
227
  spinner.start('Downloading...');
266
228
 
@@ -429,33 +391,12 @@ program
429
391
  program
430
392
  .command('mount <mountpoint>')
431
393
  .description('🔥 Mount Telegram storage as a local folder (FUSE)')
432
- .action(async (mountpoint) => {
394
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
395
+ .action(async (mountpoint, options) => {
433
396
  console.log(chalk.cyan('\n🗂️ Mounting Telegram as filesystem...\n'));
434
397
 
435
- // Load config
436
- const configPath = path.join(DATA_DIR, 'config.json');
437
- if (!fs.existsSync(configPath)) {
438
- console.log(chalk.red('✗ TAS not initialized. Run `tas init` first.'));
439
- process.exit(1);
440
- }
441
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
442
-
443
- // Get password
444
- const { password } = await inquirer.prompt([
445
- {
446
- type: 'password',
447
- name: 'password',
448
- message: 'Enter your encryption password:',
449
- mask: '*'
450
- }
451
- ]);
452
-
453
- // Verify password
454
- const encryptor = new Encryptor(password);
455
- if (encryptor.getPasswordHash() !== config.passwordHash) {
456
- console.log(chalk.red('✗ Incorrect password'));
457
- process.exit(1);
458
- }
398
+ const config = requireConfig(DATA_DIR);
399
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
459
400
 
460
401
  const spinner = ora('Initializing filesystem...').start();
461
402
 
@@ -732,32 +673,29 @@ syncCmd
732
673
  syncCmd
733
674
  .command('start')
734
675
  .description('Start syncing all registered folders')
735
- .action(async () => {
676
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
677
+ .option('-l, --limit <limit>', 'Bandwidth limit (e.g. 500k, 1m)')
678
+ .action(async (options) => {
736
679
  console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
737
680
 
738
- // Load config
739
- const configPath = path.join(DATA_DIR, 'config.json');
740
- if (!fs.existsSync(configPath)) {
741
- console.log(chalk.red('✗ TAS not initialized. Run `tas init` first.'));
742
- process.exit(1);
743
- }
744
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
681
+ const config = requireConfig(DATA_DIR);
682
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
745
683
 
746
- // Get password
747
- const { password } = await inquirer.prompt([
748
- {
749
- type: 'password',
750
- name: 'password',
751
- message: 'Enter your encryption password:',
752
- mask: '*'
684
+ let limitRate = null;
685
+ if (options.limit) {
686
+ const match = options.limit.match(/^(\d+)([kmg]?)$/i);
687
+ if (!match) {
688
+ console.error(chalk.red('Invalid limit format. Use e.g. 500{}, 1m'));
689
+ process.exit(1);
753
690
  }
754
- ]);
755
-
756
- // Verify password
757
- const encryptor = new Encryptor(password);
758
- if (encryptor.getPasswordHash() !== config.passwordHash) {
759
- console.log(chalk.red('✗ Incorrect password'));
760
- process.exit(1);
691
+ const val = parseInt(match[1]);
692
+ const unit = match[2].toLowerCase();
693
+ if (unit === 'k') limitRate = val * 1024;
694
+ else if (unit === 'm') limitRate = val * 1024 * 1024;
695
+ else if (unit === 'g') limitRate = val * 1024 * 1024 * 1024;
696
+ else limitRate = val;
697
+
698
+ console.log(chalk.dim(` Bandwidth limit: ${options.limit}/s`));
761
699
  }
762
700
 
763
701
  try {
@@ -766,7 +704,8 @@ syncCmd
766
704
  const syncEngine = new SyncEngine({
767
705
  dataDir: DATA_DIR,
768
706
  password,
769
- config
707
+ config,
708
+ limitRate
770
709
  });
771
710
 
772
711
  await syncEngine.initialize();
@@ -825,33 +764,12 @@ syncCmd
825
764
  syncCmd
826
765
  .command('pull')
827
766
  .description('Download all Telegram files to sync folders (two-way sync)')
828
- .action(async () => {
767
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
768
+ .action(async (options) => {
829
769
  console.log(chalk.cyan('\n📥 Pulling files from Telegram...\n'));
830
770
 
831
- // Load config
832
- const configPath = path.join(DATA_DIR, 'config.json');
833
- if (!fs.existsSync(configPath)) {
834
- console.log(chalk.red('✗ TAS not initialized. Run `tas init` first.'));
835
- process.exit(1);
836
- }
837
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
838
-
839
- // Get password
840
- const { password } = await inquirer.prompt([
841
- {
842
- type: 'password',
843
- name: 'password',
844
- message: 'Enter your encryption password:',
845
- mask: '*'
846
- }
847
- ]);
848
-
849
- // Verify password
850
- const encryptor = new Encryptor(password);
851
- if (encryptor.getPasswordHash() !== config.passwordHash) {
852
- console.log(chalk.red('✗ Incorrect password'));
853
- process.exit(1);
854
- }
771
+ const config = requireConfig(DATA_DIR);
772
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
855
773
 
856
774
  const spinner = ora('Loading...').start();
857
775
 
@@ -939,13 +857,7 @@ program
939
857
  .action(async () => {
940
858
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
941
859
 
942
- // Load config
943
- const configPath = path.join(DATA_DIR, 'config.json');
944
- if (!fs.existsSync(configPath)) {
945
- console.log(chalk.red('✗ TAS not initialized. Run `tas init` first.'));
946
- process.exit(1);
947
- }
948
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
860
+ const config = requireConfig(DATA_DIR);
949
861
 
950
862
  const spinner = ora('Checking files...').start();
951
863
 
@@ -1078,7 +990,8 @@ program
1078
990
  program
1079
991
  .command('resume')
1080
992
  .description('Resume interrupted uploads')
1081
- .action(async () => {
993
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
994
+ .action(async (options) => {
1082
995
  try {
1083
996
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1084
997
  db.init();
@@ -1139,30 +1052,15 @@ program
1139
1052
  }
1140
1053
 
1141
1054
  // Resume uploads
1142
- const configPath = path.join(DATA_DIR, 'config.json');
1143
- if (!fs.existsSync(configPath)) {
1055
+ const config = loadConfig(DATA_DIR);
1056
+ if (!config) {
1144
1057
  console.log(chalk.red('✗ TAS not initialized.'));
1145
1058
  db.close();
1146
1059
  return;
1147
1060
  }
1148
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1149
-
1150
- // Get password
1151
- const { password } = await inquirer.prompt([
1152
- {
1153
- type: 'password',
1154
- name: 'password',
1155
- message: 'Enter your encryption password:',
1156
- mask: '*'
1157
- }
1158
- ]);
1159
1061
 
1160
- const encryptor = new Encryptor(password);
1161
- if (encryptor.getPasswordHash() !== config.passwordHash) {
1162
- console.log(chalk.red('✗ Incorrect password'));
1163
- db.close();
1164
- return;
1165
- }
1062
+ // Get and verify password
1063
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1166
1064
 
1167
1065
  // Connect to Telegram
1168
1066
  const { TelegramClient } = await import('./telegram/client.js');
@@ -1234,6 +1132,173 @@ program
1234
1132
  }
1235
1133
  });
1236
1134
 
1135
+ // ============== SHARE COMMAND ==============
1136
+ const shareCmd = program
1137
+ .command('share')
1138
+ .description('🔗 Share files via temporary download links');
1139
+
1140
+ shareCmd
1141
+ .command('create <file>')
1142
+ .description('Create a temporary download link for a file')
1143
+ .option('-e, --expire <duration>', 'Expiry duration (e.g. 1h, 24h, 7d)', '24h')
1144
+ .option('-m, --max-downloads <n>', 'Maximum number of downloads', '1')
1145
+ .option('--port <port>', 'HTTP server port', '3000')
1146
+ .option('-p, --password <password>', 'Encryption password')
1147
+ .action(async (file, options) => {
1148
+ console.log(chalk.cyan('\n🔗 Creating share link...\n'));
1149
+
1150
+ const config = requireConfig(DATA_DIR);
1151
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1152
+
1153
+ const spinner = ora('Setting up...').start();
1154
+
1155
+ try {
1156
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1157
+ db.init();
1158
+
1159
+ const fileRecord = db.findByHash(file) || db.findByName(file);
1160
+ if (!fileRecord) {
1161
+ spinner.fail(`File not found: ${file}`);
1162
+ process.exit(1);
1163
+ }
1164
+
1165
+ // Generate token and calculate expiry
1166
+ const { generateToken, parseDuration } = await import('./share/server.js');
1167
+ const token = generateToken();
1168
+ const expiresAt = new Date(Date.now() + parseDuration(options.expire)).toISOString();
1169
+ const maxDownloads = parseInt(options.maxDownloads) || 1;
1170
+
1171
+ // Add share to DB
1172
+ db.addShare(fileRecord.id, token, expiresAt, maxDownloads);
1173
+
1174
+ // Start share server
1175
+ const { ShareServer } = await import('./share/server.js');
1176
+ const port = parseInt(options.port) || 3000;
1177
+
1178
+ const server = new ShareServer({
1179
+ dataDir: DATA_DIR,
1180
+ password,
1181
+ config,
1182
+ port
1183
+ });
1184
+
1185
+ await server.initialize();
1186
+ await server.start();
1187
+
1188
+ spinner.succeed('Share server running!');
1189
+
1190
+ // Get local IP for network sharing
1191
+ const { networkInterfaces } = await import('os');
1192
+ const nets = networkInterfaces();
1193
+ let localIP = 'localhost';
1194
+ for (const name of Object.keys(nets)) {
1195
+ for (const net of nets[name]) {
1196
+ if (net.family === 'IPv4' && !net.internal) {
1197
+ localIP = net.address;
1198
+ break;
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ console.log(chalk.cyan('\n📎 Share Links:\n'));
1204
+ console.log(` ${chalk.white('Local:')} ${chalk.green(`http://localhost:${port}/d/${token}`)}`);
1205
+ console.log(` ${chalk.white('Network:')} ${chalk.green(`http://${localIP}:${port}/d/${token}`)}`);
1206
+ console.log();
1207
+ console.log(chalk.dim(` File: ${fileRecord.filename}`));
1208
+ console.log(chalk.dim(` Expires: ${options.expire}`));
1209
+ console.log(chalk.dim(` Downloads: ${maxDownloads} max`));
1210
+ console.log(chalk.dim(` Token: ${token.substring(0, 8)}...`));
1211
+ console.log();
1212
+ console.log(chalk.yellow('Press Ctrl+C to stop the share server'));
1213
+
1214
+ // Handle graceful shutdown
1215
+ const cleanup = async () => {
1216
+ console.log(chalk.dim('\n\nStopping share server...'));
1217
+ await server.stop();
1218
+ console.log(chalk.green('✓ Share server stopped'));
1219
+ process.exit(0);
1220
+ };
1221
+
1222
+ process.on('SIGINT', cleanup);
1223
+ process.on('SIGTERM', cleanup);
1224
+
1225
+ // Keep process running
1226
+ await new Promise(() => { });
1227
+
1228
+ } catch (err) {
1229
+ spinner.fail(`Share failed: ${err.message}`);
1230
+ process.exit(1);
1231
+ }
1232
+ });
1233
+
1234
+ shareCmd
1235
+ .command('list')
1236
+ .description('List all active share links')
1237
+ .action(async () => {
1238
+ try {
1239
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1240
+ db.init();
1241
+
1242
+ // Clean expired first
1243
+ const cleaned = db.cleanExpiredShares();
1244
+ if (cleaned > 0) {
1245
+ console.log(chalk.dim(` (${cleaned} expired shares cleaned)`));
1246
+ }
1247
+
1248
+ const shares = db.listShares();
1249
+
1250
+ if (shares.length === 0) {
1251
+ console.log(chalk.yellow('\n📭 No active shares. Use `tas share create <file>` to create one.\n'));
1252
+ } else {
1253
+ console.log(chalk.cyan(`\n🔗 Active Shares (${shares.length})\n`));
1254
+
1255
+ for (const share of shares) {
1256
+ const expired = new Date(share.expires_at) < new Date();
1257
+ const status = expired
1258
+ ? chalk.red('expired')
1259
+ : chalk.green('active');
1260
+
1261
+ console.log(` ${chalk.blue('●')} ${share.filename}`);
1262
+ console.log(chalk.dim(` Token: ${share.token.substring(0, 8)}... Status: ${status} Downloads: ${share.download_count}/${share.max_downloads}`));
1263
+ console.log(chalk.dim(` Expires: ${new Date(share.expires_at).toLocaleString()}`));
1264
+ }
1265
+ console.log();
1266
+ }
1267
+
1268
+ db.close();
1269
+ } catch (err) {
1270
+ console.error(chalk.red('Error:'), err.message);
1271
+ process.exit(1);
1272
+ }
1273
+ });
1274
+
1275
+ shareCmd
1276
+ .command('revoke <token>')
1277
+ .description('Revoke a share link')
1278
+ .action(async (token) => {
1279
+ try {
1280
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1281
+ db.init();
1282
+
1283
+ // Support partial token match
1284
+ const shares = db.listShares();
1285
+ const match = shares.find(s => s.token === token || s.token.startsWith(token));
1286
+
1287
+ if (!match) {
1288
+ console.log(chalk.red(`✗ Share not found: ${token}`));
1289
+ process.exit(1);
1290
+ }
1291
+
1292
+ db.revokeShare(match.token);
1293
+ console.log(chalk.green(`✓ Revoked share for "${match.filename}"`));
1294
+
1295
+ db.close();
1296
+ } catch (err) {
1297
+ console.error(chalk.red('Error:'), err.message);
1298
+ process.exit(1);
1299
+ }
1300
+ });
1301
+
1237
1302
  program.parse();
1238
1303
 
1239
1304
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import crypto from 'crypto';
6
+ import { Transform } from 'stream';
6
7
 
7
8
  const ALGORITHM = 'aes-256-gcm';
8
9
  const KEY_LENGTH = 32; // 256 bits
@@ -66,6 +67,41 @@ export class Encryptor {
66
67
  return Buffer.concat([salt, iv, encrypted, authTag]);
67
68
  }
68
69
 
70
+ /**
71
+ * Get an encryption transform stream
72
+ * Needs to append the salt/iv to the stream begin, and authTag to the stream end
73
+ */
74
+ getEncryptStream() {
75
+ const salt = crypto.randomBytes(SALT_LENGTH);
76
+ const iv = crypto.randomBytes(IV_LENGTH);
77
+ const key = this.deriveKey(salt);
78
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
79
+
80
+ let headerWritten = false;
81
+
82
+ return new Transform({
83
+ transform(chunk, encoding, callback) {
84
+ if (!headerWritten) {
85
+ this.push(Buffer.concat([salt, iv]));
86
+ headerWritten = true;
87
+ }
88
+ const encrypted = cipher.update(chunk);
89
+ if (encrypted.length > 0) {
90
+ this.push(encrypted);
91
+ }
92
+ callback();
93
+ },
94
+ flush(callback) {
95
+ const final = cipher.final();
96
+ if (final.length > 0) {
97
+ this.push(final);
98
+ }
99
+ this.push(cipher.getAuthTag());
100
+ callback();
101
+ }
102
+ });
103
+ }
104
+
69
105
  /**
70
106
  * Decrypt data
71
107
  * Input: Buffer containing [salt (32) | iv (12) | ciphertext | authTag (16)]
@@ -90,6 +126,98 @@ export class Encryptor {
90
126
  decipher.final()
91
127
  ]);
92
128
  }
129
+
130
+ /**
131
+ * Get a decryption transform stream
132
+ * Expects [salt (32) | iv (12) | ciphertext | authTag (16)]
133
+ */
134
+ getDecryptStream() {
135
+ let salt = null;
136
+ let iv = null;
137
+ let authTag = null;
138
+ let key = null;
139
+ let decipher = null;
140
+
141
+ // Buffer for storing the salt and iv during the first few chunks
142
+ let headerBuffer = Buffer.alloc(0);
143
+ let headerRead = false;
144
+
145
+ // We must buffer the last 16 bytes across chunks because it's the authTag
146
+ let tailBuffer = Buffer.alloc(0);
147
+
148
+ const self = this;
149
+
150
+ return new Transform({
151
+ transform(chunk, encoding, callback) {
152
+ try {
153
+ // 1. Read the header (salt + iv)
154
+ if (!headerRead) {
155
+ headerBuffer = Buffer.concat([headerBuffer, chunk]);
156
+
157
+ if (headerBuffer.length >= SALT_LENGTH + IV_LENGTH) {
158
+ salt = headerBuffer.subarray(0, SALT_LENGTH);
159
+ iv = headerBuffer.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
160
+ key = self.deriveKey(salt);
161
+ decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
162
+
163
+ // The rest of the header buffer is ciphertext
164
+ const remaining = headerBuffer.subarray(SALT_LENGTH + IV_LENGTH);
165
+ headerBuffer = null; // free memory
166
+ headerRead = true;
167
+
168
+ // Push remaining into tailBuffer for processing
169
+ if (remaining.length > 0) {
170
+ tailBuffer = Buffer.concat([tailBuffer, remaining]);
171
+ }
172
+ }
173
+ } else {
174
+ tailBuffer = Buffer.concat([tailBuffer, chunk]);
175
+ }
176
+
177
+ // 2. Process ciphertext, keeping exactly TAG_LENGTH bytes in tailBuffer
178
+ if (headerRead && tailBuffer.length > TAG_LENGTH) {
179
+ const processLength = tailBuffer.length - TAG_LENGTH;
180
+ const toProcess = tailBuffer.subarray(0, processLength);
181
+
182
+ const decrypted = decipher.update(toProcess);
183
+ if (decrypted.length > 0) {
184
+ this.push(decrypted);
185
+ }
186
+
187
+ // Keep only the end
188
+ tailBuffer = tailBuffer.subarray(processLength);
189
+ }
190
+
191
+ callback();
192
+ } catch (err) {
193
+ callback(err);
194
+ }
195
+ },
196
+ flush(callback) {
197
+ try {
198
+ if (!headerRead) {
199
+ return callback(new Error('Invalid encrypted data stream: too short'));
200
+ }
201
+
202
+ if (tailBuffer.length !== TAG_LENGTH) {
203
+ return callback(new Error(`Invalid encrypted data stream: missing auth tag. Got ${tailBuffer.length} bytes, expected ${TAG_LENGTH}`));
204
+ }
205
+
206
+ authTag = tailBuffer;
207
+ decipher.setAuthTag(authTag);
208
+
209
+ const final = decipher.final();
210
+ if (final.length > 0) {
211
+ this.push(final);
212
+ }
213
+
214
+ callback();
215
+ } catch (err) {
216
+ callback(new Error(`Decryption failed: wrong password or corrupt data (${err.message})`));
217
+ }
218
+ }
219
+ });
220
+ }
93
221
  }
94
222
 
95
223
  /**