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.
@@ -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,6 @@
1
+ typer
2
+ requests
3
+ rich
4
+ python-dotenv
5
+ firebase-admin
6
+ pyrebase4
@@ -0,0 +1,3 @@
1
+ """DaemonClient CLI — Unlimited encrypted cloud storage from your terminal."""
2
+
3
+ __version__ = "1.0.0"
@@ -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}")