daemonclient 1.0.0__tar.gz
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.
- daemonclient-1.0.0/.gitignore +71 -0
- daemonclient-1.0.0/LICENSE +21 -0
- daemonclient-1.0.0/PKG-INFO +80 -0
- daemonclient-1.0.0/README.md +52 -0
- daemonclient-1.0.0/daemon.py +363 -0
- daemonclient-1.0.0/pyproject.toml +44 -0
- daemonclient-1.0.0/requirements.txt +6 -0
- daemonclient-1.0.0/src/daemonclient/__init__.py +3 -0
- daemonclient-1.0.0/src/daemonclient/auth.py +79 -0
- daemonclient-1.0.0/src/daemonclient/cli.py +139 -0
- daemonclient-1.0.0/src/daemonclient/config.py +43 -0
- daemonclient-1.0.0/src/daemonclient/crypto.py +69 -0
- daemonclient-1.0.0/src/daemonclient/transfer.py +312 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# =========================================
|
|
2
|
+
# SENSITIVE & SECRET FILES
|
|
3
|
+
# =========================================
|
|
4
|
+
# Ignore environment variables files
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
# Ignore Google Cloud service account keys
|
|
10
|
+
serviceAccountsKey.json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Ignore Telethon session files, which contain login credentials
|
|
14
|
+
*.session
|
|
15
|
+
*.session-journal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# =========================================
|
|
19
|
+
# PYTHON
|
|
20
|
+
# =========================================
|
|
21
|
+
# Ignore virtual environments
|
|
22
|
+
venv/
|
|
23
|
+
.venv/
|
|
24
|
+
env/
|
|
25
|
+
ENV/
|
|
26
|
+
|
|
27
|
+
# Ignore Python cache files and compiled files
|
|
28
|
+
__pycache__/
|
|
29
|
+
*.pyc
|
|
30
|
+
*.pyo
|
|
31
|
+
*.pyd
|
|
32
|
+
|
|
33
|
+
# Ignore package distribution folders
|
|
34
|
+
build/
|
|
35
|
+
dist/
|
|
36
|
+
*.egg-info/
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# =========================================
|
|
40
|
+
# NODE.JS / FIREBASE FUNCTIONS
|
|
41
|
+
# =========================================
|
|
42
|
+
# Ignore dependency folders
|
|
43
|
+
node_modules/
|
|
44
|
+
|
|
45
|
+
# Ignore log files
|
|
46
|
+
npm-debug.log*
|
|
47
|
+
yarn-debug.log*
|
|
48
|
+
yarn-error.log*
|
|
49
|
+
lerna-debug.log*
|
|
50
|
+
|
|
51
|
+
# Ignore build output
|
|
52
|
+
lib/
|
|
53
|
+
lib-es6/
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# =========================================
|
|
57
|
+
# OPERATING SYSTEM & EDITOR FILES
|
|
58
|
+
# =========================================
|
|
59
|
+
# macOS
|
|
60
|
+
.DS_Store
|
|
61
|
+
|
|
62
|
+
# Windows
|
|
63
|
+
Thumbs.db
|
|
64
|
+
|
|
65
|
+
# VSCode
|
|
66
|
+
.vscode/
|
|
67
|
+
|
|
68
|
+
# Ignore the environment file for Cloud Functions
|
|
69
|
+
functions/.env
|
|
70
|
+
|
|
71
|
+
daemonclient-desktop/node_modules
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 myrosama
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daemonclient
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI for DaemonClient — unlimited encrypted cloud storage powered by Telegram
|
|
5
|
+
Project-URL: Homepage, https://daemonclient.uz
|
|
6
|
+
Project-URL: Repository, https://github.com/myrosama/DaemonClient
|
|
7
|
+
Author: myrosama
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,cloud,encryption,storage,telegram
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: cryptography>=41.0.0
|
|
20
|
+
Requires-Dist: requests>=2.28.0
|
|
21
|
+
Requires-Dist: rich>=13.0.0
|
|
22
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: twine; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# DaemonClient CLI
|
|
30
|
+
|
|
31
|
+
**Unlimited, encrypted cloud storage — from your terminal.**
|
|
32
|
+
|
|
33
|
+
DaemonClient uses Telegram as a free, unlimited storage backend and encrypts everything client-side with AES-256-GCM (Zero-Knowledge Encryption).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install daemonclient
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# 1. Login with your DaemonClient account
|
|
45
|
+
daemon login
|
|
46
|
+
|
|
47
|
+
# 2. See your files
|
|
48
|
+
daemon list
|
|
49
|
+
|
|
50
|
+
# 3. Upload a file (auto-encrypted if ZKE is enabled)
|
|
51
|
+
daemon upload myfile.zip
|
|
52
|
+
|
|
53
|
+
# 4. Download a file
|
|
54
|
+
daemon download <file-id>
|
|
55
|
+
|
|
56
|
+
# 5. Delete a file
|
|
57
|
+
daemon delete <file-id>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---------|-------------|
|
|
64
|
+
| `daemon login` | Sign in with email & password |
|
|
65
|
+
| `daemon logout` | Clear saved session |
|
|
66
|
+
| `daemon whoami` | Show current user |
|
|
67
|
+
| `daemon list` | List all files (add `--json` for scripts) |
|
|
68
|
+
| `daemon upload <path>` | Upload a file |
|
|
69
|
+
| `daemon download <id>` | Download a file |
|
|
70
|
+
| `daemon delete <id>` | Delete a file |
|
|
71
|
+
| `daemon config set-url <url>` | Set backend API URL |
|
|
72
|
+
|
|
73
|
+
## Encryption
|
|
74
|
+
|
|
75
|
+
Files are encrypted with **AES-256-GCM** using a key derived via **PBKDF2** (100,000 iterations). Your password never leaves your device — the server only stores encrypted blobs.
|
|
76
|
+
|
|
77
|
+
## Links
|
|
78
|
+
|
|
79
|
+
- **Web App:** [daemonclient.uz](https://daemonclient.uz)
|
|
80
|
+
- **GitHub:** [github.com/myrosama/DaemonClient](https://github.com/myrosama/DaemonClient)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# DaemonClient CLI
|
|
2
|
+
|
|
3
|
+
**Unlimited, encrypted cloud storage — from your terminal.**
|
|
4
|
+
|
|
5
|
+
DaemonClient uses Telegram as a free, unlimited storage backend and encrypts everything client-side with AES-256-GCM (Zero-Knowledge Encryption).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install daemonclient
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 1. Login with your DaemonClient account
|
|
17
|
+
daemon login
|
|
18
|
+
|
|
19
|
+
# 2. See your files
|
|
20
|
+
daemon list
|
|
21
|
+
|
|
22
|
+
# 3. Upload a file (auto-encrypted if ZKE is enabled)
|
|
23
|
+
daemon upload myfile.zip
|
|
24
|
+
|
|
25
|
+
# 4. Download a file
|
|
26
|
+
daemon download <file-id>
|
|
27
|
+
|
|
28
|
+
# 5. Delete a file
|
|
29
|
+
daemon delete <file-id>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
| Command | Description |
|
|
35
|
+
|---------|-------------|
|
|
36
|
+
| `daemon login` | Sign in with email & password |
|
|
37
|
+
| `daemon logout` | Clear saved session |
|
|
38
|
+
| `daemon whoami` | Show current user |
|
|
39
|
+
| `daemon list` | List all files (add `--json` for scripts) |
|
|
40
|
+
| `daemon upload <path>` | Upload a file |
|
|
41
|
+
| `daemon download <id>` | Download a file |
|
|
42
|
+
| `daemon delete <id>` | Delete a file |
|
|
43
|
+
| `daemon config set-url <url>` | Set backend API URL |
|
|
44
|
+
|
|
45
|
+
## Encryption
|
|
46
|
+
|
|
47
|
+
Files are encrypted with **AES-256-GCM** using a key derived via **PBKDF2** (100,000 iterations). Your password never leaves your device — the server only stores encrypted blobs.
|
|
48
|
+
|
|
49
|
+
## Links
|
|
50
|
+
|
|
51
|
+
- **Web App:** [daemonclient.uz](https://daemonclient.uz)
|
|
52
|
+
- **GitHub:** [github.com/myrosama/DaemonClient](https://github.com/myrosama/DaemonClient)
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# daemon-cli/daemon.py
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
import requests
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
import math
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
|
|
12
|
+
# Remove the 'import pyrebase' line!
|
|
13
|
+
|
|
14
|
+
app = typer.Typer()
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# --- CONFIGURATION ---
|
|
18
|
+
# We only need the API Key for the REST API
|
|
19
|
+
API_KEY = "AIzaSyBH5diC5M7MnOIuOWaNPmOB1AV6uJVZyS8" # From your firebaseConfig
|
|
20
|
+
AUTH_URL = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={API_KEY}"
|
|
21
|
+
|
|
22
|
+
# Your Flask Server URL
|
|
23
|
+
API_URL = "http://127.0.0.1:8080/api"
|
|
24
|
+
TOKEN_FILE = os.path.expanduser("~/.daemonclient_token")
|
|
25
|
+
|
|
26
|
+
def get_auth_token():
|
|
27
|
+
"""Reads the saved token from the local file."""
|
|
28
|
+
if not os.path.exists(TOKEN_FILE):
|
|
29
|
+
console.print("[red]Not logged in. Run 'daemon login' first.[/red]")
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
with open(TOKEN_FILE, "r") as f:
|
|
32
|
+
data = json.load(f)
|
|
33
|
+
return data['idToken']
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def login(email: str = typer.Option(..., prompt=True), password: str = typer.Option(..., prompt=True, hide_input=True)):
|
|
37
|
+
"""Interactive login to save your session (Zero-Dependency Version)."""
|
|
38
|
+
|
|
39
|
+
payload = {
|
|
40
|
+
"email": email,
|
|
41
|
+
"password": password,
|
|
42
|
+
"returnSecureToken": True
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
response = requests.post(AUTH_URL, json=payload)
|
|
47
|
+
data = response.json()
|
|
48
|
+
|
|
49
|
+
if "error" in data:
|
|
50
|
+
console.print(f"[red]Login failed:[/red] {data['error']['message']}")
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
|
|
53
|
+
# Save the token locally
|
|
54
|
+
with open(TOKEN_FILE, "w") as f:
|
|
55
|
+
json.dump(data, f)
|
|
56
|
+
|
|
57
|
+
console.print(f"[green]Success! Logged in as {email}.[/green]")
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
console.print(f"[red]System Error:[/red] {e}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def list(json_output: bool = typer.Option(False, "--json", help="Output raw JSON for scripting")):
|
|
65
|
+
"""List all files in your cloud."""
|
|
66
|
+
token = get_auth_token()
|
|
67
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
response = requests.get(f"{API_URL}/list", headers=headers)
|
|
71
|
+
if response.status_code != 200:
|
|
72
|
+
console.print(f"[red]Error:[/red] {response.text}")
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
|
|
75
|
+
files = response.json().get('files', [])
|
|
76
|
+
|
|
77
|
+
if json_output:
|
|
78
|
+
# Scripting Mode: Print pure JSON
|
|
79
|
+
print(json.dumps(files))
|
|
80
|
+
else:
|
|
81
|
+
# Human Mode: Print a pretty table
|
|
82
|
+
table = Table(title="DaemonClient Files")
|
|
83
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
84
|
+
table.add_column("Name", style="magenta")
|
|
85
|
+
table.add_column("Size", style="green")
|
|
86
|
+
|
|
87
|
+
for f in files:
|
|
88
|
+
size_mb = f"{int(f.get('fileSize', 0)) / 1024 / 1024:.2f} MB"
|
|
89
|
+
table.add_row(f['id'], f.get('fileName', 'Unknown'), size_mb)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
console.print(f"[red]Connection Error:[/red] {e}")
|
|
95
|
+
|
|
96
|
+
# ... (Previous code remains the same) ...
|
|
97
|
+
|
|
98
|
+
# --- CONSTANTS ---
|
|
99
|
+
CHUNK_SIZE = 19 * 1024 * 1024 # 19MB (Matches web app)
|
|
100
|
+
MAX_RETRIES = 5
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def upload(file_path: str):
|
|
104
|
+
"""Upload a local file to the cloud (Zero-Cost Direct Transfer)."""
|
|
105
|
+
if not os.path.exists(file_path):
|
|
106
|
+
console.print(f"[red]File not found:[/red] {file_path}")
|
|
107
|
+
raise typer.Exit(code=1)
|
|
108
|
+
|
|
109
|
+
# 1. Get Config (Bot Token & Channel ID)
|
|
110
|
+
token = get_auth_token()
|
|
111
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# Fetch credentials from your API, NOT the file content
|
|
115
|
+
config_res = requests.get(f"{API_URL}/config", headers=headers)
|
|
116
|
+
if config_res.status_code != 200:
|
|
117
|
+
console.print(f"[red]Failed to get upload config:[/red] {config_res.text}")
|
|
118
|
+
raise typer.Exit(code=1)
|
|
119
|
+
|
|
120
|
+
config = config_res.json()
|
|
121
|
+
bot_token = config['bot_token']
|
|
122
|
+
channel_id = config['channel_id']
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
console.print(f"[red]Connection Error:[/red] {e}")
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
# 2. Prepare File
|
|
129
|
+
file_name = os.path.basename(file_path)
|
|
130
|
+
file_size = os.path.getsize(file_path)
|
|
131
|
+
total_parts = math.ceil(file_size / CHUNK_SIZE)
|
|
132
|
+
|
|
133
|
+
console.print(f"🚀 Uploading [bold cyan]{file_name}[/bold cyan] ({file_size/1024/1024:.2f} MB)")
|
|
134
|
+
console.print(f"📦 Chunks: {total_parts} | Target Channel: {channel_id}")
|
|
135
|
+
|
|
136
|
+
# 3. Upload Chunks Directly to Telegram
|
|
137
|
+
uploaded_messages = []
|
|
138
|
+
|
|
139
|
+
# We define a helper function for uploading a single chunk
|
|
140
|
+
def upload_chunk(part_index):
|
|
141
|
+
start = part_index * CHUNK_SIZE
|
|
142
|
+
|
|
143
|
+
# Retry logic
|
|
144
|
+
for attempt in range(MAX_RETRIES):
|
|
145
|
+
try:
|
|
146
|
+
with open(file_path, "rb") as f:
|
|
147
|
+
f.seek(start)
|
|
148
|
+
chunk_data = f.read(CHUNK_SIZE)
|
|
149
|
+
|
|
150
|
+
files = {
|
|
151
|
+
'document': (f"{file_name}.part{part_index+1:03d}", chunk_data)
|
|
152
|
+
}
|
|
153
|
+
data = {'chat_id': channel_id}
|
|
154
|
+
|
|
155
|
+
# DIRECT TO TELEGRAM (Bypasses your backend!)
|
|
156
|
+
res = requests.post(
|
|
157
|
+
f"https://api.telegram.org/bot{bot_token}/sendDocument",
|
|
158
|
+
data=data,
|
|
159
|
+
files=files,
|
|
160
|
+
timeout=300
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if res.status_code == 429:
|
|
164
|
+
# Rate limit handling
|
|
165
|
+
retry_after = res.json()['parameters']['retry_after']
|
|
166
|
+
time.sleep(retry_after + 1)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if res.status_code != 200:
|
|
170
|
+
raise Exception(f"Telegram Error: {res.text}")
|
|
171
|
+
|
|
172
|
+
result = res.json()
|
|
173
|
+
return {
|
|
174
|
+
"index": part_index,
|
|
175
|
+
"message_id": result['result']['message_id'],
|
|
176
|
+
"file_id": result['result']['document']['file_id']
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
if attempt == MAX_RETRIES - 1:
|
|
181
|
+
raise e
|
|
182
|
+
time.sleep(2)
|
|
183
|
+
|
|
184
|
+
# Execute uploads (Sequential for now to be safe, can be threaded later)
|
|
185
|
+
# Using a simple loop to ensure order and handle errors gracefully
|
|
186
|
+
with typer.progressbar(length=total_parts, label="Uploading") as progress:
|
|
187
|
+
for i in range(total_parts):
|
|
188
|
+
try:
|
|
189
|
+
msg_data = upload_chunk(i)
|
|
190
|
+
uploaded_messages.append(msg_data)
|
|
191
|
+
progress.update(1)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
console.print(f"\n[red]Failed to upload part {i+1}:[/red] {e}")
|
|
194
|
+
raise typer.Exit(code=1)
|
|
195
|
+
|
|
196
|
+
# 4. Register File in Firestore
|
|
197
|
+
# Now we tell your backend: "Hey, I'm done. Here is the metadata."
|
|
198
|
+
register_payload = {
|
|
199
|
+
"fileName": file_name,
|
|
200
|
+
"fileSize": file_size,
|
|
201
|
+
"fileType": "application/octet-stream", # Simplified for CLI
|
|
202
|
+
"parentId": "root", # Default to root for now
|
|
203
|
+
"type": "file",
|
|
204
|
+
"messages": [
|
|
205
|
+
{"message_id": m["message_id"], "file_id": m["file_id"]}
|
|
206
|
+
for m in sorted(uploaded_messages, key=lambda x: x['index'])
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# We need a new endpoint for this! Let's call it /api/register
|
|
212
|
+
# For now, we can't finish this step until we update the backend.
|
|
213
|
+
# console.print(f"[yellow]Upload complete! (Metadata registration pending)[/yellow]")
|
|
214
|
+
|
|
215
|
+
# Let's assume we added this endpoint
|
|
216
|
+
reg_res = requests.post(f"{API_URL}/register", headers=headers, json=register_payload)
|
|
217
|
+
if reg_res.status_code == 200:
|
|
218
|
+
console.print(f"[green]✨ Upload Successful! File registered.[/green]")
|
|
219
|
+
else:
|
|
220
|
+
console.print(f"[red]Upload worked, but registration failed:[/red] {reg_res.text}")
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
console.print(f"[red]Registration Error:[/red] {e}")
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def download(file_id: str, output_path: str = typer.Option(None, help="Custom output path")):
|
|
227
|
+
"""Download a file from the cloud (Direct from Telegram)."""
|
|
228
|
+
|
|
229
|
+
# 1. Get Auth & Config
|
|
230
|
+
token = get_auth_token()
|
|
231
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
232
|
+
|
|
233
|
+
# We need the Bot Token to download
|
|
234
|
+
try:
|
|
235
|
+
config_res = requests.get(f"{API_URL}/config", headers=headers)
|
|
236
|
+
bot_token = config_res.json()['bot_token']
|
|
237
|
+
except Exception:
|
|
238
|
+
console.print("[red]Failed to get bot configuration.[/red]")
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
|
|
241
|
+
# 2. Get File Metadata (We need the message IDs to find the file paths)
|
|
242
|
+
# We can reuse the /api/list endpoint, or it's better to make a specific /api/file/<id> endpoint.
|
|
243
|
+
# For now, let's filtering the list (Simple but inefficient for huge lists)
|
|
244
|
+
# A better way is to fetch the specific file doc.
|
|
245
|
+
|
|
246
|
+
# Let's iterate the list for now as it's easiest without changing backend
|
|
247
|
+
console.print(f"[dim]Fetching metadata for {file_id}...[/dim]")
|
|
248
|
+
list_res = requests.get(f"{API_URL}/list", headers=headers)
|
|
249
|
+
files = list_res.json()['files']
|
|
250
|
+
|
|
251
|
+
target_file = next((f for f in files if f['id'] == file_id), None)
|
|
252
|
+
|
|
253
|
+
if not target_file:
|
|
254
|
+
console.print(f"[red]File ID {file_id} not found.[/red]")
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
|
|
257
|
+
file_name = target_file['fileName']
|
|
258
|
+
final_path = output_path or file_name
|
|
259
|
+
|
|
260
|
+
# 3. Download Logic
|
|
261
|
+
console.print(f"⬇️ Downloading [cyan]{file_name}[/cyan]...")
|
|
262
|
+
|
|
263
|
+
# Sort messages by index to ensure order
|
|
264
|
+
# (The web app stores them in order, but good to be safe)
|
|
265
|
+
# Note: The CLI upload stored them as a list of dicts.
|
|
266
|
+
# We assume they are in order or have an index.
|
|
267
|
+
# If your `messages` array doesn't have 'index', we trust the list order.
|
|
268
|
+
|
|
269
|
+
with open(final_path, "wb") as f_out:
|
|
270
|
+
with typer.progressbar(target_file['messages'], label="Downloading") as progress:
|
|
271
|
+
for msg in target_file['messages']:
|
|
272
|
+
# Get the path for this chunk
|
|
273
|
+
# We call Telegram's getFile method
|
|
274
|
+
file_info_res = requests.get(f"https://api.telegram.org/bot{bot_token}/getFile?file_id={msg['file_id']}")
|
|
275
|
+
if file_info_res.status_code != 200:
|
|
276
|
+
console.print(f"[red]Telegram Error:[/red] {file_info_res.text}")
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
file_path_tg = file_info_res.json()['result']['file_path']
|
|
280
|
+
download_url = f"https://api.telegram.org/file/bot{bot_token}/{file_path_tg}"
|
|
281
|
+
|
|
282
|
+
# Stream the download
|
|
283
|
+
chunk_res = requests.get(download_url, stream=True)
|
|
284
|
+
for chunk in chunk_res.iter_content(chunk_size=8192):
|
|
285
|
+
f_out.write(chunk)
|
|
286
|
+
|
|
287
|
+
progress.update(1)
|
|
288
|
+
|
|
289
|
+
console.print(f"[green]✅ Download complete: {final_path}[/green]")
|
|
290
|
+
|
|
291
|
+
@app.command()
|
|
292
|
+
def delete(file_id: str, yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation (for scripts)")):
|
|
293
|
+
"""Delete a file (Removes from Telegram & Registry)."""
|
|
294
|
+
|
|
295
|
+
# 1. Setup Auth
|
|
296
|
+
token = get_auth_token()
|
|
297
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
298
|
+
|
|
299
|
+
# 2. Get Config (We need Bot Token & Channel ID to delete messages)
|
|
300
|
+
try:
|
|
301
|
+
config_res = requests.get(f"{API_URL}/config", headers=headers)
|
|
302
|
+
if config_res.status_code != 200:
|
|
303
|
+
console.print("[red]Failed to get config.[/red]")
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
config = config_res.json()
|
|
306
|
+
bot_token = config['bot_token']
|
|
307
|
+
channel_id = config['channel_id']
|
|
308
|
+
except Exception as e:
|
|
309
|
+
console.print(f"[red]Connection Error:[/red] {e}")
|
|
310
|
+
raise typer.Exit(1)
|
|
311
|
+
|
|
312
|
+
# 3. Get Metadata (We need the list of message_ids to delete)
|
|
313
|
+
# Since we don't have a specific GET /file/<id> endpoint yet, we search the list.
|
|
314
|
+
try:
|
|
315
|
+
files_res = requests.get(f"{API_URL}/list", headers=headers)
|
|
316
|
+
files = files_res.json().get('files', [])
|
|
317
|
+
target_file = next((f for f in files if f['id'] == file_id), None)
|
|
318
|
+
|
|
319
|
+
if not target_file:
|
|
320
|
+
console.print(f"[red]File ID {file_id} not found.[/red]")
|
|
321
|
+
raise typer.Exit(1)
|
|
322
|
+
|
|
323
|
+
except Exception as e:
|
|
324
|
+
console.print(f"[red]Error fetching metadata:[/red] {e}")
|
|
325
|
+
raise typer.Exit(1)
|
|
326
|
+
|
|
327
|
+
# 4. Confirmation (Skipped if --yes is passed)
|
|
328
|
+
if not yes:
|
|
329
|
+
confirm = typer.confirm(f"Are you sure you want to delete '{target_file.get('fileName')}'?")
|
|
330
|
+
if not confirm:
|
|
331
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
332
|
+
raise typer.Abort()
|
|
333
|
+
|
|
334
|
+
# 5. Delete Chunks from Telegram (Client-Side)
|
|
335
|
+
messages = target_file.get('messages', [])
|
|
336
|
+
console.print(f"[yellow]Deleting {len(messages)} chunks from Telegram...[/yellow]")
|
|
337
|
+
|
|
338
|
+
with typer.progressbar(messages, label="Cleaning Up") as progress:
|
|
339
|
+
for msg in messages:
|
|
340
|
+
try:
|
|
341
|
+
# Direct call to Telegram API
|
|
342
|
+
del_res = requests.post(f"https://api.telegram.org/bot{bot_token}/deleteMessage", json={
|
|
343
|
+
"chat_id": channel_id,
|
|
344
|
+
"message_id": msg['message_id']
|
|
345
|
+
})
|
|
346
|
+
# We don't stop on error, we try to delete as much as possible
|
|
347
|
+
progress.update(1)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
console.print(f"[dim]Failed to delete chunk {msg['message_id']}: {e}[/dim]")
|
|
350
|
+
|
|
351
|
+
# 6. Delete from Firestore Registry (Server-Side)
|
|
352
|
+
try:
|
|
353
|
+
res = requests.post(f"{API_URL}/delete", headers=headers, json={"file_id": file_id})
|
|
354
|
+
if res.status_code == 200:
|
|
355
|
+
console.print(f"[green]🗑️ File '{target_file.get('fileName')}' deleted successfully.[/green]")
|
|
356
|
+
else:
|
|
357
|
+
console.print(f"[red]Registry delete failed:[/red] {res.text}")
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
console.print(f"[red]API Error:[/red] {e}")
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
app()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daemonclient"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CLI for DaemonClient — unlimited encrypted cloud storage powered by Telegram"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "myrosama" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["cloud", "storage", "telegram", "encryption", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: System :: Archiving :: Backup",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"typer[all]>=0.9.0",
|
|
27
|
+
"requests>=2.28.0",
|
|
28
|
+
"rich>=13.0.0",
|
|
29
|
+
"cryptography>=41.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"build",
|
|
35
|
+
"twine",
|
|
36
|
+
"pytest",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
daemon = "daemonclient.cli:app"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://daemonclient.uz"
|
|
44
|
+
Repository = "https://github.com/myrosama/DaemonClient"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# daemonclient/auth.py
|
|
2
|
+
"""Authentication module — login, logout, token management."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from .config import AUTH_URL, TOKEN_FILE
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def login(email: str, password: str) -> None:
|
|
17
|
+
"""Authenticate with Firebase REST API and save the token locally."""
|
|
18
|
+
payload = {
|
|
19
|
+
"email": email,
|
|
20
|
+
"password": password,
|
|
21
|
+
"returnSecureToken": True,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
response = requests.post(AUTH_URL, json=payload, timeout=15)
|
|
26
|
+
data = response.json()
|
|
27
|
+
|
|
28
|
+
if "error" in data:
|
|
29
|
+
console.print(f"[red]Login failed:[/red] {data['error']['message']}")
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
|
|
32
|
+
# Save the full token response
|
|
33
|
+
with open(TOKEN_FILE, "w") as f:
|
|
34
|
+
json.dump(data, f)
|
|
35
|
+
|
|
36
|
+
console.print(f"[green]✅ Logged in as {email}[/green]")
|
|
37
|
+
|
|
38
|
+
except requests.ConnectionError:
|
|
39
|
+
console.print("[red]Connection error — is the internet available?[/red]")
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def logout() -> None:
|
|
44
|
+
"""Remove saved session."""
|
|
45
|
+
if os.path.exists(TOKEN_FILE):
|
|
46
|
+
os.remove(TOKEN_FILE)
|
|
47
|
+
console.print("[green]Logged out.[/green]")
|
|
48
|
+
else:
|
|
49
|
+
console.print("[yellow]Not logged in.[/yellow]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def whoami() -> None:
|
|
53
|
+
"""Print the current user's email."""
|
|
54
|
+
token_data = _load_token_data()
|
|
55
|
+
if token_data:
|
|
56
|
+
console.print(f"[cyan]{token_data.get('email', 'Unknown')}[/cyan]")
|
|
57
|
+
else:
|
|
58
|
+
console.print("[yellow]Not logged in.[/yellow]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_auth_token() -> str:
|
|
62
|
+
"""Read the saved ID token, or exit if not logged in."""
|
|
63
|
+
token_data = _load_token_data()
|
|
64
|
+
if not token_data:
|
|
65
|
+
console.print("[red]Not logged in. Run 'daemon login' first.[/red]")
|
|
66
|
+
raise typer.Exit(code=1)
|
|
67
|
+
return token_data["idToken"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_auth_headers() -> dict:
|
|
71
|
+
"""Convenience: returns {'Authorization': 'Bearer <token>'}."""
|
|
72
|
+
return {"Authorization": f"Bearer {get_auth_token()}"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_token_data() -> dict | None:
|
|
76
|
+
if not os.path.exists(TOKEN_FILE):
|
|
77
|
+
return None
|
|
78
|
+
with open(TOKEN_FILE, "r") as f:
|
|
79
|
+
return json.load(f)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# daemonclient/cli.py
|
|
2
|
+
"""Typer CLI application — the `daemon` command."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .auth import login as _login, logout as _logout, whoami as _whoami, get_auth_headers
|
|
12
|
+
from .config import get_api_url, set_api_url
|
|
13
|
+
from .transfer import upload_file, download_file, delete_file
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="daemon",
|
|
17
|
+
help="DaemonClient CLI — unlimited encrypted cloud storage from your terminal.",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
# ── Config sub-command group ─────────────────────────────────────────
|
|
23
|
+
config_app = typer.Typer(help="Manage CLI configuration.")
|
|
24
|
+
app.add_typer(config_app, name="config")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@config_app.command("set-url")
|
|
28
|
+
def config_set_url(url: str = typer.Argument(..., help="Backend API URL")):
|
|
29
|
+
"""Set the backend API URL (e.g. https://yourserver.com/api)."""
|
|
30
|
+
set_api_url(url)
|
|
31
|
+
console.print(f"[green]API URL set to:[/green] {url}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@config_app.command("show")
|
|
35
|
+
def config_show():
|
|
36
|
+
"""Show current configuration."""
|
|
37
|
+
console.print(f"[cyan]API URL:[/cyan] {get_api_url()}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Auth commands ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def login(
|
|
44
|
+
email: str = typer.Option(..., prompt=True),
|
|
45
|
+
password: str = typer.Option(..., prompt=True, hide_input=True),
|
|
46
|
+
):
|
|
47
|
+
"""Sign in with your DaemonClient email and password."""
|
|
48
|
+
_login(email, password)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def logout():
|
|
53
|
+
"""Clear your saved session."""
|
|
54
|
+
_logout()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def whoami():
|
|
59
|
+
"""Show the currently logged-in user."""
|
|
60
|
+
_whoami()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── File commands ────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
@app.command("list")
|
|
66
|
+
def list_files(
|
|
67
|
+
json_out: bool = typer.Option(False, "--json", help="Output raw JSON for scripting"),
|
|
68
|
+
):
|
|
69
|
+
"""List all files in your cloud."""
|
|
70
|
+
import requests
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
res = requests.get(f"{get_api_url()}/list", headers=get_auth_headers(), timeout=15)
|
|
74
|
+
if res.status_code != 200:
|
|
75
|
+
console.print(f"[red]Error:[/red] {res.text}")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
files = res.json().get("files", [])
|
|
79
|
+
|
|
80
|
+
if json_out:
|
|
81
|
+
print(json.dumps(files, indent=2))
|
|
82
|
+
else:
|
|
83
|
+
table = Table(title="☁️ DaemonClient Files")
|
|
84
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
85
|
+
table.add_column("Name", style="magenta")
|
|
86
|
+
table.add_column("Type", style="dim")
|
|
87
|
+
table.add_column("Size", style="green", justify="right")
|
|
88
|
+
|
|
89
|
+
for f in files:
|
|
90
|
+
ftype = f.get("type", "file")
|
|
91
|
+
if ftype == "folder":
|
|
92
|
+
size_str = "—"
|
|
93
|
+
else:
|
|
94
|
+
size_str = f"{int(f.get('fileSize', 0)) / 1024 / 1024:.2f} MB"
|
|
95
|
+
table.add_row(f["id"], f.get("fileName", "?"), ftype, size_str)
|
|
96
|
+
|
|
97
|
+
console.print(table)
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
console.print(f"[red]Connection error:[/red] {e}")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def upload(
|
|
106
|
+
file_path: str = typer.Argument(..., help="Path to the file to upload"),
|
|
107
|
+
folder: str = typer.Option("root", "--folder", "-f", help="Target folder ID"),
|
|
108
|
+
no_encrypt: bool = typer.Option(False, "--no-encrypt", help="Skip ZKE encryption"),
|
|
109
|
+
):
|
|
110
|
+
"""Upload a local file to the cloud."""
|
|
111
|
+
upload_file(file_path, folder_id=folder, no_encrypt=no_encrypt)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def download(
|
|
116
|
+
file_id: str = typer.Argument(..., help="File ID to download"),
|
|
117
|
+
output: str = typer.Option(None, "--output", "-o", help="Custom output path"),
|
|
118
|
+
):
|
|
119
|
+
"""Download a file from the cloud."""
|
|
120
|
+
download_file(file_id, output_path=output)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def delete(
|
|
125
|
+
file_id: str = typer.Argument(..., help="File ID to delete"),
|
|
126
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
127
|
+
):
|
|
128
|
+
"""Delete a file from the cloud."""
|
|
129
|
+
delete_file(file_id, skip_confirm=yes)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def version():
|
|
134
|
+
"""Show the CLI version."""
|
|
135
|
+
console.print(f"[bold cyan]daemonclient[/bold cyan] v{__version__}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
app()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# daemonclient/config.py
|
|
2
|
+
"""Configuration management for DaemonClient CLI."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
# --- Paths ---
|
|
8
|
+
TOKEN_FILE = os.path.expanduser("~/.daemonclient_token")
|
|
9
|
+
CONFIG_FILE = os.path.expanduser("~/.daemonclient.json")
|
|
10
|
+
|
|
11
|
+
# --- Defaults ---
|
|
12
|
+
DEFAULT_API_URL = "http://127.0.0.1:8080/api"
|
|
13
|
+
FIREBASE_API_KEY = "AIzaSyBH5diC5M7MnOIuOWaNPmOB1AV6uJVZyS8"
|
|
14
|
+
AUTH_URL = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={FIREBASE_API_KEY}"
|
|
15
|
+
|
|
16
|
+
# --- Upload Constants ---
|
|
17
|
+
CHUNK_SIZE = 19 * 1024 * 1024 # 19 MB (matches web app)
|
|
18
|
+
MAX_RETRIES = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_api_url() -> str:
|
|
22
|
+
"""Returns the configured API URL, falling back to default."""
|
|
23
|
+
cfg = _load_config()
|
|
24
|
+
return cfg.get("api_url", DEFAULT_API_URL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_api_url(url: str) -> None:
|
|
28
|
+
"""Persists a custom API URL."""
|
|
29
|
+
cfg = _load_config()
|
|
30
|
+
cfg["api_url"] = url.rstrip("/")
|
|
31
|
+
_save_config(cfg)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_config() -> dict:
|
|
35
|
+
if os.path.exists(CONFIG_FILE):
|
|
36
|
+
with open(CONFIG_FILE, "r") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_config(cfg: dict) -> None:
|
|
42
|
+
with open(CONFIG_FILE, "w") as f:
|
|
43
|
+
json.dump(cfg, f, indent=2)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# daemonclient/crypto.py
|
|
2
|
+
"""
|
|
3
|
+
ZKE (Zero-Knowledge Encryption) module.
|
|
4
|
+
|
|
5
|
+
Byte-compatible with the web app's crypto.js:
|
|
6
|
+
- AES-256-GCM
|
|
7
|
+
- PBKDF2 key derivation (SHA-256, 100 000 iterations)
|
|
8
|
+
- Chunk format: [IV 12 bytes][ciphertext + GCM tag]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
15
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
16
|
+
from cryptography.hazmat.primitives import hashes
|
|
17
|
+
|
|
18
|
+
# Must match crypto.js constants exactly
|
|
19
|
+
SALT_LENGTH = 16 # 128 bits
|
|
20
|
+
IV_LENGTH = 12 # 96 bits (recommended for GCM)
|
|
21
|
+
KEY_LENGTH_BYTES = 32 # 256 bits
|
|
22
|
+
PBKDF2_ITERATIONS = 100_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
26
|
+
"""Derive a 256-bit AES key from a password and salt (PBKDF2-SHA256).
|
|
27
|
+
|
|
28
|
+
Produces the same raw key bytes as the JS ``deriveKey()`` function.
|
|
29
|
+
"""
|
|
30
|
+
kdf = PBKDF2HMAC(
|
|
31
|
+
algorithm=hashes.SHA256(),
|
|
32
|
+
length=KEY_LENGTH_BYTES,
|
|
33
|
+
salt=salt,
|
|
34
|
+
iterations=PBKDF2_ITERATIONS,
|
|
35
|
+
)
|
|
36
|
+
return kdf.derive(password.encode("utf-8"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encrypt_chunk(plaintext: bytes, key: bytes) -> bytes:
|
|
40
|
+
"""Encrypt a chunk with AES-256-GCM.
|
|
41
|
+
|
|
42
|
+
Returns ``iv || ciphertext_with_tag`` — identical layout to the JS
|
|
43
|
+
``encryptChunk()`` function.
|
|
44
|
+
"""
|
|
45
|
+
iv = os.urandom(IV_LENGTH)
|
|
46
|
+
aesgcm = AESGCM(key)
|
|
47
|
+
ct = aesgcm.encrypt(iv, plaintext, None) # ct includes GCM tag
|
|
48
|
+
return iv + ct
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decrypt_chunk(data: bytes, key: bytes) -> bytes:
|
|
52
|
+
"""Decrypt a chunk produced by ``encrypt_chunk`` (or the JS equivalent).
|
|
53
|
+
|
|
54
|
+
Expects ``iv || ciphertext_with_tag``.
|
|
55
|
+
"""
|
|
56
|
+
iv = data[:IV_LENGTH]
|
|
57
|
+
ct = data[IV_LENGTH:]
|
|
58
|
+
aesgcm = AESGCM(key)
|
|
59
|
+
return aesgcm.decrypt(iv, ct, None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- base64 helpers (match JS bytesToBase64 / base64ToBytes) ---
|
|
63
|
+
|
|
64
|
+
def bytes_to_base64(b: bytes) -> str:
|
|
65
|
+
return base64.b64encode(b).decode("ascii")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def base64_to_bytes(s: str) -> bytes:
|
|
69
|
+
return base64.b64decode(s)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# daemonclient/transfer.py
|
|
2
|
+
"""Upload and download logic — chunking, encryption, Telegram transport."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
|
|
12
|
+
|
|
13
|
+
from .auth import get_auth_headers
|
|
14
|
+
from .config import CHUNK_SIZE, MAX_RETRIES, get_api_url
|
|
15
|
+
from .crypto import encrypt_chunk, decrypt_chunk
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _get_tg_config() -> dict:
|
|
23
|
+
"""Fetch bot_token + channel_id from the backend."""
|
|
24
|
+
res = requests.get(f"{get_api_url()}/config", headers=get_auth_headers(), timeout=15)
|
|
25
|
+
if res.status_code != 200:
|
|
26
|
+
console.print(f"[red]Failed to get config:[/red] {res.text}")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
return res.json()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_zke_config() -> dict | None:
|
|
32
|
+
"""Fetch ZKE config (password, salt, enabled) from the backend.
|
|
33
|
+
|
|
34
|
+
Returns None if ZKE is disabled or the endpoint doesn't exist yet.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
res = requests.get(f"{get_api_url()}/zke-config", headers=get_auth_headers(), timeout=15)
|
|
38
|
+
if res.status_code == 200:
|
|
39
|
+
return res.json()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _derive_key_from_config(zke_cfg: dict):
|
|
46
|
+
"""Derive AES key from the ZKE config fetched from the server."""
|
|
47
|
+
from .crypto import derive_key, base64_to_bytes
|
|
48
|
+
salt = base64_to_bytes(zke_cfg["salt"])
|
|
49
|
+
return derive_key(zke_cfg["password"], salt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _send_chunk_to_telegram(
|
|
53
|
+
chunk_data: bytes,
|
|
54
|
+
part_name: str,
|
|
55
|
+
bot_token: str,
|
|
56
|
+
channel_id: str,
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Upload a single chunk to Telegram with retry logic.
|
|
59
|
+
|
|
60
|
+
Returns {"message_id": ..., "file_id": ...}.
|
|
61
|
+
"""
|
|
62
|
+
for attempt in range(MAX_RETRIES):
|
|
63
|
+
try:
|
|
64
|
+
files = {"document": (part_name, chunk_data)}
|
|
65
|
+
data = {"chat_id": channel_id}
|
|
66
|
+
|
|
67
|
+
res = requests.post(
|
|
68
|
+
f"https://api.telegram.org/bot{bot_token}/sendDocument",
|
|
69
|
+
data=data,
|
|
70
|
+
files=files,
|
|
71
|
+
timeout=300,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if res.status_code == 429:
|
|
75
|
+
retry_after = res.json().get("parameters", {}).get("retry_after", 5)
|
|
76
|
+
time.sleep(retry_after + 1)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if res.status_code != 200:
|
|
80
|
+
raise Exception(f"Telegram Error {res.status_code}: {res.text}")
|
|
81
|
+
|
|
82
|
+
result = res.json()["result"]
|
|
83
|
+
return {
|
|
84
|
+
"message_id": result["message_id"],
|
|
85
|
+
"file_id": result["document"]["file_id"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if attempt == MAX_RETRIES - 1:
|
|
90
|
+
raise
|
|
91
|
+
time.sleep(2 ** attempt) # exponential backoff
|
|
92
|
+
|
|
93
|
+
raise Exception("Max retries exhausted")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── public API ───────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def upload_file(
|
|
99
|
+
file_path: str,
|
|
100
|
+
folder_id: str = "root",
|
|
101
|
+
no_encrypt: bool = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Upload a local file to DaemonClient storage."""
|
|
104
|
+
if not os.path.exists(file_path):
|
|
105
|
+
console.print(f"[red]File not found:[/red] {file_path}")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
file_name = os.path.basename(file_path)
|
|
109
|
+
file_size = os.path.getsize(file_path)
|
|
110
|
+
total_parts = math.ceil(file_size / CHUNK_SIZE)
|
|
111
|
+
|
|
112
|
+
# Fetch Telegram credentials
|
|
113
|
+
tg = _get_tg_config()
|
|
114
|
+
bot_token = tg["bot_token"]
|
|
115
|
+
channel_id = tg["channel_id"]
|
|
116
|
+
|
|
117
|
+
# Fetch ZKE key (if enabled)
|
|
118
|
+
zke_key = None
|
|
119
|
+
if not no_encrypt:
|
|
120
|
+
zke_cfg = _get_zke_config()
|
|
121
|
+
if zke_cfg and zke_cfg.get("enabled"):
|
|
122
|
+
zke_key = _derive_key_from_config(zke_cfg)
|
|
123
|
+
console.print("[dim]🔐 ZKE encryption enabled[/dim]")
|
|
124
|
+
|
|
125
|
+
console.print(
|
|
126
|
+
f"🚀 Uploading [bold cyan]{file_name}[/bold cyan] "
|
|
127
|
+
f"({file_size / 1024 / 1024:.2f} MB, {total_parts} chunk{'s' if total_parts != 1 else ''})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
uploaded_messages = []
|
|
131
|
+
|
|
132
|
+
with Progress(
|
|
133
|
+
SpinnerColumn(),
|
|
134
|
+
TextColumn("[progress.description]{task.description}"),
|
|
135
|
+
BarColumn(),
|
|
136
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
137
|
+
TimeRemainingColumn(),
|
|
138
|
+
console=console,
|
|
139
|
+
) as progress:
|
|
140
|
+
task = progress.add_task("Uploading", total=total_parts)
|
|
141
|
+
|
|
142
|
+
for i in range(total_parts):
|
|
143
|
+
start = i * CHUNK_SIZE
|
|
144
|
+
with open(file_path, "rb") as f:
|
|
145
|
+
f.seek(start)
|
|
146
|
+
chunk = f.read(CHUNK_SIZE)
|
|
147
|
+
|
|
148
|
+
# Encrypt if ZKE is active
|
|
149
|
+
if zke_key:
|
|
150
|
+
chunk = encrypt_chunk(chunk, zke_key)
|
|
151
|
+
|
|
152
|
+
part_name = f"{file_name}.part{i + 1:03d}"
|
|
153
|
+
msg = _send_chunk_to_telegram(chunk, part_name, bot_token, channel_id)
|
|
154
|
+
uploaded_messages.append({"index": i, **msg})
|
|
155
|
+
progress.update(task, advance=1)
|
|
156
|
+
|
|
157
|
+
# Register in Firestore
|
|
158
|
+
register_payload = {
|
|
159
|
+
"fileName": file_name,
|
|
160
|
+
"fileSize": file_size,
|
|
161
|
+
"fileType": "application/octet-stream",
|
|
162
|
+
"parentId": folder_id,
|
|
163
|
+
"type": "file",
|
|
164
|
+
"messages": [
|
|
165
|
+
{"message_id": m["message_id"], "file_id": m["file_id"]}
|
|
166
|
+
for m in sorted(uploaded_messages, key=lambda x: x["index"])
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
res = requests.post(
|
|
172
|
+
f"{get_api_url()}/register",
|
|
173
|
+
headers=get_auth_headers(),
|
|
174
|
+
json=register_payload,
|
|
175
|
+
timeout=15,
|
|
176
|
+
)
|
|
177
|
+
if res.status_code == 200:
|
|
178
|
+
console.print(f"[green]✨ Upload complete! '{file_name}' registered.[/green]")
|
|
179
|
+
else:
|
|
180
|
+
console.print(f"[red]Upload worked but registration failed:[/red] {res.text}")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
console.print(f"[red]Registration error:[/red] {e}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def download_file(file_id: str, output_path: str | None = None) -> None:
|
|
186
|
+
"""Download a file from DaemonClient storage."""
|
|
187
|
+
tg = _get_tg_config()
|
|
188
|
+
bot_token = tg["bot_token"]
|
|
189
|
+
|
|
190
|
+
# Fetch ZKE key
|
|
191
|
+
zke_key = None
|
|
192
|
+
zke_cfg = _get_zke_config()
|
|
193
|
+
if zke_cfg and zke_cfg.get("enabled"):
|
|
194
|
+
zke_key = _derive_key_from_config(zke_cfg)
|
|
195
|
+
|
|
196
|
+
# Find the file in the list
|
|
197
|
+
console.print(f"[dim]Fetching metadata for {file_id}...[/dim]")
|
|
198
|
+
list_res = requests.get(f"{get_api_url()}/list", headers=get_auth_headers(), timeout=15)
|
|
199
|
+
if list_res.status_code != 200:
|
|
200
|
+
console.print(f"[red]Failed to list files:[/red] {list_res.text}")
|
|
201
|
+
raise typer.Exit(1)
|
|
202
|
+
|
|
203
|
+
files = list_res.json().get("files", [])
|
|
204
|
+
target = next((f for f in files if f["id"] == file_id), None)
|
|
205
|
+
if not target:
|
|
206
|
+
console.print(f"[red]File ID '{file_id}' not found.[/red]")
|
|
207
|
+
raise typer.Exit(1)
|
|
208
|
+
|
|
209
|
+
file_name = target["fileName"]
|
|
210
|
+
final_path = output_path or file_name
|
|
211
|
+
messages = target.get("messages", [])
|
|
212
|
+
|
|
213
|
+
console.print(
|
|
214
|
+
f"⬇️ Downloading [cyan]{file_name}[/cyan] "
|
|
215
|
+
f"({len(messages)} chunk{'s' if len(messages) != 1 else ''})"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
with Progress(
|
|
219
|
+
SpinnerColumn(),
|
|
220
|
+
TextColumn("[progress.description]{task.description}"),
|
|
221
|
+
BarColumn(),
|
|
222
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
223
|
+
TimeRemainingColumn(),
|
|
224
|
+
console=console,
|
|
225
|
+
) as progress:
|
|
226
|
+
task = progress.add_task("Downloading", total=len(messages))
|
|
227
|
+
|
|
228
|
+
with open(final_path, "wb") as f_out:
|
|
229
|
+
for msg in messages:
|
|
230
|
+
# Get Telegram file path
|
|
231
|
+
info_res = requests.get(
|
|
232
|
+
f"https://api.telegram.org/bot{bot_token}/getFile",
|
|
233
|
+
params={"file_id": msg["file_id"]},
|
|
234
|
+
timeout=30,
|
|
235
|
+
)
|
|
236
|
+
if info_res.status_code != 200:
|
|
237
|
+
console.print(f"[red]Telegram error:[/red] {info_res.text}")
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
|
|
240
|
+
tg_path = info_res.json()["result"]["file_path"]
|
|
241
|
+
download_url = f"https://api.telegram.org/file/bot{bot_token}/{tg_path}"
|
|
242
|
+
|
|
243
|
+
# Stream download
|
|
244
|
+
chunk_res = requests.get(download_url, stream=True, timeout=300)
|
|
245
|
+
chunk_data = b""
|
|
246
|
+
for piece in chunk_res.iter_content(chunk_size=8192):
|
|
247
|
+
chunk_data += piece
|
|
248
|
+
|
|
249
|
+
# Decrypt if ZKE is active
|
|
250
|
+
if zke_key:
|
|
251
|
+
chunk_data = decrypt_chunk(chunk_data, zke_key)
|
|
252
|
+
|
|
253
|
+
f_out.write(chunk_data)
|
|
254
|
+
progress.update(task, advance=1)
|
|
255
|
+
|
|
256
|
+
console.print(f"[green]✅ Downloaded: {final_path}[/green]")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def delete_file(file_id: str, skip_confirm: bool = False) -> None:
|
|
260
|
+
"""Delete a file from Telegram and Firestore."""
|
|
261
|
+
tg = _get_tg_config()
|
|
262
|
+
bot_token = tg["bot_token"]
|
|
263
|
+
channel_id = tg["channel_id"]
|
|
264
|
+
|
|
265
|
+
# Find the file
|
|
266
|
+
list_res = requests.get(f"{get_api_url()}/list", headers=get_auth_headers(), timeout=15)
|
|
267
|
+
files = list_res.json().get("files", [])
|
|
268
|
+
target = next((f for f in files if f["id"] == file_id), None)
|
|
269
|
+
|
|
270
|
+
if not target:
|
|
271
|
+
console.print(f"[red]File ID '{file_id}' not found.[/red]")
|
|
272
|
+
raise typer.Exit(1)
|
|
273
|
+
|
|
274
|
+
file_name = target.get("fileName", "Unknown")
|
|
275
|
+
|
|
276
|
+
if not skip_confirm:
|
|
277
|
+
confirm = typer.confirm(f"Delete '{file_name}'?")
|
|
278
|
+
if not confirm:
|
|
279
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
280
|
+
raise typer.Abort()
|
|
281
|
+
|
|
282
|
+
# Delete chunks from Telegram
|
|
283
|
+
messages = target.get("messages", [])
|
|
284
|
+
console.print(f"[yellow]Deleting {len(messages)} chunks...[/yellow]")
|
|
285
|
+
|
|
286
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), BarColumn(), console=console) as progress:
|
|
287
|
+
task = progress.add_task("Cleaning up", total=len(messages))
|
|
288
|
+
for msg in messages:
|
|
289
|
+
try:
|
|
290
|
+
requests.post(
|
|
291
|
+
f"https://api.telegram.org/bot{bot_token}/deleteMessage",
|
|
292
|
+
json={"chat_id": channel_id, "message_id": msg["message_id"]},
|
|
293
|
+
timeout=15,
|
|
294
|
+
)
|
|
295
|
+
except Exception:
|
|
296
|
+
pass # best-effort
|
|
297
|
+
progress.update(task, advance=1)
|
|
298
|
+
|
|
299
|
+
# Delete from Firestore
|
|
300
|
+
try:
|
|
301
|
+
res = requests.post(
|
|
302
|
+
f"{get_api_url()}/delete",
|
|
303
|
+
headers=get_auth_headers(),
|
|
304
|
+
json={"file_id": file_id},
|
|
305
|
+
timeout=15,
|
|
306
|
+
)
|
|
307
|
+
if res.status_code == 200:
|
|
308
|
+
console.print(f"[green]🗑️ '{file_name}' deleted.[/green]")
|
|
309
|
+
else:
|
|
310
|
+
console.print(f"[red]Registry delete failed:[/red] {res.text}")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
console.print(f"[red]API error:[/red] {e}")
|