@nightowne/tas-cli 1.1.1 → 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 +39 -13
- package/package.json +3 -3
- package/src/cli.js +195 -149
- package/src/db/index.js +86 -0
- package/src/share/server.js +412 -0
- package/src/utils/cli-helpers.js +145 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Telegram as Storage
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ixchio/tas/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@nightowne/tas-cli)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<img src="assets/demo.gif" alt="
|
|
8
|
+
<img src="assets/demo.gif" alt="Demo" width="600">
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
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.
|
|
@@ -26,16 +26,16 @@ A CLI tool that uses your Telegram bot as encrypted file storage. Files are comp
|
|
|
26
26
|
|
|
27
27
|
| Feature | TAS | Session-based tools (e.g. teldrive) |
|
|
28
28
|
|---------|:---:|:-----------------------------------:|
|
|
29
|
-
| Account ban risk |
|
|
29
|
+
| Account ban risk | None (Bot API) | High (session hijack detection) |
|
|
30
30
|
| Encryption | AES-256-GCM | Usually none |
|
|
31
31
|
| Dependencies | SQLite only | Rclone, external DB |
|
|
32
32
|
| Setup complexity | 2 minutes | Docker + multiple services |
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
- Uses
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
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
|
|
39
39
|
|
|
40
40
|
## Security Model
|
|
41
41
|
|
|
@@ -51,11 +51,11 @@ Your password never leaves your machine. Telegram stores encrypted blobs.
|
|
|
51
51
|
|
|
52
52
|
## Limitations
|
|
53
53
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
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
|
|
59
59
|
|
|
60
60
|
## Quick Start
|
|
61
61
|
|
|
@@ -122,10 +122,36 @@ tas sync start # Start watching
|
|
|
122
122
|
tas sync pull # Download all to sync folders
|
|
123
123
|
tas sync status # Show sync status
|
|
124
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
|
+
|
|
125
131
|
# Verification
|
|
126
132
|
tas verify # Check file integrity
|
|
127
133
|
```
|
|
128
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
|
+
|
|
129
155
|
## Auto-Start (systemd)
|
|
130
156
|
|
|
131
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": "
|
|
4
|
-
"description": "
|
|
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": {
|
|
@@ -54,4 +54,4 @@
|
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=18.0.0"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
436
|
-
const
|
|
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
|
-
.
|
|
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
|
-
|
|
739
|
-
const
|
|
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
|
-
.
|
|
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
|
-
|
|
832
|
-
const
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
1143
|
-
if (!
|
|
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
|
-
|
|
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
1042
|
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
+
}
|