tempspace-cli 1.2.8__tar.gz → 1.3.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.
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/PKG-INFO +1 -1
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/cli/tempspace.py +227 -97
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/pyproject.toml +1 -1
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/PKG-INFO +1 -1
- tempspace_cli-1.3.0/tests/test_cli.py +270 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tests/test_main.py +218 -1
- tempspace_cli-1.2.8/tests/test_cli.py +0 -159
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/README.md +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/cli/__init__.py +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/setup.cfg +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/SOURCES.txt +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/dependency_links.txt +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/entry_points.txt +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/requires.txt +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/top_level.txt +0 -0
- {tempspace_cli-1.2.8 → tempspace_cli-1.3.0}/tests/test_binary.py +0 -0
|
@@ -4,8 +4,15 @@ import os
|
|
|
4
4
|
import sys
|
|
5
5
|
import hashlib
|
|
6
6
|
import multiprocessing
|
|
7
|
-
|
|
8
7
|
import math
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import unquote
|
|
15
|
+
|
|
9
16
|
from rich.console import Console
|
|
10
17
|
from rich.panel import Panel
|
|
11
18
|
from rich.table import Table
|
|
@@ -13,16 +20,62 @@ from rich import box
|
|
|
13
20
|
import qrcode
|
|
14
21
|
from rich.prompt import Prompt, Confirm
|
|
15
22
|
from rich.progress import Progress, BarColumn, TextColumn, TransferSpeedColumn, TimeRemainingColumn
|
|
16
|
-
|
|
17
23
|
from rich.live import Live
|
|
18
|
-
import shutil
|
|
19
|
-
import tempfile
|
|
20
24
|
from requests.adapters import HTTPAdapter
|
|
21
25
|
from urllib3.util.retry import Retry
|
|
22
26
|
|
|
23
|
-
# Default configuration
|
|
24
27
|
DEFAULT_SERVER_URL = "https://tempspace.needrp.net"
|
|
25
|
-
CHUNK_SIZE = 1024 * 1024
|
|
28
|
+
CHUNK_SIZE = 1024 * 1024
|
|
29
|
+
REQUEST_TIMEOUT = 30
|
|
30
|
+
VALID_HOURS = [1, 3, 6, 12, 24, 48, 168, 336, 720]
|
|
31
|
+
CONFIG_DIR = Path.home() / ".tempspace"
|
|
32
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
33
|
+
HISTORY_FILE = CONFIG_DIR / "history.json"
|
|
34
|
+
MAX_HISTORY = 50
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_config() -> dict:
|
|
38
|
+
"""Loads configuration from ~/.tempspace/config.json."""
|
|
39
|
+
if CONFIG_FILE.exists():
|
|
40
|
+
try:
|
|
41
|
+
with open(CONFIG_FILE, 'r') as f:
|
|
42
|
+
return json.load(f)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_config(config: dict):
|
|
49
|
+
"""Saves configuration to ~/.tempspace/config.json."""
|
|
50
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
with open(CONFIG_FILE, 'w') as f:
|
|
52
|
+
json.dump(config, f, indent=2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_history() -> list:
|
|
56
|
+
"""Loads upload history from ~/.tempspace/history.json."""
|
|
57
|
+
if HISTORY_FILE.exists():
|
|
58
|
+
try:
|
|
59
|
+
with open(HISTORY_FILE, 'r') as f:
|
|
60
|
+
return json.load(f)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_history(history: list):
|
|
67
|
+
"""Saves upload history to ~/.tempspace/history.json."""
|
|
68
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
history = history[:MAX_HISTORY]
|
|
70
|
+
with open(HISTORY_FILE, 'w') as f:
|
|
71
|
+
json.dump(history, f, indent=2)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def add_to_history(entry: dict):
|
|
75
|
+
"""Adds an upload entry to history."""
|
|
76
|
+
history = load_history()
|
|
77
|
+
history.insert(0, entry)
|
|
78
|
+
save_history(history)
|
|
26
79
|
|
|
27
80
|
def parse_time(time_str: str) -> int:
|
|
28
81
|
"""Parses a user-provided time string into a total number of hours.
|
|
@@ -54,6 +107,11 @@ def parse_time(time_str: str) -> int:
|
|
|
54
107
|
except ValueError:
|
|
55
108
|
return None
|
|
56
109
|
|
|
110
|
+
|
|
111
|
+
def validate_hours(hours: int) -> bool:
|
|
112
|
+
"""Validates that hours is an accepted value by the server."""
|
|
113
|
+
return hours in VALID_HOURS
|
|
114
|
+
|
|
57
115
|
def format_size(size_bytes: int) -> str:
|
|
58
116
|
"""Converts a file size in bytes into a human-readable string.
|
|
59
117
|
|
|
@@ -92,7 +150,7 @@ def calculate_file_hash(filepath: str) -> str:
|
|
|
92
150
|
|
|
93
151
|
|
|
94
152
|
def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]):
|
|
95
|
-
"""Creates a requests Session with automatic retries."""
|
|
153
|
+
"""Creates a requests Session with automatic retries and default timeout."""
|
|
96
154
|
session = requests.Session()
|
|
97
155
|
retry = Retry(
|
|
98
156
|
total=retries,
|
|
@@ -104,74 +162,51 @@ def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 5
|
|
|
104
162
|
adapter = HTTPAdapter(max_retries=retry)
|
|
105
163
|
session.mount('http://', adapter)
|
|
106
164
|
session.mount('https://', adapter)
|
|
165
|
+
session.request_timeout = REQUEST_TIMEOUT
|
|
107
166
|
return session
|
|
108
167
|
|
|
109
168
|
|
|
110
|
-
def zip_directory(path: str) -> str:
|
|
111
|
-
"""Zips a directory and returns the path to the
|
|
112
|
-
# Create a temporary directory to store the zip
|
|
169
|
+
def zip_directory(path: str) -> tuple[str, str]:
|
|
170
|
+
"""Zips a directory and returns the path to the zip file and temp directory."""
|
|
113
171
|
temp_dir = tempfile.mkdtemp()
|
|
114
|
-
# Use the original folder name for the zip file
|
|
115
172
|
base_name = os.path.basename(os.path.normpath(path))
|
|
116
173
|
archive_base = os.path.join(temp_dir, base_name)
|
|
117
|
-
|
|
118
174
|
shutil.make_archive(archive_base, 'zip', path)
|
|
119
|
-
return archive_base + ".zip"
|
|
175
|
+
return archive_base + ".zip", temp_dir
|
|
120
176
|
|
|
121
177
|
|
|
122
178
|
def download_file(console, url, password=None):
|
|
123
179
|
"""Downloads a file from Tempspace."""
|
|
124
180
|
session = get_retry_session()
|
|
125
|
-
|
|
181
|
+
|
|
126
182
|
try:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# or the tool should try to GET the URL.
|
|
137
|
-
# If the server has an API like /api/download/<id>?password=..., that would be best.
|
|
138
|
-
# Looking at upload_file response: `download_link = data.get('url')`.
|
|
139
|
-
# Code audit earlier showed `DEFAULT_SERVER_URL`.
|
|
140
|
-
# Use regex to extract ID from URL if possible, or just try GET.
|
|
141
|
-
|
|
142
|
-
# Simple approach: GET the URL. If it initiates a download (headers), good.
|
|
143
|
-
|
|
144
|
-
session.head(url, allow_redirects=True)
|
|
145
|
-
# Check headers for content-disposition or just proceed
|
|
146
|
-
|
|
147
|
-
if password:
|
|
148
|
-
# If password is needed, headers or query param might be required.
|
|
149
|
-
# Since I don't know the exact server auth mechanism for downloads,
|
|
150
|
-
# I will try passing it as a query param 'password' or header 'X-Password'.
|
|
151
|
-
# Better: ask the user to enter it if the server responds 401/403.
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
# For a streaming download
|
|
155
|
-
response = session.get(url, stream=True, params={'password': password} if password else None)
|
|
183
|
+
response = session.get(url, stream=True, params={'password': password} if password else None, timeout=REQUEST_TIMEOUT)
|
|
184
|
+
|
|
185
|
+
if response.status_code in (401, 403):
|
|
186
|
+
if password:
|
|
187
|
+
console.print(Panel("[bold red]Error:[/] Incorrect password.", border_style="red"))
|
|
188
|
+
return
|
|
189
|
+
password = Prompt.ask("This file is password protected. Enter password", password=True)
|
|
190
|
+
response = session.get(url, stream=True, params={'password': password}, timeout=REQUEST_TIMEOUT)
|
|
191
|
+
|
|
156
192
|
response.raise_for_status()
|
|
157
|
-
|
|
193
|
+
|
|
158
194
|
total_size = int(response.headers.get('content-length', 0))
|
|
159
195
|
disposition = response.headers.get('content-disposition')
|
|
160
|
-
|
|
196
|
+
|
|
161
197
|
filename = None
|
|
162
198
|
if disposition:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
fname = re.findall(r"filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?", disposition)
|
|
200
|
+
for match in fname:
|
|
201
|
+
filename = match[0] or match[1]
|
|
202
|
+
if filename:
|
|
203
|
+
filename = unquote(filename)
|
|
204
|
+
break
|
|
205
|
+
|
|
170
206
|
if not filename:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
parsed_path = unquote(url.split("?")[0])
|
|
208
|
+
filename = parsed_path.split("/")[-1]
|
|
209
|
+
|
|
175
210
|
if not filename:
|
|
176
211
|
filename = "downloaded_file"
|
|
177
212
|
|
|
@@ -182,21 +217,25 @@ def download_file(console, url, password=None):
|
|
|
182
217
|
TransferSpeedColumn(), "•",
|
|
183
218
|
TimeRemainingColumn(),
|
|
184
219
|
)
|
|
185
|
-
|
|
220
|
+
|
|
186
221
|
with Live(Panel(progress, title="[cyan]Downloading[/cyan]", border_style="cyan", title_align="left")):
|
|
187
222
|
task_id = progress.add_task(filename, total=total_size)
|
|
188
223
|
with open(filename, 'wb') as f:
|
|
189
224
|
for chunk in response.iter_content(CHUNK_SIZE):
|
|
190
225
|
f.write(chunk)
|
|
191
226
|
progress.update(task_id, advance=len(chunk))
|
|
192
|
-
|
|
227
|
+
|
|
193
228
|
console.print(Panel(f"[bold green]Download successful![/] Saved to '{filename}'", border_style="green"))
|
|
194
229
|
|
|
230
|
+
except requests.exceptions.Timeout:
|
|
231
|
+
console.print(Panel("[bold red]Error:[/] Download timed out.", border_style="red"))
|
|
232
|
+
except requests.exceptions.ConnectionError:
|
|
233
|
+
console.print(Panel("[bold red]Error:[/] Connection failed. Check your internet connection.", border_style="red"))
|
|
195
234
|
except Exception as e:
|
|
196
235
|
console.print(Panel(f"[bold red]Download failed:[/] {e}", border_style="red"))
|
|
197
236
|
|
|
198
237
|
|
|
199
|
-
def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
238
|
+
def upload_file(console, filepath, hours, password, one_time, qr, url, client_id=None):
|
|
200
239
|
"""Handles the upload of a single file.
|
|
201
240
|
|
|
202
241
|
Args:
|
|
@@ -207,13 +246,20 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
207
246
|
one_time (bool): Whether the file should be a one-time download.
|
|
208
247
|
qr (bool): Whether to display a QR code for the download link.
|
|
209
248
|
url (str): The base URL of the Tempspace server.
|
|
249
|
+
client_id (str): Optional client ID for file ownership tracking.
|
|
210
250
|
"""
|
|
211
|
-
# --- Validate Inputs ---
|
|
212
251
|
if not os.path.isfile(filepath):
|
|
213
252
|
console.print(Panel(f"[bold red]Error:[/] File not found at '{filepath}'", title="[bold red]Error[/bold red]", border_style="red"))
|
|
214
|
-
return
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
if password and len(password) < 4:
|
|
256
|
+
console.print(Panel("[bold red]Error:[/] Password must be at least 4 characters.", border_style="red"))
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
if not validate_hours(hours):
|
|
260
|
+
console.print(Panel(f"[bold red]Error:[/] Invalid expiry time '{hours}'. Valid values: {VALID_HOURS}", border_style="red"))
|
|
261
|
+
return False
|
|
215
262
|
|
|
216
|
-
# --- Display File Details ---
|
|
217
263
|
table = Table(title="File Details", show_header=False, box=box.ROUNDED, border_style="cyan")
|
|
218
264
|
table.add_column("Field", style="bold")
|
|
219
265
|
table.add_column("Value")
|
|
@@ -223,27 +269,21 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
223
269
|
table.add_row("Password", "[green]Yes[/green]" if password else "[red]No[/red]")
|
|
224
270
|
table.add_row("One-Time Download", "[green]Yes[/green]" if one_time else "[red]No[/red]")
|
|
225
271
|
|
|
226
|
-
# --- Prepare Upload ---
|
|
227
272
|
upload_url = f"{url.rstrip('/')}"
|
|
228
273
|
filename = os.path.basename(filepath)
|
|
229
274
|
file_size = os.path.getsize(filepath)
|
|
230
275
|
|
|
231
|
-
# --- Calculate Hash ---
|
|
232
276
|
client_hash = calculate_file_hash(filepath)
|
|
233
277
|
table.add_row("File Hash", f"[cyan]{client_hash}[/cyan]")
|
|
234
278
|
console.print(table)
|
|
235
279
|
|
|
236
|
-
|
|
237
|
-
# --- Chunked Upload ---
|
|
238
280
|
response = None
|
|
239
281
|
session = get_retry_session()
|
|
240
282
|
try:
|
|
241
|
-
|
|
242
|
-
initiate_response = session.post(f"{upload_url}/upload/initiate")
|
|
283
|
+
initiate_response = session.post(f"{upload_url}/upload/initiate", timeout=REQUEST_TIMEOUT)
|
|
243
284
|
initiate_response.raise_for_status()
|
|
244
285
|
upload_id = initiate_response.json()['upload_id']
|
|
245
286
|
|
|
246
|
-
# 2. Upload Chunks
|
|
247
287
|
progress = Progress(
|
|
248
288
|
TextColumn("[bold blue]{task.description}", justify="right"),
|
|
249
289
|
BarColumn(bar_width=None),
|
|
@@ -267,29 +307,37 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
267
307
|
chunk_response = session.post(
|
|
268
308
|
f"{upload_url}/upload/chunk",
|
|
269
309
|
data=chunk_data,
|
|
270
|
-
files=files
|
|
310
|
+
files=files,
|
|
311
|
+
timeout=REQUEST_TIMEOUT
|
|
271
312
|
)
|
|
272
313
|
chunk_response.raise_for_status()
|
|
273
314
|
progress.update(task_id, advance=len(chunk))
|
|
274
315
|
|
|
275
|
-
# 3. Finalize Upload
|
|
276
316
|
console.print(Panel("[bold green]Finalizing upload...[/bold green]", border_style="green"))
|
|
277
317
|
finalize_data = {
|
|
278
318
|
'upload_id': upload_id,
|
|
279
319
|
'filename': filename,
|
|
280
320
|
'hours': str(hours),
|
|
281
321
|
'one_time': str(one_time).lower(),
|
|
282
|
-
'client_hash': client_hash,
|
|
322
|
+
'client_hash': client_hash,
|
|
283
323
|
}
|
|
324
|
+
if client_id:
|
|
325
|
+
finalize_data['client_id'] = client_id
|
|
284
326
|
if password:
|
|
285
327
|
finalize_data['password'] = password
|
|
286
328
|
|
|
287
|
-
response = session.post(f"{upload_url}/upload/finalize", data=finalize_data)
|
|
329
|
+
response = session.post(f"{upload_url}/upload/finalize", data=finalize_data, timeout=REQUEST_TIMEOUT)
|
|
288
330
|
response.raise_for_status()
|
|
289
331
|
|
|
332
|
+
except requests.exceptions.Timeout:
|
|
333
|
+
console.print(Panel("[bold red]Error:[/] Upload timed out.", border_style="red"))
|
|
334
|
+
return False
|
|
335
|
+
except requests.exceptions.ConnectionError:
|
|
336
|
+
console.print(Panel("[bold red]Error:[/] Connection failed. Check your internet connection.", border_style="red"))
|
|
337
|
+
return False
|
|
290
338
|
except FileNotFoundError:
|
|
291
|
-
console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.",
|
|
292
|
-
return
|
|
339
|
+
console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", border_style="red"))
|
|
340
|
+
return False
|
|
293
341
|
except requests.exceptions.RequestException as e:
|
|
294
342
|
error_message = str(e)
|
|
295
343
|
if e.response:
|
|
@@ -297,13 +345,12 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
297
345
|
error_message = e.response.json().get('detail', e.response.text)
|
|
298
346
|
except Exception:
|
|
299
347
|
error_message = e.response.text
|
|
300
|
-
console.print(Panel(f"[bold red]An error occurred:[/] {error_message}",
|
|
301
|
-
return
|
|
348
|
+
console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", border_style="red"))
|
|
349
|
+
return False
|
|
302
350
|
except Exception as e:
|
|
303
|
-
console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}",
|
|
304
|
-
return
|
|
351
|
+
console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", border_style="red"))
|
|
352
|
+
return False
|
|
305
353
|
|
|
306
|
-
# --- Handle Response ---
|
|
307
354
|
if response is not None:
|
|
308
355
|
if response.status_code == 200:
|
|
309
356
|
try:
|
|
@@ -327,8 +374,17 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
327
374
|
qr_code.make(fit=True)
|
|
328
375
|
qr_code.print_ascii()
|
|
329
376
|
|
|
377
|
+
from datetime import datetime
|
|
378
|
+
add_to_history({
|
|
379
|
+
'filename': filename,
|
|
380
|
+
'url': download_link,
|
|
381
|
+
'hash': file_hash,
|
|
382
|
+
'timestamp': datetime.now().isoformat()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
return True
|
|
386
|
+
|
|
330
387
|
except requests.exceptions.JSONDecodeError:
|
|
331
|
-
# Fallback for older servers or unexpected plain text responses
|
|
332
388
|
download_link = response.text.strip()
|
|
333
389
|
success_panel = Panel(f"[bold green]Upload successful![/bold green]\n\nDownload Link: {download_link}",
|
|
334
390
|
title="[bold cyan]Success[/bold cyan]", border_style="green")
|
|
@@ -339,13 +395,43 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
339
395
|
qr_code.add_data(download_link)
|
|
340
396
|
qr_code.make(fit=True)
|
|
341
397
|
qr_code.print_ascii()
|
|
398
|
+
|
|
399
|
+
from datetime import datetime
|
|
400
|
+
add_to_history({
|
|
401
|
+
'filename': filename,
|
|
402
|
+
'url': download_link,
|
|
403
|
+
'hash': None,
|
|
404
|
+
'timestamp': datetime.now().isoformat()
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
return True
|
|
342
408
|
else:
|
|
343
409
|
try:
|
|
344
410
|
error_details = response.json()
|
|
345
411
|
error_message = error_details.get('detail', 'No details provided.')
|
|
346
412
|
except requests.exceptions.JSONDecodeError:
|
|
347
413
|
error_message = response.text
|
|
348
|
-
console.print(Panel(f"[bold red]Error:[/] Upload failed with status code {response.status_code}\n[red]Server message:[/] {error_message}",
|
|
414
|
+
console.print(Panel(f"[bold red]Error:[/] Upload failed with status code {response.status_code}\n[red]Server message:[/] {error_message}", border_style="red"))
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def get_client_id() -> str:
|
|
421
|
+
"""Returns a persistent client ID from config, environment, or generates one."""
|
|
422
|
+
client_id = os.environ.get('TEMPSPACE_CLIENT_ID')
|
|
423
|
+
if client_id:
|
|
424
|
+
return client_id
|
|
425
|
+
|
|
426
|
+
config = load_config()
|
|
427
|
+
client_id = config.get('client_id')
|
|
428
|
+
if client_id:
|
|
429
|
+
return client_id
|
|
430
|
+
|
|
431
|
+
client_id = f"cli_{uuid.uuid4().hex[:12]}"
|
|
432
|
+
config['client_id'] = client_id
|
|
433
|
+
save_config(config)
|
|
434
|
+
return client_id
|
|
349
435
|
|
|
350
436
|
|
|
351
437
|
def main():
|
|
@@ -356,22 +442,52 @@ def main():
|
|
|
356
442
|
"""
|
|
357
443
|
console = Console()
|
|
358
444
|
|
|
359
|
-
# --- Handle Download Command ---
|
|
360
445
|
if len(sys.argv) > 1 and sys.argv[1] == 'download':
|
|
361
446
|
parser = argparse.ArgumentParser(description="Download a file from Tempspace.")
|
|
362
447
|
parser.add_argument("url", help="The URL of the file to download.")
|
|
363
448
|
parser.add_argument("-p", "--password", help="The password for the file.")
|
|
364
|
-
|
|
365
|
-
# We process only the download arguments
|
|
449
|
+
|
|
366
450
|
if len(sys.argv) == 2 and (sys.argv[1] == '-h' or sys.argv[1] == '--help'):
|
|
367
451
|
parser.print_help()
|
|
368
452
|
sys.exit(0)
|
|
369
|
-
|
|
453
|
+
|
|
370
454
|
args = parser.parse_args(sys.argv[2:])
|
|
371
455
|
download_file(console, args.url, args.password)
|
|
372
456
|
return
|
|
373
457
|
|
|
374
|
-
|
|
458
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'history':
|
|
459
|
+
history = load_history()
|
|
460
|
+
if not history:
|
|
461
|
+
console.print(Panel("[yellow]No upload history found.[/yellow]", border_style="yellow"))
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
table = Table(title="Upload History", show_header=True, box=box.ROUNDED, border_style="cyan")
|
|
465
|
+
table.add_column("#", style="dim", width=4)
|
|
466
|
+
table.add_column("Filename", style="bold")
|
|
467
|
+
table.add_column("URL", style="green")
|
|
468
|
+
table.add_column("Date", style="cyan")
|
|
469
|
+
|
|
470
|
+
for i, entry in enumerate(history[:20], 1):
|
|
471
|
+
from datetime import datetime
|
|
472
|
+
date_str = datetime.fromisoformat(entry['timestamp']).strftime("%Y-%m-%d %H:%M")
|
|
473
|
+
table.add_row(str(i), entry['filename'], entry['url'], date_str)
|
|
474
|
+
|
|
475
|
+
console.print(table)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'config':
|
|
479
|
+
config = load_config()
|
|
480
|
+
if len(sys.argv) > 2 and sys.argv[2] == 'show':
|
|
481
|
+
console.print(Panel(json.dumps(config, indent=2), title="Config", border_style="cyan"))
|
|
482
|
+
elif len(sys.argv) > 3 and sys.argv[2] == 'set':
|
|
483
|
+
key, value = sys.argv[3].split('=', 1) if '=' in sys.argv[3] else (sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else '')
|
|
484
|
+
config[key] = value
|
|
485
|
+
save_config(config)
|
|
486
|
+
console.print(Panel(f"[green]Set {key}={value}[/green]", border_style="green"))
|
|
487
|
+
else:
|
|
488
|
+
console.print("Usage: tempspace config [show|set KEY=VALUE]")
|
|
489
|
+
return
|
|
490
|
+
|
|
375
491
|
console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
|
|
376
492
|
|
|
377
493
|
parser = argparse.ArgumentParser(
|
|
@@ -391,9 +507,7 @@ def main():
|
|
|
391
507
|
|
|
392
508
|
filepaths = args.filepaths
|
|
393
509
|
|
|
394
|
-
# --- Interactive Mode ---
|
|
395
510
|
if args.it:
|
|
396
|
-
# Interactive mode only supports a single file, so we overwrite the filepaths list
|
|
397
511
|
filepath = Prompt.ask("Enter the path to the file you want to upload")
|
|
398
512
|
filepaths = [filepath]
|
|
399
513
|
args.time = Prompt.ask("Set the file's expiration time (e.g., '24h', '7d')", default='24')
|
|
@@ -401,7 +515,6 @@ def main():
|
|
|
401
515
|
args.one_time = Confirm.ask("Delete the file after the first download?", default=False)
|
|
402
516
|
args.qr = Confirm.ask("Display a QR code of the download link?", default=False)
|
|
403
517
|
|
|
404
|
-
# --- Validate Inputs ---
|
|
405
518
|
if not filepaths:
|
|
406
519
|
console.print(Panel("[bold red]Error:[/] No file path(s) provided.", title="[bold red]Error[/bold red]", border_style="red"))
|
|
407
520
|
parser.print_help()
|
|
@@ -412,24 +525,41 @@ def main():
|
|
|
412
525
|
console.print(Panel(f"[bold red]Error:[/] Invalid time format '{args.time}'. Use formats like '24h', '7d', or '360'.", title="[bold red]Error[/bold red]", border_style="red"))
|
|
413
526
|
sys.exit(1)
|
|
414
527
|
|
|
415
|
-
|
|
416
|
-
|
|
528
|
+
if not validate_hours(hours):
|
|
529
|
+
console.print(Panel(f"[bold red]Error:[/] Invalid expiry time '{hours}'. Valid values: {VALID_HOURS}", title="[bold red]Error[/bold red]", border_style="red"))
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
|
|
532
|
+
if args.password and len(args.password) < 4:
|
|
533
|
+
console.print(Panel("[bold red]Error:[/] Password must be at least 4 characters.", title="[bold red]Error[/bold red]", border_style="red"))
|
|
534
|
+
sys.exit(1)
|
|
535
|
+
|
|
536
|
+
client_id = get_client_id()
|
|
537
|
+
all_success = True
|
|
538
|
+
|
|
417
539
|
for i, filepath in enumerate(filepaths):
|
|
418
540
|
actual_path = filepath
|
|
419
541
|
is_temp = False
|
|
420
|
-
|
|
542
|
+
temp_dir = None
|
|
543
|
+
|
|
421
544
|
if os.path.isdir(filepath):
|
|
422
545
|
console.print(f"[bold yellow]Zipping directory '{os.path.basename(filepath)}'...[/]")
|
|
423
|
-
actual_path = zip_directory(filepath)
|
|
546
|
+
actual_path, temp_dir = zip_directory(filepath)
|
|
424
547
|
is_temp = True
|
|
425
548
|
|
|
426
549
|
if len(filepaths) > 1:
|
|
427
550
|
console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(actual_path)}[/bold yellow]\n")
|
|
428
|
-
|
|
429
|
-
upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url)
|
|
430
|
-
|
|
551
|
+
|
|
552
|
+
success = upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url, client_id)
|
|
553
|
+
if not success:
|
|
554
|
+
all_success = False
|
|
555
|
+
|
|
431
556
|
if is_temp:
|
|
432
557
|
os.remove(actual_path)
|
|
558
|
+
if temp_dir:
|
|
559
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
560
|
+
|
|
561
|
+
if not all_success:
|
|
562
|
+
sys.exit(1)
|
|
433
563
|
|
|
434
564
|
|
|
435
565
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
from cli.tempspace import parse_time, format_size, calculate_file_hash, zip_directory, upload_file, download_file, main, validate_hours
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --- Unit Tests ---
|
|
13
|
+
|
|
14
|
+
def test_parse_time():
|
|
15
|
+
assert parse_time("7d") == 168
|
|
16
|
+
assert parse_time("24h") == 24
|
|
17
|
+
assert parse_time("360") == 360
|
|
18
|
+
assert parse_time("1D") == 24
|
|
19
|
+
assert parse_time("2H") == 2
|
|
20
|
+
assert parse_time(" 7d ") == 168
|
|
21
|
+
assert parse_time("invalid") is None
|
|
22
|
+
assert parse_time("1w") is None
|
|
23
|
+
|
|
24
|
+
def test_format_size():
|
|
25
|
+
assert format_size(0) == "0B"
|
|
26
|
+
assert format_size(1023) == "1023.0 B"
|
|
27
|
+
assert format_size(100) == "100.0 B"
|
|
28
|
+
assert format_size(1024) == "1.0 KB"
|
|
29
|
+
assert format_size(1024 * 1024) == "1.0 MB"
|
|
30
|
+
assert format_size(1024 * 1024 * 1024 * 2.5) == "2.5 GB"
|
|
31
|
+
|
|
32
|
+
def test_validate_hours():
|
|
33
|
+
assert validate_hours(24) is True
|
|
34
|
+
assert validate_hours(1) is True
|
|
35
|
+
assert validate_hours(720) is True
|
|
36
|
+
assert validate_hours(999) is False
|
|
37
|
+
assert validate_hours(0) is False
|
|
38
|
+
assert validate_hours(5) is False
|
|
39
|
+
|
|
40
|
+
def test_calculate_file_hash():
|
|
41
|
+
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
42
|
+
f.write(b"hello world")
|
|
43
|
+
filepath = f.name
|
|
44
|
+
try:
|
|
45
|
+
expected_hash = hashlib.sha256(b"hello world").hexdigest()
|
|
46
|
+
assert calculate_file_hash(filepath) == expected_hash
|
|
47
|
+
finally:
|
|
48
|
+
os.remove(filepath)
|
|
49
|
+
|
|
50
|
+
def test_zip_directory():
|
|
51
|
+
temp_dir = tempfile.mkdtemp()
|
|
52
|
+
try:
|
|
53
|
+
os.makedirs(os.path.join(temp_dir, "subdir"))
|
|
54
|
+
with open(os.path.join(temp_dir, "file1.txt"), "w") as f:
|
|
55
|
+
f.write("content1")
|
|
56
|
+
|
|
57
|
+
zip_path, zip_temp_dir = zip_directory(temp_dir)
|
|
58
|
+
|
|
59
|
+
assert os.path.exists(zip_path)
|
|
60
|
+
assert zip_path.endswith(".zip")
|
|
61
|
+
assert os.path.basename(temp_dir) in os.path.basename(zip_path)
|
|
62
|
+
assert os.path.exists(zip_temp_dir)
|
|
63
|
+
|
|
64
|
+
os.remove(zip_path)
|
|
65
|
+
shutil.rmtree(zip_temp_dir)
|
|
66
|
+
finally:
|
|
67
|
+
shutil.rmtree(temp_dir)
|
|
68
|
+
|
|
69
|
+
# --- Mock Tests ---
|
|
70
|
+
|
|
71
|
+
@patch('cli.tempspace.requests.Session')
|
|
72
|
+
def test_upload_file_success(mock_session_cls, capsys):
|
|
73
|
+
mock_session = mock_session_cls.return_value
|
|
74
|
+
|
|
75
|
+
mock_session.post.side_effect = [
|
|
76
|
+
MagicMock(status_code=200, json=lambda: {'upload_id': '123'}),
|
|
77
|
+
MagicMock(status_code=200),
|
|
78
|
+
MagicMock(status_code=200, json=lambda: {'url': 'http://test.com/file', 'hash': 'abc', 'hash_verified': True})
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
82
|
+
f.write(b"test content")
|
|
83
|
+
filepath = f.name
|
|
84
|
+
|
|
85
|
+
mock_console = MagicMock()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = upload_file(mock_console, filepath, 24, None, False, False, "http://test-server", "test-client-id")
|
|
89
|
+
|
|
90
|
+
assert result is True
|
|
91
|
+
assert mock_session.post.call_count == 3
|
|
92
|
+
mock_session.post.call_args_list[0][0][0].endswith("/upload/initiate")
|
|
93
|
+
mock_session.post.call_args_list[2][0][0].endswith("/upload/finalize")
|
|
94
|
+
|
|
95
|
+
finally:
|
|
96
|
+
os.remove(filepath)
|
|
97
|
+
|
|
98
|
+
@patch('cli.tempspace.requests.Session')
|
|
99
|
+
def test_upload_file_invalid_hours(mock_session_cls):
|
|
100
|
+
mock_session = mock_session_cls.return_value
|
|
101
|
+
|
|
102
|
+
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
103
|
+
f.write(b"test content")
|
|
104
|
+
filepath = f.name
|
|
105
|
+
|
|
106
|
+
mock_console = MagicMock()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = upload_file(mock_console, filepath, 999, None, False, False, "http://test-server")
|
|
110
|
+
assert result is False
|
|
111
|
+
assert mock_session.post.call_count == 0
|
|
112
|
+
finally:
|
|
113
|
+
os.remove(filepath)
|
|
114
|
+
|
|
115
|
+
@patch('cli.tempspace.requests.Session')
|
|
116
|
+
def test_upload_file_short_password(mock_session_cls):
|
|
117
|
+
mock_session = mock_session_cls.return_value
|
|
118
|
+
|
|
119
|
+
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
120
|
+
f.write(b"test content")
|
|
121
|
+
filepath = f.name
|
|
122
|
+
|
|
123
|
+
mock_console = MagicMock()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
result = upload_file(mock_console, filepath, 24, "ab", False, False, "http://test-server")
|
|
127
|
+
assert result is False
|
|
128
|
+
assert mock_session.post.call_count == 0
|
|
129
|
+
finally:
|
|
130
|
+
os.remove(filepath)
|
|
131
|
+
|
|
132
|
+
@patch('cli.tempspace.requests.Session')
|
|
133
|
+
def test_download_file_success(mock_session_cls):
|
|
134
|
+
mock_session = mock_session_cls.return_value
|
|
135
|
+
|
|
136
|
+
content = b"downloaded content"
|
|
137
|
+
mock_response = MagicMock(status_code=200)
|
|
138
|
+
mock_response.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="server_file.txt"'}
|
|
139
|
+
mock_response.iter_content.return_value = [content]
|
|
140
|
+
mock_session.get.return_value = mock_response
|
|
141
|
+
|
|
142
|
+
mock_console = MagicMock()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
download_file(mock_console, "http://test-server/file")
|
|
146
|
+
|
|
147
|
+
assert os.path.exists("server_file.txt")
|
|
148
|
+
with open("server_file.txt", "rb") as f:
|
|
149
|
+
assert f.read() == content
|
|
150
|
+
finally:
|
|
151
|
+
if os.path.exists("server_file.txt"):
|
|
152
|
+
os.remove("server_file.txt")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@patch('sys.argv', ['tempspace', 'file.txt'])
|
|
156
|
+
@patch('cli.tempspace.upload_file')
|
|
157
|
+
def test_main_upload_defaults(mock_upload):
|
|
158
|
+
mock_upload.return_value = True
|
|
159
|
+
with patch('cli.tempspace.os.path.isfile', return_value=True):
|
|
160
|
+
main()
|
|
161
|
+
|
|
162
|
+
mock_upload.assert_called_once()
|
|
163
|
+
args = mock_upload.call_args[0]
|
|
164
|
+
assert args[1] == 'file.txt'
|
|
165
|
+
assert args[2] == 24
|
|
166
|
+
assert args[7] is not None
|
|
167
|
+
|
|
168
|
+
@patch('sys.argv', ['tempspace', 'file.txt', '-t', '1h', '-p', 'secret', '--qr'])
|
|
169
|
+
@patch('cli.tempspace.upload_file')
|
|
170
|
+
def test_main_upload_custom(mock_upload):
|
|
171
|
+
mock_upload.return_value = True
|
|
172
|
+
with patch('cli.tempspace.os.path.isfile', return_value=True):
|
|
173
|
+
main()
|
|
174
|
+
|
|
175
|
+
args = mock_upload.call_args[0]
|
|
176
|
+
assert args[1] == 'file.txt'
|
|
177
|
+
assert args[2] == 1
|
|
178
|
+
assert args[3] == 'secret'
|
|
179
|
+
assert args[5] is True
|
|
180
|
+
|
|
181
|
+
@patch('sys.argv', ['tempspace', 'download', 'http://example.com/file', '-p', 'pass'])
|
|
182
|
+
@patch('cli.tempspace.download_file')
|
|
183
|
+
def test_main_download_command(mock_download):
|
|
184
|
+
main()
|
|
185
|
+
mock_download.assert_called_once()
|
|
186
|
+
args = mock_download.call_args[0]
|
|
187
|
+
assert args[1] == 'http://example.com/file'
|
|
188
|
+
assert args[2] == 'pass'
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@patch('cli.tempspace.requests.Session')
|
|
192
|
+
@patch('cli.tempspace.Prompt.ask', return_value='correct_password')
|
|
193
|
+
def test_download_file_prompts_for_password(mock_prompt, mock_session_cls):
|
|
194
|
+
mock_session = mock_session_cls.return_value
|
|
195
|
+
|
|
196
|
+
content = b"secret content"
|
|
197
|
+
mock_response_401 = MagicMock(status_code=401)
|
|
198
|
+
mock_response_200 = MagicMock(status_code=200)
|
|
199
|
+
mock_response_200.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="secret.txt"'}
|
|
200
|
+
mock_response_200.iter_content.return_value = [content]
|
|
201
|
+
mock_session.get.side_effect = [mock_response_401, mock_response_200]
|
|
202
|
+
|
|
203
|
+
mock_console = MagicMock()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
download_file(mock_console, "http://test-server/secret")
|
|
207
|
+
|
|
208
|
+
assert mock_prompt.called
|
|
209
|
+
assert os.path.exists("secret.txt")
|
|
210
|
+
with open("secret.txt", "rb") as f:
|
|
211
|
+
assert f.read() == content
|
|
212
|
+
finally:
|
|
213
|
+
if os.path.exists("secret.txt"):
|
|
214
|
+
os.remove("secret.txt")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_load_save_config():
|
|
218
|
+
"""Test config file loading and saving."""
|
|
219
|
+
from cli.tempspace import load_config, save_config, CONFIG_DIR, CONFIG_FILE
|
|
220
|
+
import tempfile
|
|
221
|
+
|
|
222
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
223
|
+
import cli.tempspace as cli_module
|
|
224
|
+
original_config_dir = cli_module.CONFIG_DIR
|
|
225
|
+
original_config_file = cli_module.CONFIG_FILE
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
cli_module.CONFIG_DIR = Path(tmpdir)
|
|
229
|
+
cli_module.CONFIG_FILE = Path(tmpdir) / "config.json"
|
|
230
|
+
|
|
231
|
+
save_config({"client_id": "test-id", "server_url": "http://test.com"})
|
|
232
|
+
config = load_config()
|
|
233
|
+
|
|
234
|
+
assert config["client_id"] == "test-id"
|
|
235
|
+
assert config["server_url"] == "http://test.com"
|
|
236
|
+
finally:
|
|
237
|
+
cli_module.CONFIG_DIR = original_config_dir
|
|
238
|
+
cli_module.CONFIG_FILE = original_config_file
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_load_save_history():
|
|
242
|
+
"""Test history file loading and saving."""
|
|
243
|
+
from cli.tempspace import load_history, save_history, add_to_history
|
|
244
|
+
import tempfile
|
|
245
|
+
|
|
246
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
247
|
+
import cli.tempspace as cli_module
|
|
248
|
+
original_config_dir = cli_module.CONFIG_DIR
|
|
249
|
+
original_history_file = cli_module.HISTORY_FILE
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
cli_module.CONFIG_DIR = Path(tmpdir)
|
|
253
|
+
cli_module.HISTORY_FILE = Path(tmpdir) / "history.json"
|
|
254
|
+
|
|
255
|
+
save_history([
|
|
256
|
+
{"filename": "test1.txt", "url": "http://test.com/1", "timestamp": "2024-01-01T00:00:00"},
|
|
257
|
+
{"filename": "test2.txt", "url": "http://test.com/2", "timestamp": "2024-01-02T00:00:00"}
|
|
258
|
+
])
|
|
259
|
+
|
|
260
|
+
history = load_history()
|
|
261
|
+
assert len(history) == 2
|
|
262
|
+
assert history[0]["filename"] == "test1.txt"
|
|
263
|
+
|
|
264
|
+
add_to_history({"filename": "test3.txt", "url": "http://test.com/3", "timestamp": "2024-01-03T00:00:00"})
|
|
265
|
+
history = load_history()
|
|
266
|
+
assert len(history) == 3
|
|
267
|
+
assert history[0]["filename"] == "test3.txt"
|
|
268
|
+
finally:
|
|
269
|
+
cli_module.CONFIG_DIR = original_config_dir
|
|
270
|
+
cli_module.HISTORY_FILE = original_history_file
|
|
@@ -3,7 +3,7 @@ from fastapi.testclient import TestClient
|
|
|
3
3
|
from main import (
|
|
4
4
|
app, UPLOAD_DIR, RateLimiter, RATE_LIMIT_UPLOADS, RATE_LIMIT_DOWNLOADS,
|
|
5
5
|
RATE_LIMIT_WINDOW, cleanup_expired_files, shutdown_event,
|
|
6
|
-
is_video, is_text, format_bytes
|
|
6
|
+
is_video, is_text, format_bytes, sanitize_filename, hash_password, verify_password
|
|
7
7
|
)
|
|
8
8
|
import os
|
|
9
9
|
import shutil
|
|
@@ -757,3 +757,220 @@ def test_download_content_disposition():
|
|
|
757
757
|
# Starlette/FastAPI FileResponse might use filename*=UTF-8''... for safety
|
|
758
758
|
cd = response.headers["content-disposition"]
|
|
759
759
|
assert filename in cd
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def test_sanitize_filename_path_traversal():
|
|
763
|
+
"""Test that sanitize_filename prevents path traversal attacks."""
|
|
764
|
+
assert sanitize_filename("../../../etc/passwd") == "passwd"
|
|
765
|
+
assert sanitize_filename("..\\..\\windows\\system32\\config\\sam") == "sam"
|
|
766
|
+
assert sanitize_filename("normal_file.txt") == "normal_file.txt"
|
|
767
|
+
assert sanitize_filename(" spaces .txt") == "spaces .txt"
|
|
768
|
+
assert sanitize_filename("....") == "unnamed_file"
|
|
769
|
+
assert sanitize_filename("") == "unnamed_file"
|
|
770
|
+
assert sanitize_filename("file\x00.txt") == "file.txt"
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def test_hash_password_with_salt():
|
|
774
|
+
"""Test that hash_password produces salted hashes."""
|
|
775
|
+
password = "test_password"
|
|
776
|
+
hash1 = hash_password(password)
|
|
777
|
+
hash2 = hash_password(password)
|
|
778
|
+
# Hashes should be different due to random salt
|
|
779
|
+
assert hash1 != hash2
|
|
780
|
+
# Both should contain a colon separator
|
|
781
|
+
assert ":" in hash1
|
|
782
|
+
assert ":" in hash2
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def test_verify_password():
|
|
786
|
+
"""Test that verify_password correctly validates passwords."""
|
|
787
|
+
password = "my_secret"
|
|
788
|
+
stored_hash = hash_password(password)
|
|
789
|
+
assert verify_password(password, stored_hash) is True
|
|
790
|
+
assert verify_password("wrong_password", stored_hash) is False
|
|
791
|
+
# Test backward compatibility with old unsalted hashes
|
|
792
|
+
old_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
793
|
+
assert verify_password(password, old_hash) is True
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def test_upload_path_traversal_attempt():
|
|
797
|
+
"""Test that uploading with a path traversal filename is sanitized."""
|
|
798
|
+
response = client.post(
|
|
799
|
+
"/upload",
|
|
800
|
+
files={"file": ("../../etc/passwd", b"malicious content", "text/plain")},
|
|
801
|
+
data={"hours": "1"},
|
|
802
|
+
)
|
|
803
|
+
assert response.status_code == 200
|
|
804
|
+
data = response.json()
|
|
805
|
+
assert data["filename"] == "passwd"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def test_finalize_invalid_hours():
|
|
809
|
+
"""Test that /upload/finalize rejects invalid expiry hours."""
|
|
810
|
+
initiate_response = client.post("/upload/initiate")
|
|
811
|
+
upload_id = initiate_response.json()["upload_id"]
|
|
812
|
+
|
|
813
|
+
client.post(
|
|
814
|
+
"/upload/chunk",
|
|
815
|
+
data={"upload_id": upload_id, "chunk_number": "1"},
|
|
816
|
+
files={"file": ("chunk", b"some content")},
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
finalize_response = client.post(
|
|
820
|
+
"/upload/finalize",
|
|
821
|
+
data={
|
|
822
|
+
"upload_id": upload_id,
|
|
823
|
+
"filename": "test.txt",
|
|
824
|
+
"hours": "999",
|
|
825
|
+
},
|
|
826
|
+
)
|
|
827
|
+
assert finalize_response.status_code == 400
|
|
828
|
+
assert "Invalid expiry time" in finalize_response.json()["detail"]
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def test_finalize_exceeds_max_size(monkeypatch):
|
|
832
|
+
"""Test that /upload/finalize rejects files exceeding MAX_FILE_SIZE."""
|
|
833
|
+
monkeypatch.setattr("main.MAX_FILE_SIZE", 10)
|
|
834
|
+
|
|
835
|
+
initiate_response = client.post("/upload/initiate")
|
|
836
|
+
upload_id = initiate_response.json()["upload_id"]
|
|
837
|
+
|
|
838
|
+
client.post(
|
|
839
|
+
"/upload/chunk",
|
|
840
|
+
data={"upload_id": upload_id, "chunk_number": "1"},
|
|
841
|
+
files={"file": ("chunk", b"this is way more than ten bytes of content")},
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
finalize_response = client.post(
|
|
845
|
+
"/upload/finalize",
|
|
846
|
+
data={
|
|
847
|
+
"upload_id": upload_id,
|
|
848
|
+
"filename": "too_big.txt",
|
|
849
|
+
"hours": "1",
|
|
850
|
+
},
|
|
851
|
+
)
|
|
852
|
+
assert finalize_response.status_code == 413
|
|
853
|
+
assert "File too large" in finalize_response.json()["detail"]
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def test_health_endpoint_timezone_aware():
|
|
857
|
+
"""Test that /health returns a timezone-aware timestamp."""
|
|
858
|
+
response = client.get("/health")
|
|
859
|
+
assert response.status_code == 200
|
|
860
|
+
data = response.json()
|
|
861
|
+
timestamp = data["timestamp"]
|
|
862
|
+
# Timezone-aware ISO format contains '+' or 'Z'
|
|
863
|
+
assert "+" in timestamp or "Z" in timestamp or timestamp.endswith("+00:00")
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def test_duplicate_preserves_one_time_flag():
|
|
867
|
+
"""Test that uploading a duplicate file preserves the original one_time flag."""
|
|
868
|
+
content = b"unique content for one_time preservation test " + os.urandom(8)
|
|
869
|
+
response1 = client.post(
|
|
870
|
+
"/upload",
|
|
871
|
+
files={"file": ("one_time_original.txt", content, "text/plain")},
|
|
872
|
+
data={"hours": "1", "one_time": "true"},
|
|
873
|
+
)
|
|
874
|
+
assert response1.status_code == 200
|
|
875
|
+
data1 = response1.json()
|
|
876
|
+
assert data1["one_time"] is True
|
|
877
|
+
|
|
878
|
+
response2 = client.post(
|
|
879
|
+
"/upload",
|
|
880
|
+
files={"file": ("duplicate.txt", content, "text/plain")},
|
|
881
|
+
data={"hours": "1", "one_time": "false"},
|
|
882
|
+
)
|
|
883
|
+
assert response2.status_code == 200
|
|
884
|
+
data2 = response2.json()
|
|
885
|
+
assert data2["one_time"] is True
|
|
886
|
+
assert data1["file_id"] == data2["file_id"]
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def test_extend_file_expiry():
|
|
890
|
+
"""Test extending file expiry time via /extend/{file_id}."""
|
|
891
|
+
client_id = "extend-test-client"
|
|
892
|
+
upload_response = client.post(
|
|
893
|
+
"/upload",
|
|
894
|
+
files={"file": ("extend_test.txt", b"extend me", "text/plain")},
|
|
895
|
+
data={"hours": "1", "client_id": client_id},
|
|
896
|
+
)
|
|
897
|
+
assert upload_response.status_code == 200
|
|
898
|
+
file_id = upload_response.json()["file_id"]
|
|
899
|
+
original_expiry = upload_response.json()["expires_at"]
|
|
900
|
+
|
|
901
|
+
extend_response = client.post(
|
|
902
|
+
f"/extend/{file_id}",
|
|
903
|
+
json={"client_id": client_id, "hours": 24}
|
|
904
|
+
)
|
|
905
|
+
assert extend_response.status_code == 200
|
|
906
|
+
data = extend_response.json()
|
|
907
|
+
assert data["success"] is True
|
|
908
|
+
assert data["new_expires_at"] != original_expiry
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def test_extend_file_unauthorized():
|
|
912
|
+
"""Test that extending file expiry with wrong client_id fails."""
|
|
913
|
+
owner_client_id = "owner-client-extend"
|
|
914
|
+
upload_response = client.post(
|
|
915
|
+
"/upload",
|
|
916
|
+
files={"file": ("extend_unauth.txt", b"extend unauthorized", "text/plain")},
|
|
917
|
+
data={"hours": "1", "client_id": owner_client_id},
|
|
918
|
+
)
|
|
919
|
+
file_id = upload_response.json()["file_id"]
|
|
920
|
+
|
|
921
|
+
extend_response = client.post(
|
|
922
|
+
f"/extend/{file_id}",
|
|
923
|
+
json={"client_id": "wrong-client", "hours": 24}
|
|
924
|
+
)
|
|
925
|
+
assert extend_response.status_code == 403
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def test_extend_file_invalid_hours():
|
|
929
|
+
"""Test that extending with invalid hours fails."""
|
|
930
|
+
client_id = "extend-invalid-client"
|
|
931
|
+
upload_response = client.post(
|
|
932
|
+
"/upload",
|
|
933
|
+
files={"file": ("extend_invalid.txt", b"extend invalid", "text/plain")},
|
|
934
|
+
data={"hours": "1", "client_id": client_id},
|
|
935
|
+
)
|
|
936
|
+
file_id = upload_response.json()["file_id"]
|
|
937
|
+
|
|
938
|
+
extend_response = client.post(
|
|
939
|
+
f"/extend/{file_id}",
|
|
940
|
+
json={"client_id": client_id, "hours": 999}
|
|
941
|
+
)
|
|
942
|
+
assert extend_response.status_code == 400
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def test_debug_bulk_delete_authorized():
|
|
946
|
+
"""Test bulk delete with admin authentication."""
|
|
947
|
+
client_id = "bulk-delete-client"
|
|
948
|
+
file_ids = []
|
|
949
|
+
|
|
950
|
+
for i in range(3):
|
|
951
|
+
upload_response = client.post(
|
|
952
|
+
"/upload",
|
|
953
|
+
files={"file": (f"bulk_{i}.txt", f"bulk content {i}".encode(), "text/plain")},
|
|
954
|
+
data={"hours": "1", "client_id": client_id},
|
|
955
|
+
)
|
|
956
|
+
file_ids.append(upload_response.json()["file_id"])
|
|
957
|
+
|
|
958
|
+
bulk_response = client.post(
|
|
959
|
+
"/debug/bulk-delete",
|
|
960
|
+
auth=("admin", "changeme123"),
|
|
961
|
+
json={"file_ids": file_ids}
|
|
962
|
+
)
|
|
963
|
+
assert bulk_response.status_code == 200
|
|
964
|
+
data = bulk_response.json()
|
|
965
|
+
assert data["success"] is True
|
|
966
|
+
assert data["deleted_count"] == 3
|
|
967
|
+
|
|
968
|
+
for file_id in file_ids:
|
|
969
|
+
get_response = client.get(f"/{file_id}/bulk_0.txt")
|
|
970
|
+
assert get_response.status_code == 404
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def test_debug_bulk_delete_unauthorized():
|
|
974
|
+
"""Test that bulk delete requires admin authentication."""
|
|
975
|
+
response = client.post("/debug/bulk-delete", json={"file_ids": ["fake-id"]})
|
|
976
|
+
assert response.status_code == 401
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import os
|
|
3
|
-
import shutil
|
|
4
|
-
import tempfile
|
|
5
|
-
import hashlib
|
|
6
|
-
|
|
7
|
-
from unittest.mock import MagicMock, patch
|
|
8
|
-
from cli.tempspace import parse_time, format_size, calculate_file_hash, zip_directory, upload_file, download_file, main
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# --- Unit Tests ---
|
|
12
|
-
|
|
13
|
-
def test_parse_time():
|
|
14
|
-
assert parse_time("7d") == 168
|
|
15
|
-
assert parse_time("24h") == 24
|
|
16
|
-
assert parse_time("360") == 360
|
|
17
|
-
assert parse_time("1D") == 24
|
|
18
|
-
assert parse_time("2H") == 2
|
|
19
|
-
assert parse_time(" 7d ") == 168
|
|
20
|
-
assert parse_time("invalid") is None
|
|
21
|
-
assert parse_time("1w") is None
|
|
22
|
-
|
|
23
|
-
def test_format_size():
|
|
24
|
-
assert format_size(0) == "0B"
|
|
25
|
-
assert format_size(1023) == "1023.0 B"
|
|
26
|
-
assert format_size(100) == "100.0 B"
|
|
27
|
-
assert format_size(1024) == "1.0 KB"
|
|
28
|
-
assert format_size(1024 * 1024) == "1.0 MB"
|
|
29
|
-
assert format_size(1024 * 1024 * 1024 * 2.5) == "2.5 GB"
|
|
30
|
-
|
|
31
|
-
def test_calculate_file_hash():
|
|
32
|
-
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
33
|
-
f.write(b"hello world")
|
|
34
|
-
filepath = f.name
|
|
35
|
-
try:
|
|
36
|
-
expected_hash = hashlib.sha256(b"hello world").hexdigest()
|
|
37
|
-
assert calculate_file_hash(filepath) == expected_hash
|
|
38
|
-
finally:
|
|
39
|
-
os.remove(filepath)
|
|
40
|
-
|
|
41
|
-
def test_zip_directory():
|
|
42
|
-
# Create a dummy directory structure
|
|
43
|
-
temp_dir = tempfile.mkdtemp()
|
|
44
|
-
try:
|
|
45
|
-
os.makedirs(os.path.join(temp_dir, "subdir"))
|
|
46
|
-
with open(os.path.join(temp_dir, "file1.txt"), "w") as f:
|
|
47
|
-
f.write("content1")
|
|
48
|
-
|
|
49
|
-
# Call zip_directory
|
|
50
|
-
zip_path = zip_directory(temp_dir)
|
|
51
|
-
|
|
52
|
-
assert os.path.exists(zip_path)
|
|
53
|
-
assert zip_path.endswith(".zip")
|
|
54
|
-
assert os.path.basename(temp_dir) in os.path.basename(zip_path)
|
|
55
|
-
|
|
56
|
-
# Cleanup zip
|
|
57
|
-
os.remove(zip_path)
|
|
58
|
-
finally:
|
|
59
|
-
shutil.rmtree(temp_dir)
|
|
60
|
-
|
|
61
|
-
# --- Mock Tests ---
|
|
62
|
-
|
|
63
|
-
@patch('cli.tempspace.requests.Session')
|
|
64
|
-
def test_upload_file_success(mock_session_cls, capsys):
|
|
65
|
-
mock_session = mock_session_cls.return_value
|
|
66
|
-
|
|
67
|
-
# Mock Initiate
|
|
68
|
-
mock_session.post.side_effect = [
|
|
69
|
-
MagicMock(status_code=200, json=lambda: {'upload_id': '123'}), # Initiate
|
|
70
|
-
MagicMock(status_code=200), # Chunk 1
|
|
71
|
-
MagicMock(status_code=200, json=lambda: {'url': 'http://test.com/file', 'hash': 'abc', 'hash_verified': True}) # Finalize
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
75
|
-
f.write(b"test content")
|
|
76
|
-
filepath = f.name
|
|
77
|
-
|
|
78
|
-
mock_console = MagicMock()
|
|
79
|
-
|
|
80
|
-
try:
|
|
81
|
-
upload_file(mock_console, filepath, 24, None, False, False, "http://test-server")
|
|
82
|
-
|
|
83
|
-
# Assert calls
|
|
84
|
-
assert mock_session.post.call_count == 3
|
|
85
|
-
# Verify initiate call
|
|
86
|
-
mock_session.post.call_args_list[0][0][0].endswith("/upload/initiate")
|
|
87
|
-
# Verify finalize call
|
|
88
|
-
mock_session.post.call_args_list[2][0][0].endswith("/upload/finalize")
|
|
89
|
-
|
|
90
|
-
# Verify success message printed
|
|
91
|
-
# mock_console.print.assert_called() # Hard to check partial strings on rich objects without more complex matching
|
|
92
|
-
|
|
93
|
-
finally:
|
|
94
|
-
os.remove(filepath)
|
|
95
|
-
|
|
96
|
-
@patch('cli.tempspace.requests.Session')
|
|
97
|
-
def test_download_file_success(mock_session_cls):
|
|
98
|
-
mock_session = mock_session_cls.return_value
|
|
99
|
-
|
|
100
|
-
# Mock HEAD request (optional/handled in code?) Code does checks but uses same session
|
|
101
|
-
mock_session.head.return_value = MagicMock(status_code=200)
|
|
102
|
-
|
|
103
|
-
# Mock GET request
|
|
104
|
-
content = b"downloaded content"
|
|
105
|
-
mock_response = MagicMock(status_code=200)
|
|
106
|
-
mock_response.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="server_file.txt"'}
|
|
107
|
-
mock_response.iter_content.return_value = [content]
|
|
108
|
-
mock_session.get.return_value = mock_response
|
|
109
|
-
|
|
110
|
-
mock_console = MagicMock()
|
|
111
|
-
|
|
112
|
-
# Run download
|
|
113
|
-
try:
|
|
114
|
-
download_file(mock_console, "http://test-server/file")
|
|
115
|
-
|
|
116
|
-
# Verify file saved
|
|
117
|
-
assert os.path.exists("server_file.txt")
|
|
118
|
-
with open("server_file.txt", "rb") as f:
|
|
119
|
-
assert f.read() == content
|
|
120
|
-
finally:
|
|
121
|
-
if os.path.exists("server_file.txt"):
|
|
122
|
-
os.remove("server_file.txt")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@patch('sys.argv', ['tempspace', 'file.txt'])
|
|
126
|
-
@patch('cli.tempspace.upload_file')
|
|
127
|
-
def test_main_upload_defaults(mock_upload):
|
|
128
|
-
# Mock os.path.isfile via patch is tricky if imported in module.
|
|
129
|
-
# cli.tempspace uses os.path.isfile directly from os module import?
|
|
130
|
-
# It does `import os`. So `os.path.isfile`.
|
|
131
|
-
with patch('cli.tempspace.os.path.isfile', return_value=True):
|
|
132
|
-
main()
|
|
133
|
-
|
|
134
|
-
mock_upload.assert_called_once()
|
|
135
|
-
# args: (console, filepath, hours, password, one_time, qr, url)
|
|
136
|
-
args = mock_upload.call_args[0]
|
|
137
|
-
assert args[1] == 'file.txt'
|
|
138
|
-
assert args[2] == 24 # Default 24h
|
|
139
|
-
|
|
140
|
-
@patch('sys.argv', ['tempspace', 'file.txt', '-t', '1h', '-p', 'secret', '--qr'])
|
|
141
|
-
@patch('cli.tempspace.upload_file')
|
|
142
|
-
def test_main_upload_custom(mock_upload):
|
|
143
|
-
with patch('cli.tempspace.os.path.isfile', return_value=True):
|
|
144
|
-
main()
|
|
145
|
-
|
|
146
|
-
args = mock_upload.call_args[0]
|
|
147
|
-
assert args[1] == 'file.txt'
|
|
148
|
-
assert args[2] == 1
|
|
149
|
-
assert args[3] == 'secret'
|
|
150
|
-
assert args[5] is True
|
|
151
|
-
|
|
152
|
-
@patch('sys.argv', ['tempspace', 'download', 'http://example.com/file', '-p', 'pass'])
|
|
153
|
-
@patch('cli.tempspace.download_file')
|
|
154
|
-
def test_main_download_command(mock_download):
|
|
155
|
-
main()
|
|
156
|
-
mock_download.assert_called_once()
|
|
157
|
-
args = mock_download.call_args[0]
|
|
158
|
-
assert args[1] == 'http://example.com/file'
|
|
159
|
-
assert args[2] == 'pass'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|