televault 0.1.0__py3-none-any.whl

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.
televault/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ TeleVault - Unlimited cloud storage using Telegram MTProto.
3
+
4
+ Features:
5
+ - MTProto Direct (no bot API limits)
6
+ - Zero Local DB (metadata on Telegram)
7
+ - Client-side Encryption (AES-256-GCM)
8
+ - Parallel Processing
9
+ - Folder Support
10
+ - TUI + CLI
11
+ """
12
+ __version__ = "1.0.0"
13
+ __author__ = "Yahya Toubali"
14
+ __email__ = "yahya@yahyatoubali.me"
15
+ __license__ = "MIT"
16
+ __url__ = "https://github.com/yahyatoubali/televault"
televault/chunker.py ADDED
@@ -0,0 +1,189 @@
1
+ """File chunking utilities for TeleVault."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Iterator, BinaryIO
6
+ from dataclasses import dataclass
7
+
8
+ import blake3
9
+
10
+ # Telegram limits: 2GB per file via MTProto
11
+ # Using 100MB chunks for better parallelism and resume capability
12
+ DEFAULT_CHUNK_SIZE = 100 * 1024 * 1024 # 100MB
13
+ MAX_CHUNK_SIZE = 2000 * 1024 * 1024 # ~2GB (with margin)
14
+
15
+
16
+ @dataclass
17
+ class Chunk:
18
+ """A chunk of file data ready for upload."""
19
+
20
+ index: int
21
+ data: bytes
22
+ hash: str
23
+ size: int
24
+
25
+ @property
26
+ def filename(self) -> str:
27
+ """Generate chunk filename."""
28
+ return f"{self.index:04d}.chunk"
29
+
30
+
31
+ def hash_data(data: bytes) -> str:
32
+ """Compute BLAKE3 hash of data (fast, secure)."""
33
+ return blake3.blake3(data).hexdigest()[:32] # 128-bit prefix
34
+
35
+
36
+ def hash_file(path: str | Path) -> str:
37
+ """Compute BLAKE3 hash of entire file (streaming)."""
38
+ hasher = blake3.blake3()
39
+ with open(path, "rb") as f:
40
+ while chunk := f.read(8192):
41
+ hasher.update(chunk)
42
+ return hasher.hexdigest()[:32]
43
+
44
+
45
+ def get_file_size(path: str | Path) -> int:
46
+ """Get file size in bytes."""
47
+ return os.path.getsize(path)
48
+
49
+
50
+ def iter_chunks(
51
+ file_path: str | Path,
52
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
53
+ ) -> Iterator[Chunk]:
54
+ """
55
+ Split a file into chunks.
56
+
57
+ Yields Chunk objects with index, data, hash, and size.
58
+ Memory-efficient: only one chunk in memory at a time.
59
+ """
60
+ if chunk_size > MAX_CHUNK_SIZE:
61
+ raise ValueError(f"Chunk size {chunk_size} exceeds max {MAX_CHUNK_SIZE}")
62
+
63
+ with open(file_path, "rb") as f:
64
+ index = 0
65
+ while True:
66
+ data = f.read(chunk_size)
67
+ if not data:
68
+ break
69
+
70
+ yield Chunk(
71
+ index=index,
72
+ data=data,
73
+ hash=hash_data(data),
74
+ size=len(data),
75
+ )
76
+ index += 1
77
+
78
+
79
+ def count_chunks(file_size: int, chunk_size: int = DEFAULT_CHUNK_SIZE) -> int:
80
+ """Calculate number of chunks for a file."""
81
+ if file_size == 0:
82
+ return 0
83
+ return (file_size + chunk_size - 1) // chunk_size
84
+
85
+
86
+ def read_chunk(
87
+ file_path: str | Path,
88
+ index: int,
89
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
90
+ ) -> Chunk:
91
+ """Read a specific chunk by index."""
92
+ with open(file_path, "rb") as f:
93
+ f.seek(index * chunk_size)
94
+ data = f.read(chunk_size)
95
+ if not data:
96
+ raise ValueError(f"Chunk {index} is empty or out of range")
97
+
98
+ return Chunk(
99
+ index=index,
100
+ data=data,
101
+ hash=hash_data(data),
102
+ size=len(data),
103
+ )
104
+
105
+
106
+ class ChunkWriter:
107
+ """
108
+ Reassemble chunks into a file.
109
+
110
+ Handles out-of-order chunks by writing to correct positions.
111
+ """
112
+
113
+ def __init__(self, output_path: str | Path, total_size: int, chunk_size: int = DEFAULT_CHUNK_SIZE):
114
+ self.output_path = Path(output_path)
115
+ self.total_size = total_size
116
+ self.chunk_size = chunk_size
117
+ self.written_chunks: set[int] = set()
118
+
119
+ # Pre-allocate file
120
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
121
+ with open(self.output_path, "wb") as f:
122
+ f.truncate(total_size)
123
+
124
+ def write_chunk(self, chunk: Chunk) -> None:
125
+ """Write a chunk to the correct position."""
126
+ if chunk.index in self.written_chunks:
127
+ return # Already written
128
+
129
+ offset = chunk.index * self.chunk_size
130
+ with open(self.output_path, "r+b") as f:
131
+ f.seek(offset)
132
+ f.write(chunk.data)
133
+
134
+ self.written_chunks.add(chunk.index)
135
+
136
+ def is_complete(self, expected_chunks: int) -> bool:
137
+ """Check if all chunks have been written."""
138
+ return len(self.written_chunks) == expected_chunks
139
+
140
+ def missing_chunks(self, expected_chunks: int) -> list[int]:
141
+ """Get list of missing chunk indices."""
142
+ return [i for i in range(expected_chunks) if i not in self.written_chunks]
143
+
144
+
145
+ class ChunkBuffer:
146
+ """
147
+ Buffer for streaming chunk creation.
148
+
149
+ Useful when reading from a stream (network, compression, encryption)
150
+ rather than a file.
151
+ """
152
+
153
+ def __init__(self, chunk_size: int = DEFAULT_CHUNK_SIZE):
154
+ self.chunk_size = chunk_size
155
+ self.buffer = bytearray()
156
+ self.index = 0
157
+
158
+ def write(self, data: bytes) -> Iterator[Chunk]:
159
+ """
160
+ Write data to buffer, yielding complete chunks.
161
+ """
162
+ self.buffer.extend(data)
163
+
164
+ while len(self.buffer) >= self.chunk_size:
165
+ chunk_data = bytes(self.buffer[:self.chunk_size])
166
+ self.buffer = self.buffer[self.chunk_size:]
167
+
168
+ yield Chunk(
169
+ index=self.index,
170
+ data=chunk_data,
171
+ hash=hash_data(chunk_data),
172
+ size=len(chunk_data),
173
+ )
174
+ self.index += 1
175
+
176
+ def flush(self) -> Chunk | None:
177
+ """Flush remaining data as final chunk."""
178
+ if not self.buffer:
179
+ return None
180
+
181
+ chunk_data = bytes(self.buffer)
182
+ self.buffer.clear()
183
+
184
+ return Chunk(
185
+ index=self.index,
186
+ data=chunk_data,
187
+ hash=hash_data(chunk_data),
188
+ size=len(chunk_data),
189
+ )
televault/cli.py ADDED
@@ -0,0 +1,445 @@
1
+ """TeleVault CLI - Command line interface."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
12
+ from rich import print as rprint
13
+
14
+ from .core import TeleVault, UploadProgress, DownloadProgress
15
+ from .config import Config, get_config_dir
16
+
17
+
18
+ console = Console()
19
+
20
+
21
+ def format_size(size: int) -> str:
22
+ """Format bytes as human readable."""
23
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
24
+ if size < 1024:
25
+ return f"{size:.1f} {unit}"
26
+ size /= 1024
27
+ return f"{size:.1f} PB"
28
+
29
+
30
+ def run_async(coro):
31
+ """Run async function."""
32
+ return asyncio.get_event_loop().run_until_complete(coro)
33
+
34
+
35
+ @click.group(invoke_without_command=True)
36
+ @click.option("-h", "--help", is_flag=True, help="Show this message and exit.")
37
+ @click.pass_context
38
+ def main(ctx, help):
39
+ """TeleVault - Unlimited cloud storage using Telegram."""
40
+ if help or ctx.invoked_subcommand is None:
41
+ click.echo(ctx.get_help())
42
+
43
+
44
+ @main.command()
45
+ @click.option("--phone", "-p", help="Phone number for login")
46
+ def login(phone: Optional[str]):
47
+ """Login to Telegram."""
48
+ async def _login():
49
+ vault = TeleVault()
50
+ await vault.connect(skip_channel=True) # Don't try to access channel yet
51
+
52
+ console.print("[bold blue]TeleVault Login[/bold blue]")
53
+ console.print("You'll receive a code on Telegram.\n")
54
+
55
+ session = await vault.login(phone)
56
+
57
+ console.print("\n[bold green]✓ Login successful![/bold green]")
58
+ console.print(f"Session saved to: {get_config_dir() / 'telegram.json'}")
59
+
60
+ # Now set up channel if configured
61
+ if vault.config.channel_id:
62
+ await vault.telegram.set_channel(vault.config.channel_id)
63
+ console.print(f"Channel configured: {vault.config.channel_id}")
64
+
65
+ await vault.disconnect()
66
+
67
+ run_async(_login())
68
+
69
+
70
+ @main.command()
71
+ def logout():
72
+ """Logout and clear session."""
73
+ config_dir = get_config_dir()
74
+ telegram_config = config_dir / "telegram.json"
75
+
76
+ if telegram_config.exists():
77
+ telegram_config.unlink()
78
+ console.print("[green]✓ Logged out successfully[/green]")
79
+ else:
80
+ console.print("[yellow]Not logged in[/yellow]")
81
+
82
+
83
+ @main.command()
84
+ @click.option("--channel-id", "-c", type=int, help="Existing channel ID to use")
85
+ def setup(channel_id: Optional[int]):
86
+ """Set up storage channel."""
87
+ async def _setup():
88
+ vault = TeleVault()
89
+ await vault.connect()
90
+
91
+ if channel_id:
92
+ cid = await vault.setup_channel(channel_id)
93
+ console.print(f"[green]✓ Using channel: {cid}[/green]")
94
+ else:
95
+ console.print("[bold]Creating new storage channel...[/bold]")
96
+ cid = await vault.setup_channel()
97
+ console.print(f"[green]✓ Created channel: {cid}[/green]")
98
+
99
+ await vault.disconnect()
100
+
101
+ run_async(_setup())
102
+
103
+
104
+ @main.command()
105
+ @click.argument("file_path", type=click.Path(exists=True))
106
+ @click.option("--password", "-p", help="Encryption password", envvar="TELEVAULT_PASSWORD")
107
+ @click.option("--no-compress", is_flag=True, help="Disable compression")
108
+ @click.option("--no-encrypt", is_flag=True, help="Disable encryption")
109
+ @click.option("--recursive", "-r", is_flag=True, help="Upload directory recursively")
110
+ def push(file_path: str, password: Optional[str], no_compress: bool, no_encrypt: bool, recursive: bool):
111
+ """Upload a file or directory to TeleVault."""
112
+ async def _push():
113
+ config = Config.load_or_create()
114
+
115
+ if no_compress:
116
+ config.compression = False
117
+ if no_encrypt:
118
+ config.encryption = False
119
+
120
+ if config.encryption and not password:
121
+ console.print("[yellow]Warning: Encryption enabled but no password provided.[/yellow]")
122
+ console.print("Set password with --password or TELEVAULT_PASSWORD env var.")
123
+ console.print("Use --no-encrypt to disable encryption.\n")
124
+
125
+ vault = TeleVault(config=config, password=password)
126
+ await vault.connect()
127
+
128
+ file_path_obj = Path(file_path)
129
+
130
+ # Handle directory upload
131
+ if file_path_obj.is_dir():
132
+ if not recursive:
133
+ console.print(f"[red]'{file_path}' is a directory. Use --recursive (-r) to upload.[/red]")
134
+ await vault.disconnect()
135
+ return
136
+
137
+ files = list(file_path_obj.rglob("*"))
138
+ files = [f for f in files if f.is_file()]
139
+
140
+ if not files:
141
+ console.print("[yellow]No files found in directory.[/yellow]")
142
+ await vault.disconnect()
143
+ return
144
+
145
+ console.print(f"[bold]Uploading {len(files)} files from {file_path_obj.name}/[/bold]\n")
146
+
147
+ for i, f in enumerate(files, 1):
148
+ rel_path = f.relative_to(file_path_obj)
149
+ console.print(f"[{i}/{len(files)}] {rel_path}...", end=" ")
150
+ try:
151
+ metadata = await vault.upload(f)
152
+ console.print(f"[green]✓[/green] ({format_size(metadata.size)})")
153
+ except Exception as e:
154
+ console.print(f"[red]✗ {e}[/red]")
155
+
156
+ console.print(f"\n[bold green]✓ Uploaded {len(files)} files[/bold green]")
157
+ else:
158
+ # Single file upload with progress
159
+ file_size = file_path_obj.stat().st_size
160
+
161
+ with Progress(
162
+ SpinnerColumn(),
163
+ TextColumn("[progress.description]{task.description}"),
164
+ BarColumn(),
165
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
166
+ TextColumn("({task.fields[size]})"),
167
+ TimeRemainingColumn(),
168
+ console=console,
169
+ refresh_per_second=10,
170
+ ) as progress:
171
+ task = progress.add_task(
172
+ f"Uploading {file_path_obj.name}",
173
+ total=100,
174
+ size=format_size(file_size)
175
+ )
176
+
177
+ def on_progress(p: UploadProgress):
178
+ progress.update(task, completed=p.percent)
179
+
180
+ metadata = await vault.upload(file_path, progress_callback=on_progress)
181
+ progress.update(task, completed=100) # Ensure 100% at end
182
+
183
+ console.print(f"\n[bold green]✓ Uploaded successfully![/bold green]")
184
+ console.print(f" File ID: {metadata.id}")
185
+ console.print(f" Size: {format_size(metadata.size)}")
186
+ console.print(f" Chunks: {metadata.chunk_count}")
187
+ console.print(f" Encrypted: {'Yes' if metadata.encrypted else 'No'}")
188
+ console.print(f" Compressed: {'Yes' if metadata.compressed else 'No'}")
189
+
190
+ await vault.disconnect()
191
+
192
+ run_async(_push())
193
+
194
+
195
+ @main.command()
196
+ @click.argument("file_id_or_name")
197
+ @click.option("--output", "-o", type=click.Path(), help="Output path")
198
+ @click.option("--password", "-p", help="Decryption password", envvar="TELEVAULT_PASSWORD")
199
+ def pull(file_id_or_name: str, output: Optional[str], password: Optional[str]):
200
+ """Download a file from TeleVault."""
201
+ async def _pull():
202
+ vault = TeleVault(password=password)
203
+ await vault.connect()
204
+
205
+ with Progress(
206
+ SpinnerColumn(),
207
+ TextColumn("[progress.description]{task.description}"),
208
+ BarColumn(),
209
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
210
+ TimeRemainingColumn(),
211
+ console=console,
212
+ refresh_per_second=10,
213
+ ) as progress:
214
+ task = progress.add_task(f"Downloading {file_id_or_name}", total=100)
215
+
216
+ def on_progress(p: DownloadProgress):
217
+ progress.update(task, completed=p.percent)
218
+
219
+ try:
220
+ output_path = await vault.download(
221
+ file_id_or_name,
222
+ output_path=output,
223
+ progress_callback=on_progress,
224
+ )
225
+ progress.update(task, completed=100) # Ensure 100% at end
226
+ except FileNotFoundError:
227
+ console.print(f"[red]✗ File not found: {file_id_or_name}[/red]")
228
+ await vault.disconnect()
229
+ sys.exit(1)
230
+ except ValueError as e:
231
+ console.print(f"[red]✗ Error: {e}[/red]")
232
+ await vault.disconnect()
233
+ sys.exit(1)
234
+
235
+ console.print(f"\n[bold green]✓ Downloaded to: {output_path}[/bold green]")
236
+
237
+ await vault.disconnect()
238
+
239
+ run_async(_pull())
240
+
241
+
242
+ @main.command(name="ls")
243
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
244
+ @click.option("--sort", type=click.Choice(["name", "size", "date"]), default="name")
245
+ def list_files(as_json: bool, sort: str):
246
+ """List all files in the vault."""
247
+ async def _list():
248
+ vault = TeleVault()
249
+ await vault.connect()
250
+
251
+ files = await vault.list_files()
252
+
253
+ # Sort
254
+ if sort == "name":
255
+ files.sort(key=lambda f: f.name.lower())
256
+ elif sort == "size":
257
+ files.sort(key=lambda f: f.size, reverse=True)
258
+ elif sort == "date":
259
+ files.sort(key=lambda f: f.created_at, reverse=True)
260
+
261
+ if as_json:
262
+ import json
263
+ output = [{"id": f.id, "name": f.name, "size": f.size} for f in files]
264
+ click.echo(json.dumps(output, indent=2))
265
+ else:
266
+ if not files:
267
+ console.print("[dim]No files in vault[/dim]")
268
+ else:
269
+ table = Table(title="TeleVault Files")
270
+ table.add_column("ID", style="dim")
271
+ table.add_column("Name")
272
+ table.add_column("Size", justify="right")
273
+ table.add_column("Chunks", justify="right")
274
+ table.add_column("Encrypted")
275
+
276
+ for f in files:
277
+ table.add_row(
278
+ f.id[:8],
279
+ f.name,
280
+ format_size(f.size),
281
+ str(f.chunk_count),
282
+ "🔒" if f.encrypted else "📄",
283
+ )
284
+
285
+ console.print(table)
286
+ console.print(f"\n[dim]{len(files)} file(s), {format_size(sum(f.size for f in files))} total[/dim]")
287
+
288
+ await vault.disconnect()
289
+
290
+ run_async(_list())
291
+
292
+
293
+ @main.command()
294
+ @click.argument("query")
295
+ def search(query: str):
296
+ """Search files by name."""
297
+ async def _search():
298
+ vault = TeleVault()
299
+ await vault.connect()
300
+
301
+ files = await vault.search(query)
302
+
303
+ if not files:
304
+ console.print(f"[dim]No files matching '{query}'[/dim]")
305
+ else:
306
+ for f in files:
307
+ console.print(f"[cyan]{f.id[:8]}[/cyan] {f.name} ({format_size(f.size)})")
308
+
309
+ await vault.disconnect()
310
+
311
+ run_async(_search())
312
+
313
+
314
+ @main.command()
315
+ @click.argument("file_id_or_name")
316
+ def info(file_id_or_name: str):
317
+ """Show detailed file information."""
318
+ async def _info():
319
+ vault = TeleVault()
320
+ await vault.connect()
321
+
322
+ try:
323
+ # Find file
324
+ files = await vault.search(file_id_or_name)
325
+ if not files:
326
+ # Try by ID
327
+ index = await vault.telegram.get_index()
328
+ for fid, msg_id in index.files.items():
329
+ if fid.startswith(file_id_or_name):
330
+ metadata = await vault.telegram.get_metadata(msg_id)
331
+ files = [metadata]
332
+ break
333
+
334
+ if not files:
335
+ console.print(f"[red]File not found: {file_id_or_name}[/red]")
336
+ await vault.disconnect()
337
+ return
338
+
339
+ f = files[0]
340
+
341
+ console.print(f"[bold]{f.name}[/bold]\n")
342
+ console.print(f" ID: {f.id}")
343
+ console.print(f" Size: {format_size(f.size)}")
344
+ console.print(f" Hash: {f.hash}")
345
+ console.print(f" Chunks: {f.chunk_count}")
346
+ console.print(f" Encrypted: {'Yes 🔒' if f.encrypted else 'No'}")
347
+ console.print(f" Compressed: {'Yes' if f.compressed else 'No'}")
348
+ if f.compressed and f.compression_ratio:
349
+ console.print(f" Comp. ratio: {f.compression_ratio:.1%}")
350
+ if f.mime_type:
351
+ console.print(f" MIME type: {f.mime_type}")
352
+
353
+ from datetime import datetime
354
+ created = datetime.fromtimestamp(f.created_at)
355
+ console.print(f" Created: {created.strftime('%Y-%m-%d %H:%M')}")
356
+
357
+ if f.chunks:
358
+ stored = sum(c.size for c in f.chunks)
359
+ console.print(f" Stored size: {format_size(stored)}")
360
+
361
+ except Exception as e:
362
+ console.print(f"[red]Error: {e}[/red]")
363
+
364
+ await vault.disconnect()
365
+
366
+ run_async(_info())
367
+
368
+
369
+ @main.command()
370
+ @click.argument("file_id_or_name")
371
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
372
+ def rm(file_id_or_name: str, yes: bool):
373
+ """Delete a file from the vault."""
374
+ async def _rm():
375
+ vault = TeleVault()
376
+ await vault.connect()
377
+
378
+ if not yes:
379
+ if not click.confirm(f"Delete '{file_id_or_name}'?"):
380
+ console.print("[dim]Cancelled[/dim]")
381
+ await vault.disconnect()
382
+ return
383
+
384
+ deleted = await vault.delete(file_id_or_name)
385
+
386
+ if deleted:
387
+ console.print(f"[green]✓ Deleted: {file_id_or_name}[/green]")
388
+ else:
389
+ console.print(f"[red]✗ File not found: {file_id_or_name}[/red]")
390
+
391
+ await vault.disconnect()
392
+
393
+ run_async(_rm())
394
+
395
+
396
+ @main.command()
397
+ def status():
398
+ """Show vault status."""
399
+ async def _status():
400
+ vault = TeleVault()
401
+ await vault.connect()
402
+
403
+ try:
404
+ status = await vault.get_status()
405
+
406
+ console.print("[bold]TeleVault Status[/bold]\n")
407
+ console.print(f" Channel: {status['channel_id']}")
408
+ console.print(f" Files: {status['file_count']}")
409
+ console.print(f" Total size: {format_size(status['total_size'])}")
410
+ console.print(f" Stored size: {format_size(status['stored_size'])}")
411
+ console.print(f" Compression ratio: {status['compression_ratio']:.1%}")
412
+ except Exception as e:
413
+ console.print(f"[red]Error: {e}[/red]")
414
+ console.print("\n[dim]Have you run 'televault login' and 'televault setup'?[/dim]")
415
+
416
+ await vault.disconnect()
417
+
418
+ run_async(_status())
419
+
420
+
421
+ @main.command()
422
+ def whoami():
423
+ """Show current Telegram account."""
424
+ async def _whoami():
425
+ vault = TeleVault()
426
+ await vault.connect()
427
+
428
+ me = await vault.telegram._client.get_me()
429
+
430
+ console.print(f"[bold]{me.first_name}[/bold]", end="")
431
+ if me.last_name:
432
+ console.print(f" {me.last_name}", end="")
433
+ console.print()
434
+
435
+ if me.username:
436
+ console.print(f" @{me.username}")
437
+ console.print(f" ID: {me.id}")
438
+
439
+ await vault.disconnect()
440
+
441
+ run_async(_whoami())
442
+
443
+
444
+ if __name__ == "__main__":
445
+ main()