@nightowne/tas-cli 2.0.0 → 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/README.md +92 -113
- package/package.json +1 -1
- package/src/cli.js +20 -1
- package/src/crypto/encryption.js +128 -0
- package/src/fuse/mount.js +104 -40
- package/src/index.js +173 -103
- package/src/share/server.js +56 -27
- package/src/sync/sync.js +54 -35
- package/src/telegram/client.js +45 -17
- package/src/utils/chunker.js +11 -1
- package/src/utils/compression.js +30 -0
- package/src/utils/throttle.js +26 -0
package/README.md
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/demo.gif" alt="TAS Demo" width="600">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Telegram as Storage</h1>
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Free, encrypted, unlimited cloud storage — inside Telegram.</strong>
|
|
9
|
+
</p>
|
|
6
10
|
|
|
7
11
|
<p align="center">
|
|
8
|
-
<
|
|
12
|
+
<a href="https://github.com/ixchio/tas/actions/workflows/ci.yml"><img src="https://github.com/ixchio/tas/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/@nightowne/tas-cli"><img src="https://img.shields.io/npm/v/@nightowne/tas-cli" alt="npm"></a>
|
|
14
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/@nightowne/tas-cli"><img src="https://img.shields.io/npm/dm/@nightowne/tas-cli" alt="Downloads"></a>
|
|
9
16
|
</p>
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
I built this because I wanted encrypted cloud storage that's actually free. Google Drive reads your files. Dropbox costs money. Telegram gives you unlimited storage with a bot API — so I wrote a CLI that turns it into a proper encrypted drive.
|
|
21
|
+
|
|
22
|
+
**What this does:** Compresses, encrypts (AES-256-GCM), and uploads your files to your own private Telegram bot chat. Mount it as a folder, sync directories, or share files with expiring links.
|
|
12
23
|
|
|
13
24
|
```
|
|
14
25
|
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
|
|
@@ -22,162 +33,130 @@ A CLI tool that uses your Telegram bot as encrypted file storage. Files are comp
|
|
|
22
33
|
└─────────────┘ └───────────────┘ └──────────────┘
|
|
23
34
|
```
|
|
24
35
|
|
|
25
|
-
##
|
|
26
|
-
|
|
27
|
-
| Feature | TAS | Session-based tools (e.g. teldrive) |
|
|
28
|
-
|---------|:---:|:-----------------------------------:|
|
|
29
|
-
| Account ban risk | None (Bot API) | High (session hijack detection) |
|
|
30
|
-
| Encryption | AES-256-GCM | Usually none |
|
|
31
|
-
| Dependencies | SQLite only | Rclone, external DB |
|
|
32
|
-
| Setup complexity | 2 minutes | Docker + multiple services |
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
## Security Model
|
|
36
|
+
## ⚡ Quick Start
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
| Auth tag | 16 bytes (integrity) |
|
|
49
|
-
|
|
50
|
-
Your password never leaves your machine. Telegram stores encrypted blobs.
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @nightowne/tas-cli
|
|
40
|
+
tas init # 2-minute setup wizard
|
|
41
|
+
tas push secret.pdf
|
|
42
|
+
tas pull secret.pdf
|
|
43
|
+
```
|
|
51
44
|
|
|
52
|
-
##
|
|
45
|
+
## 🔥 Features
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
### Mount as a folder
|
|
48
|
+
```bash
|
|
49
|
+
tas mount ~/cloud # FUSE mount — drag & drop files
|
|
50
|
+
tas unmount ~/cloud
|
|
51
|
+
```
|
|
52
|
+
Requires `libfuse` (`apt install fuse libfuse-dev` on Debian/Ubuntu, `brew install macfuse` on macOS).
|
|
59
53
|
|
|
60
|
-
|
|
54
|
+
### Auto-sync folders
|
|
55
|
+
```bash
|
|
56
|
+
tas sync add ~/Documents # Register
|
|
57
|
+
tas sync start # Watch for changes, auto-upload
|
|
58
|
+
tas sync pull # Download everything back
|
|
59
|
+
```
|
|
61
60
|
|
|
61
|
+
### Share files with expiring links
|
|
62
62
|
```bash
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
tas share create secret.pdf --expire 1h --max-downloads 3
|
|
64
|
+
# → http://localhost:3000/d/a1b2c3d4...
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
tas
|
|
66
|
+
tas share list # Active shares
|
|
67
|
+
tas share revoke a1b2c3d4 # Revoke
|
|
68
|
+
```
|
|
69
|
+
Spins up a local HTTP server. File is downloaded from Telegram, decrypted, and served. Dark-themed download page with file info.
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
tas push secret.pdf
|
|
71
|
+
## 🛡️ Security
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
| Component | Implementation |
|
|
74
|
+
|-----------|----------------|
|
|
75
|
+
| Cipher | AES-256-GCM |
|
|
76
|
+
| Key derivation | PBKDF2-SHA512, 100k iterations |
|
|
77
|
+
| Salt | 32 bytes, random per file |
|
|
78
|
+
| IV | 12 bytes, random per file |
|
|
79
|
+
| Auth tag | 16 bytes (integrity verification) |
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
tas mount ~/cloud
|
|
77
|
-
```
|
|
81
|
+
Your password **never** leaves your machine. Telegram only sees encrypted blobs. Even if someone accesses your bot chat, they get meaningless data without your password.
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
- Node.js ≥18
|
|
81
|
-
- Telegram account + bot token from [@BotFather](https://t.me/BotFather)
|
|
82
|
-
- `libfuse` for mount feature:
|
|
83
|
-
```bash
|
|
84
|
-
# Debian/Ubuntu
|
|
85
|
-
sudo apt install fuse libfuse-dev
|
|
86
|
-
|
|
87
|
-
# Fedora
|
|
88
|
-
sudo dnf install fuse fuse-devel
|
|
89
|
-
|
|
90
|
-
# macOS
|
|
91
|
-
brew install macfuse
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## CLI Reference
|
|
83
|
+
## 📋 Full CLI Reference
|
|
95
84
|
|
|
96
85
|
```bash
|
|
97
86
|
# Core
|
|
98
87
|
tas init # Setup wizard
|
|
99
|
-
tas push <file> # Upload
|
|
100
|
-
tas pull <file|hash> # Download
|
|
101
|
-
tas list [-l] # List files
|
|
102
|
-
tas delete <file|hash> #
|
|
103
|
-
tas status #
|
|
104
|
-
|
|
105
|
-
# Search & Resume (v1.1.0)
|
|
106
|
-
tas search <query> # Search by filename
|
|
107
|
-
tas search -t <query> # Search by tag
|
|
88
|
+
tas push <file> # Upload
|
|
89
|
+
tas pull <file|hash> # Download
|
|
90
|
+
tas list [-l] # List files
|
|
91
|
+
tas delete <file|hash> # Delete
|
|
92
|
+
tas status # Stats
|
|
93
|
+
tas search <query> # Search files
|
|
108
94
|
tas resume # Resume interrupted uploads
|
|
95
|
+
tas verify # Check integrity
|
|
109
96
|
|
|
110
97
|
# FUSE Mount
|
|
111
98
|
tas mount <path> # Mount as folder
|
|
112
99
|
tas unmount <path> # Unmount
|
|
113
100
|
|
|
114
|
-
#
|
|
115
|
-
tas
|
|
116
|
-
tas tag remove <file> <tags...> # Remove tags
|
|
117
|
-
tas tag list [tag] # List tags or files by tag
|
|
118
|
-
|
|
119
|
-
# Sync (Dropbox-style)
|
|
120
|
-
tas sync add <folder> # Register folder for sync
|
|
101
|
+
# Sync
|
|
102
|
+
tas sync add <folder> # Register folder
|
|
121
103
|
tas sync start # Start watching
|
|
122
|
-
tas sync pull # Download all
|
|
123
|
-
tas sync status # Show
|
|
104
|
+
tas sync pull # Download all
|
|
105
|
+
tas sync status # Show status
|
|
124
106
|
|
|
125
|
-
# Share
|
|
107
|
+
# Share
|
|
126
108
|
tas share create <file> # Create download link
|
|
127
|
-
tas share
|
|
128
|
-
tas share
|
|
129
|
-
tas share revoke <token> # Revoke a share
|
|
109
|
+
tas share list # Active shares
|
|
110
|
+
tas share revoke <token> # Revoke
|
|
130
111
|
|
|
131
|
-
#
|
|
132
|
-
tas
|
|
112
|
+
# Tags
|
|
113
|
+
tas tag add <file> <tags...>
|
|
114
|
+
tas tag remove <file> <tags...>
|
|
115
|
+
tas tag list [tag]
|
|
133
116
|
```
|
|
134
117
|
|
|
135
|
-
## Automation
|
|
136
|
-
|
|
137
|
-
Skip password prompts for scripts and CI/CD:
|
|
118
|
+
## 🤖 Automation
|
|
138
119
|
|
|
139
120
|
```bash
|
|
140
|
-
#
|
|
121
|
+
# Skip password prompts
|
|
141
122
|
export TAS_PASSWORD="your-password"
|
|
142
123
|
tas push file.pdf
|
|
143
|
-
tas sync start
|
|
144
124
|
|
|
145
|
-
# Or
|
|
125
|
+
# Or inline
|
|
146
126
|
tas push -p "password" file.pdf
|
|
147
127
|
```
|
|
148
128
|
|
|
149
|
-
Works
|
|
150
|
-
- Cron jobs for automated backups
|
|
151
|
-
- GitHub Actions and GitLab CI
|
|
152
|
-
- Docker containers
|
|
153
|
-
- Shell scripts for batch operations
|
|
129
|
+
Works with cron, GitHub Actions, Docker, or any CI/CD.
|
|
154
130
|
|
|
155
|
-
##
|
|
131
|
+
## ⚠️ Limitations
|
|
156
132
|
|
|
157
|
-
|
|
133
|
+
- **Not a backup** — Telegram can delete content without notice
|
|
134
|
+
- **No versioning** — overwriting deletes the old version
|
|
135
|
+
- **49MB chunks** — files split due to Bot API limits
|
|
136
|
+
- **Single user** — personal use, not multi-tenant
|
|
137
|
+
- **FUSE required** — mount feature needs libfuse
|
|
158
138
|
|
|
159
|
-
## Development
|
|
139
|
+
## 🛠️ Development
|
|
160
140
|
|
|
161
141
|
```bash
|
|
162
142
|
git clone https://github.com/ixchio/tas
|
|
163
|
-
cd tas
|
|
164
|
-
npm
|
|
165
|
-
npm test # 28 tests
|
|
143
|
+
cd tas && npm install
|
|
144
|
+
npm test # 43 tests
|
|
166
145
|
```
|
|
167
146
|
|
|
168
|
-
### Project Structure
|
|
169
147
|
```
|
|
170
148
|
src/
|
|
171
|
-
├── cli.js #
|
|
149
|
+
├── cli.js # Commands
|
|
172
150
|
├── index.js # Upload/download pipeline
|
|
173
|
-
├── crypto/ # AES-256-GCM
|
|
174
|
-
├── db/ # SQLite
|
|
175
|
-
├── fuse/ # FUSE filesystem
|
|
151
|
+
├── crypto/ # AES-256-GCM
|
|
152
|
+
├── db/ # SQLite index
|
|
153
|
+
├── fuse/ # FUSE filesystem
|
|
154
|
+
├── share/ # HTTP share server
|
|
176
155
|
├── sync/ # Folder sync engine
|
|
177
156
|
├── telegram/ # Bot API client
|
|
178
157
|
└── utils/ # Compression, chunking
|
|
179
158
|
```
|
|
180
159
|
|
|
181
|
-
## License
|
|
160
|
+
## 📄 License
|
|
182
161
|
|
|
183
|
-
MIT
|
|
162
|
+
MIT — do whatever you want with it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightowne/tas-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
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",
|
package/src/cli.js
CHANGED
|
@@ -674,19 +674,38 @@ syncCmd
|
|
|
674
674
|
.command('start')
|
|
675
675
|
.description('Start syncing all registered folders')
|
|
676
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)')
|
|
677
678
|
.action(async (options) => {
|
|
678
679
|
console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
|
|
679
680
|
|
|
680
681
|
const config = requireConfig(DATA_DIR);
|
|
681
682
|
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
682
683
|
|
|
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);
|
|
690
|
+
}
|
|
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`));
|
|
699
|
+
}
|
|
700
|
+
|
|
683
701
|
try {
|
|
684
702
|
const { SyncEngine } = await import('./sync/sync.js');
|
|
685
703
|
|
|
686
704
|
const syncEngine = new SyncEngine({
|
|
687
705
|
dataDir: DATA_DIR,
|
|
688
706
|
password,
|
|
689
|
-
config
|
|
707
|
+
config,
|
|
708
|
+
limitRate
|
|
690
709
|
});
|
|
691
710
|
|
|
692
711
|
await syncEngine.initialize();
|
package/src/crypto/encryption.js
CHANGED
|
@@ -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
|
/**
|
package/src/fuse/mount.js
CHANGED
|
@@ -130,26 +130,35 @@ export class TelegramFS {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* Read file contents
|
|
133
|
+
* Read file contents from disk cache
|
|
134
134
|
*/
|
|
135
135
|
async read(filepath, fd, buffer, length, position, cb) {
|
|
136
136
|
const filename = path.basename(filepath);
|
|
137
137
|
|
|
138
138
|
try {
|
|
139
|
+
// Check write buffers first
|
|
140
|
+
const wb = this.writeBuffers.get(filename);
|
|
141
|
+
if (wb) {
|
|
142
|
+
const slice = wb.data.subarray(position, position + length);
|
|
143
|
+
slice.copy(buffer);
|
|
144
|
+
return cb(slice.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
// Check cache first
|
|
140
|
-
let
|
|
148
|
+
let cachedPath = this.getCached(filename);
|
|
141
149
|
|
|
142
|
-
if (!
|
|
143
|
-
// Download and
|
|
144
|
-
|
|
145
|
-
this.setCache(filename,
|
|
150
|
+
if (!cachedPath) {
|
|
151
|
+
// Download, decrypt, and save to disk cache
|
|
152
|
+
cachedPath = await this.downloadFileToCache(filename);
|
|
153
|
+
this.setCache(filename, cachedPath);
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
// Copy requested portion to buffer
|
|
149
|
-
const
|
|
150
|
-
|
|
156
|
+
// Copy requested portion to buffer from disk
|
|
157
|
+
const fdDisk = fs.openSync(cachedPath, 'r');
|
|
158
|
+
const bytesRead = fs.readSync(fdDisk, buffer, 0, length, position);
|
|
159
|
+
fs.closeSync(fdDisk);
|
|
151
160
|
|
|
152
|
-
return cb(
|
|
161
|
+
return cb(bytesRead);
|
|
153
162
|
} catch (err) {
|
|
154
163
|
console.error('Read error:', err.message);
|
|
155
164
|
return cb(Fuse.EIO);
|
|
@@ -305,10 +314,10 @@ export class TelegramFS {
|
|
|
305
314
|
|
|
306
315
|
// Get or load into write buffer
|
|
307
316
|
if (!this.writeBuffers.has(filename)) {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
317
|
+
const cachedPath = this.getCached(filename);
|
|
318
|
+
if (cachedPath) {
|
|
310
319
|
this.writeBuffers.set(filename, {
|
|
311
|
-
data:
|
|
320
|
+
data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
|
|
312
321
|
modified: true
|
|
313
322
|
});
|
|
314
323
|
} else {
|
|
@@ -335,38 +344,81 @@ export class TelegramFS {
|
|
|
335
344
|
|
|
336
345
|
// ============== Helper Methods ==============
|
|
337
346
|
|
|
338
|
-
async
|
|
347
|
+
async downloadFileToCache(filename) {
|
|
339
348
|
const file = this.db.findByName(filename);
|
|
340
349
|
if (!file) throw new Error('File not found');
|
|
341
350
|
|
|
351
|
+
const cacheDir = path.join(this.dataDir, 'cache');
|
|
352
|
+
if (!fs.existsSync(cacheDir)) {
|
|
353
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const outputPath = path.join(cacheDir, file.hash);
|
|
357
|
+
|
|
358
|
+
// If it's already fully downloaded and cached on disk, return path
|
|
359
|
+
if (fs.existsSync(outputPath)) {
|
|
360
|
+
const stats = fs.statSync(outputPath);
|
|
361
|
+
if (stats.size === file.original_size) {
|
|
362
|
+
return outputPath;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
342
366
|
const chunks = this.db.getChunks(file.id);
|
|
343
367
|
if (chunks.length === 0) throw new Error('No chunks found');
|
|
344
368
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
369
|
+
// Pre-sort chunks
|
|
370
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
371
|
+
|
|
372
|
+
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
373
|
+
const header = parseHeader(firstChunkData);
|
|
374
|
+
let wasCompressed = header.compressed;
|
|
375
|
+
|
|
376
|
+
const decryptStream = this.encryptor.getDecryptStream();
|
|
377
|
+
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
378
|
+
|
|
379
|
+
const { Readable } = await import('stream');
|
|
380
|
+
const { pipeline } = await import('stream/promises');
|
|
381
|
+
|
|
382
|
+
const self = this;
|
|
383
|
+
let currentChunkIndex = 0;
|
|
384
|
+
let preloadedFirstChunk = firstChunkData;
|
|
385
|
+
|
|
386
|
+
const downloadStream = new Readable({
|
|
387
|
+
async read() {
|
|
388
|
+
try {
|
|
389
|
+
if (currentChunkIndex >= chunks.length) {
|
|
390
|
+
this.push(null);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const chunk = chunks[currentChunkIndex];
|
|
395
|
+
let data;
|
|
396
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
397
|
+
data = preloadedFirstChunk;
|
|
398
|
+
preloadedFirstChunk = null;
|
|
399
|
+
} else {
|
|
400
|
+
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
404
|
+
this.push(payload);
|
|
405
|
+
currentChunkIndex++;
|
|
406
|
+
} catch (err) {
|
|
407
|
+
this.destroy(err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
352
411
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
data: payload,
|
|
356
|
-
compressed: header.compressed
|
|
357
|
-
});
|
|
358
|
-
}
|
|
412
|
+
const tmpOutputPath = outputPath + '.tmp';
|
|
413
|
+
const writeStream = fs.createWriteStream(tmpOutputPath);
|
|
359
414
|
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
|
|
415
|
+
// Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
|
|
416
|
+
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
363
417
|
|
|
364
|
-
//
|
|
365
|
-
|
|
418
|
+
// Rename to final atomic path
|
|
419
|
+
fs.renameSync(tmpOutputPath, outputPath);
|
|
366
420
|
|
|
367
|
-
|
|
368
|
-
const wasCompressed = downloadedChunks[0].compressed;
|
|
369
|
-
return await this.compressor.decompress(compressedData, wasCompressed);
|
|
421
|
+
return outputPath;
|
|
370
422
|
}
|
|
371
423
|
|
|
372
424
|
async uploadFile(filename, data) {
|
|
@@ -399,7 +451,7 @@ export class TelegramFS {
|
|
|
399
451
|
const encryptedData = this.encryptor.encrypt(compressedData);
|
|
400
452
|
|
|
401
453
|
// Create temp file with header
|
|
402
|
-
const tempDir = path.join(this.dataDir, 'tmp');
|
|
454
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
|
|
403
455
|
if (!fs.existsSync(tempDir)) {
|
|
404
456
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
405
457
|
}
|
|
@@ -438,22 +490,34 @@ export class TelegramFS {
|
|
|
438
490
|
if (!entry) return null;
|
|
439
491
|
|
|
440
492
|
if (Date.now() - entry.timestamp > CACHE_TTL) {
|
|
493
|
+
// Expired, delete the file if possible
|
|
494
|
+
try {
|
|
495
|
+
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
496
|
+
} catch (e) { }
|
|
441
497
|
fileCache.delete(filename);
|
|
442
498
|
return null;
|
|
443
499
|
}
|
|
444
500
|
|
|
445
|
-
|
|
501
|
+
// Extend cache TTL on read
|
|
502
|
+
entry.timestamp = Date.now();
|
|
503
|
+
return entry.path;
|
|
446
504
|
}
|
|
447
505
|
|
|
448
|
-
setCache(filename,
|
|
506
|
+
setCache(filename, cachePath) {
|
|
449
507
|
fileCache.set(filename, {
|
|
450
|
-
|
|
508
|
+
path: cachePath,
|
|
451
509
|
timestamp: Date.now()
|
|
452
510
|
});
|
|
453
511
|
}
|
|
454
512
|
|
|
455
513
|
invalidateCache(filename) {
|
|
456
|
-
fileCache.
|
|
514
|
+
const entry = fileCache.get(filename);
|
|
515
|
+
if (entry) {
|
|
516
|
+
try {
|
|
517
|
+
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
518
|
+
} catch (e) { }
|
|
519
|
+
fileCache.delete(filename);
|
|
520
|
+
}
|
|
457
521
|
}
|
|
458
522
|
|
|
459
523
|
/**
|