@nightowne/tas-cli 1.1.0 → 2.0.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 CHANGED
@@ -1,4 +1,12 @@
1
- # TAS — Telegram as Storage
1
+ # Telegram as Storage
2
+
3
+ [![CI](https://github.com/ixchio/tas/actions/workflows/ci.yml/badge.svg)](https://github.com/ixchio/tas/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@nightowne/tas-cli)](https://www.npmjs.com/package/@nightowne/tas-cli)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ <p align="center">
8
+ <img src="assets/demo.gif" alt="Demo" width="600">
9
+ </p>
2
10
 
3
11
  A CLI tool that uses your Telegram bot as encrypted file storage. Files are compressed, encrypted locally, then uploaded to your private bot chat.
4
12
 
@@ -18,16 +26,16 @@ A CLI tool that uses your Telegram bot as encrypted file storage. Files are comp
18
26
 
19
27
  | Feature | TAS | Session-based tools (e.g. teldrive) |
20
28
  |---------|:---:|:-----------------------------------:|
21
- | Account ban risk | **None** (Bot API) | High (session hijack detection) |
29
+ | Account ban risk | None (Bot API) | High (session hijack detection) |
22
30
  | Encryption | AES-256-GCM | Usually none |
23
31
  | Dependencies | SQLite only | Rclone, external DB |
24
32
  | Setup complexity | 2 minutes | Docker + multiple services |
25
33
 
26
- **Key differences:**
27
- - Uses **Bot API**, not session-based auth Telegram can't ban your account
28
- - **Encryption by default** files encrypted before leaving your machine
29
- - **Local-first** SQLite index, no cloud dependencies
30
- - **FUSE mount** use Telegram like a folder
34
+ Key differences:
35
+ - Uses Bot API, not session-based auth - Telegram can't ban your account
36
+ - Encryption by default - files encrypted before leaving your machine
37
+ - Local-first - SQLite index, no cloud dependencies
38
+ - FUSE mount - use Telegram like a folder
31
39
 
32
40
  ## Security Model
33
41
 
@@ -43,11 +51,11 @@ Your password never leaves your machine. Telegram stores encrypted blobs.
43
51
 
44
52
  ## Limitations
45
53
 
46
- - **Not a backup** Telegram can delete content without notice
47
- - **No versioning** overwriting a file deletes the old version
48
- - **49MB chunks** files split due to Bot API limits
49
- - **FUSE required** mount feature needs `libfuse` on Linux/macOS
50
- - **Single user** designed for personal use, not multi-tenant
54
+ - Not a backup - Telegram can delete content without notice
55
+ - No versioning - overwriting a file deletes the old version
56
+ - 49MB chunks - files split due to Bot API limits
57
+ - FUSE required - mount feature needs libfuse on Linux/macOS
58
+ - Single user - designed for personal use, not multi-tenant
51
59
 
52
60
  ## Quick Start
53
61
 
@@ -114,10 +122,36 @@ tas sync start # Start watching
114
122
  tas sync pull # Download all to sync folders
115
123
  tas sync status # Show sync status
116
124
 
125
+ # Share (temporary links)
126
+ tas share create <file> # Create download link
127
+ tas share create <file> --expire 1h --max-downloads 3
128
+ tas share list # Show active shares
129
+ tas share revoke <token> # Revoke a share
130
+
117
131
  # Verification
118
132
  tas verify # Check file integrity
119
133
  ```
120
134
 
135
+ ## Automation
136
+
137
+ Skip password prompts for scripts and CI/CD:
138
+
139
+ ```bash
140
+ # Environment variable
141
+ export TAS_PASSWORD="your-password"
142
+ tas push file.pdf
143
+ tas sync start
144
+
145
+ # Or use CLI flag (recommended for CI/CD)
146
+ tas push -p "password" file.pdf
147
+ ```
148
+
149
+ Works great with:
150
+ - Cron jobs for automated backups
151
+ - GitHub Actions and GitLab CI
152
+ - Docker containers
153
+ - Shell scripts for batch operations
154
+
121
155
  ## Auto-Start (systemd)
122
156
 
123
157
  See [systemd/README.md](systemd/README.md) for running sync as a service.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "1.1.0",
4
- "description": "📦 Telegram as Storage - Free encrypted cloud storage via Telegram. Mount Telegram as a folder!",
3
+ "version": "2.0.0",
4
+ "description": "Telegram as Storage - Automated encrypted cloud backup. Free, encrypted, scriptable. Mount as folder or use with cron/Docker.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node src/cli.js",
12
12
  "init": "node src/cli.js init",
13
- "test": "node --test tests/",
13
+ "test": "node --test tests/*.test.js",
14
14
  "prepublishOnly": "npm test"
15
15
  },
16
16
  "keywords": [
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
 
@@ -218,20 +199,15 @@ program
218
199
 
219
200
  // ============== PULL COMMAND ==============
220
201
  program
221
- .command('pull <identifier>')
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')
224
- .action(async (identifier, options) => {
205
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
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
 
@@ -268,7 +230,7 @@ program
268
230
  const { ProgressBar } = await import('./utils/progress.js');
269
231
  let progressBar = null;
270
232
 
271
- const outputPath = options.output || fileRecord.filename;
233
+ const outputPath = output || options.output || fileRecord.filename;
272
234
  await retrieveFile(fileRecord, {
273
235
  password,
274
236
  dataDir: DATA_DIR,
@@ -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,33 +673,12 @@ 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
+ .action(async (options) => {
736
678
  console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
737
679
 
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'));
745
-
746
- // Get password
747
- const { password } = await inquirer.prompt([
748
- {
749
- type: 'password',
750
- name: 'password',
751
- message: 'Enter your encryption password:',
752
- mask: '*'
753
- }
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);
761
- }
680
+ const config = requireConfig(DATA_DIR);
681
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
762
682
 
763
683
  try {
764
684
  const { SyncEngine } = await import('./sync/sync.js');
@@ -825,33 +745,12 @@ syncCmd
825
745
  syncCmd
826
746
  .command('pull')
827
747
  .description('Download all Telegram files to sync folders (two-way sync)')
828
- .action(async () => {
748
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
749
+ .action(async (options) => {
829
750
  console.log(chalk.cyan('\n📥 Pulling files from Telegram...\n'));
830
751
 
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
- }
752
+ const config = requireConfig(DATA_DIR);
753
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
855
754
 
856
755
  const spinner = ora('Loading...').start();
857
756
 
@@ -939,13 +838,7 @@ program
939
838
  .action(async () => {
940
839
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
941
840
 
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'));
841
+ const config = requireConfig(DATA_DIR);
949
842
 
950
843
  const spinner = ora('Checking files...').start();
951
844
 
@@ -1078,7 +971,8 @@ program
1078
971
  program
1079
972
  .command('resume')
1080
973
  .description('Resume interrupted uploads')
1081
- .action(async () => {
974
+ .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
975
+ .action(async (options) => {
1082
976
  try {
1083
977
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1084
978
  db.init();
@@ -1139,30 +1033,15 @@ program
1139
1033
  }
1140
1034
 
1141
1035
  // Resume uploads
1142
- const configPath = path.join(DATA_DIR, 'config.json');
1143
- if (!fs.existsSync(configPath)) {
1036
+ const config = loadConfig(DATA_DIR);
1037
+ if (!config) {
1144
1038
  console.log(chalk.red('✗ TAS not initialized.'));
1145
1039
  db.close();
1146
1040
  return;
1147
1041
  }
1148
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1149
1042
 
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
-
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
- }
1043
+ // Get and verify password
1044
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1166
1045
 
1167
1046
  // Connect to Telegram
1168
1047
  const { TelegramClient } = await import('./telegram/client.js');
@@ -1234,6 +1113,173 @@ program
1234
1113
  }
1235
1114
  });
1236
1115
 
1116
+ // ============== SHARE COMMAND ==============
1117
+ const shareCmd = program
1118
+ .command('share')
1119
+ .description('🔗 Share files via temporary download links');
1120
+
1121
+ shareCmd
1122
+ .command('create <file>')
1123
+ .description('Create a temporary download link for a file')
1124
+ .option('-e, --expire <duration>', 'Expiry duration (e.g. 1h, 24h, 7d)', '24h')
1125
+ .option('-m, --max-downloads <n>', 'Maximum number of downloads', '1')
1126
+ .option('--port <port>', 'HTTP server port', '3000')
1127
+ .option('-p, --password <password>', 'Encryption password')
1128
+ .action(async (file, options) => {
1129
+ console.log(chalk.cyan('\n🔗 Creating share link...\n'));
1130
+
1131
+ const config = requireConfig(DATA_DIR);
1132
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
1133
+
1134
+ const spinner = ora('Setting up...').start();
1135
+
1136
+ try {
1137
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1138
+ db.init();
1139
+
1140
+ const fileRecord = db.findByHash(file) || db.findByName(file);
1141
+ if (!fileRecord) {
1142
+ spinner.fail(`File not found: ${file}`);
1143
+ process.exit(1);
1144
+ }
1145
+
1146
+ // Generate token and calculate expiry
1147
+ const { generateToken, parseDuration } = await import('./share/server.js');
1148
+ const token = generateToken();
1149
+ const expiresAt = new Date(Date.now() + parseDuration(options.expire)).toISOString();
1150
+ const maxDownloads = parseInt(options.maxDownloads) || 1;
1151
+
1152
+ // Add share to DB
1153
+ db.addShare(fileRecord.id, token, expiresAt, maxDownloads);
1154
+
1155
+ // Start share server
1156
+ const { ShareServer } = await import('./share/server.js');
1157
+ const port = parseInt(options.port) || 3000;
1158
+
1159
+ const server = new ShareServer({
1160
+ dataDir: DATA_DIR,
1161
+ password,
1162
+ config,
1163
+ port
1164
+ });
1165
+
1166
+ await server.initialize();
1167
+ await server.start();
1168
+
1169
+ spinner.succeed('Share server running!');
1170
+
1171
+ // Get local IP for network sharing
1172
+ const { networkInterfaces } = await import('os');
1173
+ const nets = networkInterfaces();
1174
+ let localIP = 'localhost';
1175
+ for (const name of Object.keys(nets)) {
1176
+ for (const net of nets[name]) {
1177
+ if (net.family === 'IPv4' && !net.internal) {
1178
+ localIP = net.address;
1179
+ break;
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ console.log(chalk.cyan('\n📎 Share Links:\n'));
1185
+ console.log(` ${chalk.white('Local:')} ${chalk.green(`http://localhost:${port}/d/${token}`)}`);
1186
+ console.log(` ${chalk.white('Network:')} ${chalk.green(`http://${localIP}:${port}/d/${token}`)}`);
1187
+ console.log();
1188
+ console.log(chalk.dim(` File: ${fileRecord.filename}`));
1189
+ console.log(chalk.dim(` Expires: ${options.expire}`));
1190
+ console.log(chalk.dim(` Downloads: ${maxDownloads} max`));
1191
+ console.log(chalk.dim(` Token: ${token.substring(0, 8)}...`));
1192
+ console.log();
1193
+ console.log(chalk.yellow('Press Ctrl+C to stop the share server'));
1194
+
1195
+ // Handle graceful shutdown
1196
+ const cleanup = async () => {
1197
+ console.log(chalk.dim('\n\nStopping share server...'));
1198
+ await server.stop();
1199
+ console.log(chalk.green('✓ Share server stopped'));
1200
+ process.exit(0);
1201
+ };
1202
+
1203
+ process.on('SIGINT', cleanup);
1204
+ process.on('SIGTERM', cleanup);
1205
+
1206
+ // Keep process running
1207
+ await new Promise(() => { });
1208
+
1209
+ } catch (err) {
1210
+ spinner.fail(`Share failed: ${err.message}`);
1211
+ process.exit(1);
1212
+ }
1213
+ });
1214
+
1215
+ shareCmd
1216
+ .command('list')
1217
+ .description('List all active share links')
1218
+ .action(async () => {
1219
+ try {
1220
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1221
+ db.init();
1222
+
1223
+ // Clean expired first
1224
+ const cleaned = db.cleanExpiredShares();
1225
+ if (cleaned > 0) {
1226
+ console.log(chalk.dim(` (${cleaned} expired shares cleaned)`));
1227
+ }
1228
+
1229
+ const shares = db.listShares();
1230
+
1231
+ if (shares.length === 0) {
1232
+ console.log(chalk.yellow('\n📭 No active shares. Use `tas share create <file>` to create one.\n'));
1233
+ } else {
1234
+ console.log(chalk.cyan(`\n🔗 Active Shares (${shares.length})\n`));
1235
+
1236
+ for (const share of shares) {
1237
+ const expired = new Date(share.expires_at) < new Date();
1238
+ const status = expired
1239
+ ? chalk.red('expired')
1240
+ : chalk.green('active');
1241
+
1242
+ console.log(` ${chalk.blue('●')} ${share.filename}`);
1243
+ console.log(chalk.dim(` Token: ${share.token.substring(0, 8)}... Status: ${status} Downloads: ${share.download_count}/${share.max_downloads}`));
1244
+ console.log(chalk.dim(` Expires: ${new Date(share.expires_at).toLocaleString()}`));
1245
+ }
1246
+ console.log();
1247
+ }
1248
+
1249
+ db.close();
1250
+ } catch (err) {
1251
+ console.error(chalk.red('Error:'), err.message);
1252
+ process.exit(1);
1253
+ }
1254
+ });
1255
+
1256
+ shareCmd
1257
+ .command('revoke <token>')
1258
+ .description('Revoke a share link')
1259
+ .action(async (token) => {
1260
+ try {
1261
+ const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
1262
+ db.init();
1263
+
1264
+ // Support partial token match
1265
+ const shares = db.listShares();
1266
+ const match = shares.find(s => s.token === token || s.token.startsWith(token));
1267
+
1268
+ if (!match) {
1269
+ console.log(chalk.red(`✗ Share not found: ${token}`));
1270
+ process.exit(1);
1271
+ }
1272
+
1273
+ db.revokeShare(match.token);
1274
+ console.log(chalk.green(`✓ Revoked share for "${match.filename}"`));
1275
+
1276
+ db.close();
1277
+ } catch (err) {
1278
+ console.error(chalk.red('Error:'), err.message);
1279
+ process.exit(1);
1280
+ }
1281
+ });
1282
+
1237
1283
  program.parse();
1238
1284
 
1239
1285
 
package/src/db/index.js CHANGED
@@ -92,6 +92,22 @@ export class FileIndex {
92
92
  );
93
93
  `);
94
94
 
95
+ // Create shares table for temporary file sharing
96
+ this.db.exec(`
97
+ CREATE TABLE IF NOT EXISTS shares (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ file_id INTEGER NOT NULL,
100
+ token TEXT UNIQUE NOT NULL,
101
+ expires_at TEXT NOT NULL,
102
+ max_downloads INTEGER NOT NULL DEFAULT 1,
103
+ download_count INTEGER NOT NULL DEFAULT 0,
104
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
105
+ FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
106
+ );
107
+
108
+ CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(token);
109
+ `);
110
+
95
111
  // Create pending_uploads table for resume functionality
96
112
  this.db.exec(`
97
113
  CREATE TABLE IF NOT EXISTS pending_uploads (
@@ -492,6 +508,76 @@ export class FileIndex {
492
508
  return stmt.get(hash);
493
509
  }
494
510
 
511
+ // ============== SHARE METHODS ==============
512
+
513
+ /**
514
+ * Create a share link for a file
515
+ */
516
+ addShare(fileId, token, expiresAt, maxDownloads = 1) {
517
+ const stmt = this.db.prepare(`
518
+ INSERT INTO shares (file_id, token, expires_at, max_downloads)
519
+ VALUES (?, ?, ?, ?)
520
+ `);
521
+ const result = stmt.run(fileId, token, expiresAt, maxDownloads);
522
+ return result.lastInsertRowid;
523
+ }
524
+
525
+ /**
526
+ * Get a share by token (returns null if not found)
527
+ */
528
+ getShare(token) {
529
+ const stmt = this.db.prepare(`
530
+ SELECT s.*, f.filename, f.original_size
531
+ FROM shares s
532
+ JOIN files f ON s.file_id = f.id
533
+ WHERE s.token = ?
534
+ `);
535
+ return stmt.get(token) || null;
536
+ }
537
+
538
+ /**
539
+ * List all active shares
540
+ */
541
+ listShares() {
542
+ const stmt = this.db.prepare(`
543
+ SELECT s.*, f.filename, f.original_size
544
+ FROM shares s
545
+ JOIN files f ON s.file_id = f.id
546
+ ORDER BY s.created_at DESC
547
+ `);
548
+ return stmt.all();
549
+ }
550
+
551
+ /**
552
+ * Revoke (delete) a share by token
553
+ */
554
+ revokeShare(token) {
555
+ const stmt = this.db.prepare('DELETE FROM shares WHERE token = ?');
556
+ const result = stmt.run(token);
557
+ return result.changes > 0;
558
+ }
559
+
560
+ /**
561
+ * Increment download count for a share
562
+ */
563
+ incrementShareDownload(token) {
564
+ const stmt = this.db.prepare(`
565
+ UPDATE shares SET download_count = download_count + 1 WHERE token = ?
566
+ `);
567
+ stmt.run(token);
568
+ }
569
+
570
+ /**
571
+ * Remove expired shares
572
+ */
573
+ cleanExpiredShares() {
574
+ const stmt = this.db.prepare(`
575
+ DELETE FROM shares WHERE
576
+ REPLACE(REPLACE(expires_at, 'T', ' '), 'Z', '') < strftime('%Y-%m-%d %H:%M:%f', 'now')
577
+ `);
578
+ return stmt.run().changes;
579
+ }
580
+
495
581
  /**
496
582
  * Close database connection
497
583
  */
@@ -0,0 +1,412 @@
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
+ * Download and decrypt a file from Telegram
245
+ */
246
+ async downloadAndDecrypt(fileRecord) {
247
+ const chunks = this.db.getChunks(fileRecord.id);
248
+ if (chunks.length === 0) throw new Error('No chunks found');
249
+
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);
268
+
269
+ const wasCompressed = downloadedChunks[0].compressed;
270
+ return await this.compressor.decompress(compressedData, wasCompressed);
271
+ }
272
+
273
+ /**
274
+ * Handle incoming HTTP requests
275
+ */
276
+ async handleRequest(req, res) {
277
+ const url = new URL(req.url, `http://${req.headers.host}`);
278
+
279
+ // Route: GET /d/:token
280
+ const downloadMatch = url.pathname.match(/^\/d\/([a-f0-9]+)$/);
281
+
282
+ if (!downloadMatch) {
283
+ res.writeHead(404, { 'Content-Type': 'text/html' });
284
+ res.end(generateExpiredPage('invalid'));
285
+ return;
286
+ }
287
+
288
+ const token = downloadMatch[1];
289
+ const wantDownload = url.searchParams.get('download') === '1';
290
+
291
+ try {
292
+ // Clean expired shares first
293
+ this.db.cleanExpiredShares();
294
+
295
+ // Look up share
296
+ const share = this.db.getShare(token);
297
+
298
+ if (!share) {
299
+ res.writeHead(410, { 'Content-Type': 'text/html' });
300
+ res.end(generateExpiredPage('invalid'));
301
+ return;
302
+ }
303
+
304
+ // Check expiry
305
+ if (new Date(share.expires_at) < new Date()) {
306
+ res.writeHead(410, { 'Content-Type': 'text/html' });
307
+ res.end(generateExpiredPage('expired'));
308
+ return;
309
+ }
310
+
311
+ // Check download limit
312
+ if (share.download_count >= share.max_downloads) {
313
+ res.writeHead(410, { 'Content-Type': 'text/html' });
314
+ res.end(generateExpiredPage('used'));
315
+ return;
316
+ }
317
+
318
+ // Get file record
319
+ const fileRecord = this.db.db.prepare('SELECT * FROM files WHERE id = ?').get(share.file_id);
320
+ if (!fileRecord) {
321
+ res.writeHead(404, { 'Content-Type': 'text/html' });
322
+ res.end(generateExpiredPage('invalid'));
323
+ return;
324
+ }
325
+
326
+ if (!wantDownload) {
327
+ // Show download page
328
+ res.writeHead(200, { 'Content-Type': 'text/html' });
329
+ res.end(generateDownloadPage(share, fileRecord));
330
+ return;
331
+ }
332
+
333
+ // Download the file from Telegram, decrypt, and serve
334
+ const data = await this.downloadAndDecrypt(fileRecord);
335
+
336
+ // Increment download count
337
+ this.db.incrementShareDownload(token);
338
+
339
+ // Determine content type
340
+ const ext = path.extname(fileRecord.filename).toLowerCase();
341
+ const contentTypes = {
342
+ '.pdf': 'application/pdf',
343
+ '.png': 'image/png',
344
+ '.jpg': 'image/jpeg',
345
+ '.jpeg': 'image/jpeg',
346
+ '.gif': 'image/gif',
347
+ '.txt': 'text/plain',
348
+ '.json': 'application/json',
349
+ '.zip': 'application/zip',
350
+ '.mp4': 'video/mp4',
351
+ '.mp3': 'audio/mpeg'
352
+ };
353
+ const contentType = contentTypes[ext] || 'application/octet-stream';
354
+
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);
361
+
362
+ } catch (err) {
363
+ console.error('Share server error:', err.message);
364
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
365
+ res.end('Internal server error');
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Start the HTTP server
371
+ */
372
+ start() {
373
+ return new Promise((resolve, reject) => {
374
+ this.server = http.createServer((req, res) => {
375
+ this.handleRequest(req, res).catch(err => {
376
+ console.error('Request error:', err);
377
+ res.writeHead(500);
378
+ res.end('Internal error');
379
+ });
380
+ });
381
+
382
+ this.server.on('error', (err) => {
383
+ if (err.code === 'EADDRINUSE') {
384
+ reject(new Error(`Port ${this.port} is already in use. Try --port <other-port>`));
385
+ } else {
386
+ reject(err);
387
+ }
388
+ });
389
+
390
+ this.server.listen(this.port, this.host, () => {
391
+ resolve();
392
+ });
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Stop the HTTP server
398
+ */
399
+ stop() {
400
+ return new Promise((resolve) => {
401
+ if (this.server) {
402
+ this.server.close(() => {
403
+ if (this.db) this.db.close();
404
+ resolve();
405
+ });
406
+ } else {
407
+ if (this.db) this.db.close();
408
+ resolve();
409
+ }
410
+ });
411
+ }
412
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * CLI Helper utilities
3
+ * Shared logic for commands
4
+ */
5
+
6
+ import inquirer from 'inquirer';
7
+ import chalk from 'chalk';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { Encryptor } from '../crypto/encryption.js';
11
+
12
+ /**
13
+ * Get password from command-line option, environment variable, or interactive prompt
14
+ * @param {string} passwordOption - Password from --password flag (if provided)
15
+ * @param {boolean} allowCache - Allow caching via TAS_PASSWORD env var
16
+ * @returns {Promise<string>}
17
+ */
18
+ export async function getPassword(passwordOption, allowCache = true) {
19
+ // Priority: CLI flag > Environment variable > Interactive prompt
20
+
21
+ if (passwordOption) {
22
+ return passwordOption;
23
+ }
24
+
25
+ if (allowCache && process.env.TAS_PASSWORD) {
26
+ return process.env.TAS_PASSWORD;
27
+ }
28
+
29
+ const { password } = await inquirer.prompt([
30
+ {
31
+ type: 'password',
32
+ name: 'password',
33
+ message: 'Enter your encryption password:',
34
+ mask: '*'
35
+ }
36
+ ]);
37
+
38
+ return password;
39
+ }
40
+
41
+ /**
42
+ * Verify password against config
43
+ * @param {string} password - Password to verify
44
+ * @param {Object} config - Config object with passwordHash
45
+ * @returns {boolean}
46
+ */
47
+ export function verifyPassword(password, config) {
48
+ const encryptor = new Encryptor(password);
49
+ return encryptor.getPasswordHash() === config.passwordHash;
50
+ }
51
+
52
+ /**
53
+ * Validate config structure and content
54
+ * @param {Object} config - Config to validate
55
+ * @returns {Object} - { valid: boolean, errors: string[] }
56
+ */
57
+ export function validateConfig(config) {
58
+ const errors = [];
59
+
60
+ if (!config) {
61
+ errors.push('Config is null or undefined');
62
+ return { valid: false, errors };
63
+ }
64
+
65
+ if (!config.botToken || typeof config.botToken !== 'string') {
66
+ errors.push('Missing or invalid botToken');
67
+ }
68
+
69
+ if (!config.botToken?.includes(':')) {
70
+ errors.push('Invalid bot token format (should contain :)');
71
+ }
72
+
73
+ if (!config.chatId || (typeof config.chatId !== 'number' && typeof config.chatId !== 'string')) {
74
+ errors.push('Missing or invalid chatId');
75
+ }
76
+
77
+ if (!config.passwordHash || typeof config.passwordHash !== 'string') {
78
+ errors.push('Missing or invalid passwordHash');
79
+ }
80
+
81
+ return {
82
+ valid: errors.length === 0,
83
+ errors
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Load and validate config
89
+ * @param {string} dataDir - Data directory path
90
+ * @returns {Object|null} - Config object or null if not found
91
+ */
92
+ export function loadConfig(dataDir) {
93
+ const configPath = path.join(dataDir, 'config.json');
94
+
95
+ if (!fs.existsSync(configPath)) {
96
+ return null;
97
+ }
98
+
99
+ try {
100
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
101
+ const validation = validateConfig(config);
102
+
103
+ if (!validation.valid) {
104
+ throw new Error(`Invalid config: ${validation.errors.join(', ')}`);
105
+ }
106
+
107
+ return config;
108
+ } catch (err) {
109
+ throw new Error(`Config error: ${err.message}`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Ensure TAS is initialized
115
+ * @param {string} dataDir - Data directory path
116
+ * @returns {Object} - Config object
117
+ */
118
+ export function requireConfig(dataDir) {
119
+ const config = loadConfig(dataDir);
120
+
121
+ if (!config) {
122
+ console.log(chalk.red('✗ TAS not initialized. Run `tas init` first.'));
123
+ process.exit(1);
124
+ }
125
+
126
+ return config;
127
+ }
128
+
129
+ /**
130
+ * Get and verify password with proper error handling
131
+ * @param {string} passwordOption - Password from CLI flag
132
+ * @param {string} dataDir - Data directory path
133
+ * @returns {Promise<string>} - Verified password
134
+ */
135
+ export async function getAndVerifyPassword(passwordOption, dataDir) {
136
+ const config = requireConfig(dataDir);
137
+ const password = await getPassword(passwordOption);
138
+
139
+ if (!verifyPassword(password, config)) {
140
+ console.log(chalk.red('✗ Incorrect password'));
141
+ process.exit(1);
142
+ }
143
+
144
+ return password;
145
+ }