tempspace-cli 1.2.9__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.9 → tempspace_cli-1.3.0}/PKG-INFO +1 -1
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/cli/tempspace.py +235 -121
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/pyproject.toml +1 -1
- {tempspace_cli-1.2.9 → 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.9 → tempspace_cli-1.3.0}/tests/test_main.py +218 -1
- tempspace_cli-1.2.9/tests/test_cli.py +0 -159
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/README.md +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/cli/__init__.py +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/setup.cfg +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/SOURCES.txt +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/dependency_links.txt +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/entry_points.txt +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/requires.txt +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tempspace_cli.egg-info/top_level.txt +0 -0
- {tempspace_cli-1.2.9 → tempspace_cli-1.3.0}/tests/test_binary.py +0 -0
|
@@ -4,9 +4,15 @@ import os
|
|
|
4
4
|
import sys
|
|
5
5
|
import hashlib
|
|
6
6
|
import multiprocessing
|
|
7
|
-
|
|
8
7
|
import math
|
|
9
|
-
import
|
|
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
|
+
|
|
10
16
|
from rich.console import Console
|
|
11
17
|
from rich.panel import Panel
|
|
12
18
|
from rich.table import Table
|
|
@@ -14,16 +20,62 @@ from rich import box
|
|
|
14
20
|
import qrcode
|
|
15
21
|
from rich.prompt import Prompt, Confirm
|
|
16
22
|
from rich.progress import Progress, BarColumn, TextColumn, TransferSpeedColumn, TimeRemainingColumn
|
|
17
|
-
|
|
18
23
|
from rich.live import Live
|
|
19
|
-
import shutil
|
|
20
|
-
import tempfile
|
|
21
24
|
from requests.adapters import HTTPAdapter
|
|
22
25
|
from urllib3.util.retry import Retry
|
|
23
26
|
|
|
24
|
-
# Default configuration
|
|
25
27
|
DEFAULT_SERVER_URL = "https://tempspace.needrp.net"
|
|
26
|
-
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)
|
|
27
79
|
|
|
28
80
|
def parse_time(time_str: str) -> int:
|
|
29
81
|
"""Parses a user-provided time string into a total number of hours.
|
|
@@ -55,6 +107,11 @@ def parse_time(time_str: str) -> int:
|
|
|
55
107
|
except ValueError:
|
|
56
108
|
return None
|
|
57
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
|
+
|
|
58
115
|
def format_size(size_bytes: int) -> str:
|
|
59
116
|
"""Converts a file size in bytes into a human-readable string.
|
|
60
117
|
|
|
@@ -92,89 +149,64 @@ def calculate_file_hash(filepath: str) -> str:
|
|
|
92
149
|
|
|
93
150
|
|
|
94
151
|
|
|
95
|
-
def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[
|
|
96
|
-
"""Creates a requests Session with automatic retries."""
|
|
152
|
+
def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]):
|
|
153
|
+
"""Creates a requests Session with automatic retries and default timeout."""
|
|
97
154
|
session = requests.Session()
|
|
98
155
|
retry = Retry(
|
|
99
156
|
total=retries,
|
|
100
157
|
read=retries,
|
|
101
158
|
connect=retries,
|
|
102
|
-
other=retries,
|
|
103
159
|
backoff_factor=backoff_factor,
|
|
104
160
|
status_forcelist=status_forcelist,
|
|
105
|
-
allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
|
106
161
|
)
|
|
107
162
|
adapter = HTTPAdapter(max_retries=retry)
|
|
108
163
|
session.mount('http://', adapter)
|
|
109
164
|
session.mount('https://', adapter)
|
|
165
|
+
session.request_timeout = REQUEST_TIMEOUT
|
|
110
166
|
return session
|
|
111
167
|
|
|
112
168
|
|
|
113
|
-
def zip_directory(path: str) -> str:
|
|
114
|
-
"""Zips a directory and returns the path to the
|
|
115
|
-
# 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."""
|
|
116
171
|
temp_dir = tempfile.mkdtemp()
|
|
117
|
-
# Use the original folder name for the zip file
|
|
118
172
|
base_name = os.path.basename(os.path.normpath(path))
|
|
119
173
|
archive_base = os.path.join(temp_dir, base_name)
|
|
120
|
-
|
|
121
174
|
shutil.make_archive(archive_base, 'zip', path)
|
|
122
|
-
return archive_base + ".zip"
|
|
175
|
+
return archive_base + ".zip", temp_dir
|
|
123
176
|
|
|
124
177
|
|
|
125
178
|
def download_file(console, url, password=None):
|
|
126
179
|
"""Downloads a file from Tempspace."""
|
|
127
180
|
session = get_retry_session()
|
|
128
|
-
|
|
181
|
+
|
|
129
182
|
try:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# or the tool should try to GET the URL.
|
|
140
|
-
# If the server has an API like /api/download/<id>?password=..., that would be best.
|
|
141
|
-
# Looking at upload_file response: `download_link = data.get('url')`.
|
|
142
|
-
# Code audit earlier showed `DEFAULT_SERVER_URL`.
|
|
143
|
-
# Use regex to extract ID from URL if possible, or just try GET.
|
|
144
|
-
|
|
145
|
-
# Simple approach: GET the URL. If it initiates a download (headers), good.
|
|
146
|
-
|
|
147
|
-
session.head(url, allow_redirects=True)
|
|
148
|
-
# Check headers for content-disposition or just proceed
|
|
149
|
-
|
|
150
|
-
if password:
|
|
151
|
-
# If password is needed, headers or query param might be required.
|
|
152
|
-
# Since I don't know the exact server auth mechanism for downloads,
|
|
153
|
-
# I will try passing it as a query param 'password' or header 'X-Password'.
|
|
154
|
-
# Better: ask the user to enter it if the server responds 401/403.
|
|
155
|
-
pass
|
|
156
|
-
|
|
157
|
-
# For a streaming download
|
|
158
|
-
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
|
+
|
|
159
192
|
response.raise_for_status()
|
|
160
|
-
|
|
193
|
+
|
|
161
194
|
total_size = int(response.headers.get('content-length', 0))
|
|
162
195
|
disposition = response.headers.get('content-disposition')
|
|
163
|
-
|
|
196
|
+
|
|
164
197
|
filename = None
|
|
165
198
|
if disposition:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
|
|
173
206
|
if not filename:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
207
|
+
parsed_path = unquote(url.split("?")[0])
|
|
208
|
+
filename = parsed_path.split("/")[-1]
|
|
209
|
+
|
|
178
210
|
if not filename:
|
|
179
211
|
filename = "downloaded_file"
|
|
180
212
|
|
|
@@ -185,21 +217,25 @@ def download_file(console, url, password=None):
|
|
|
185
217
|
TransferSpeedColumn(), "•",
|
|
186
218
|
TimeRemainingColumn(),
|
|
187
219
|
)
|
|
188
|
-
|
|
220
|
+
|
|
189
221
|
with Live(Panel(progress, title="[cyan]Downloading[/cyan]", border_style="cyan", title_align="left")):
|
|
190
222
|
task_id = progress.add_task(filename, total=total_size)
|
|
191
223
|
with open(filename, 'wb') as f:
|
|
192
224
|
for chunk in response.iter_content(CHUNK_SIZE):
|
|
193
225
|
f.write(chunk)
|
|
194
226
|
progress.update(task_id, advance=len(chunk))
|
|
195
|
-
|
|
227
|
+
|
|
196
228
|
console.print(Panel(f"[bold green]Download successful![/] Saved to '{filename}'", border_style="green"))
|
|
197
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"))
|
|
198
234
|
except Exception as e:
|
|
199
235
|
console.print(Panel(f"[bold red]Download failed:[/] {e}", border_style="red"))
|
|
200
236
|
|
|
201
237
|
|
|
202
|
-
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):
|
|
203
239
|
"""Handles the upload of a single file.
|
|
204
240
|
|
|
205
241
|
Args:
|
|
@@ -210,13 +246,20 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
210
246
|
one_time (bool): Whether the file should be a one-time download.
|
|
211
247
|
qr (bool): Whether to display a QR code for the download link.
|
|
212
248
|
url (str): The base URL of the Tempspace server.
|
|
249
|
+
client_id (str): Optional client ID for file ownership tracking.
|
|
213
250
|
"""
|
|
214
|
-
# --- Validate Inputs ---
|
|
215
251
|
if not os.path.isfile(filepath):
|
|
216
252
|
console.print(Panel(f"[bold red]Error:[/] File not found at '{filepath}'", title="[bold red]Error[/bold red]", border_style="red"))
|
|
217
|
-
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
|
|
218
262
|
|
|
219
|
-
# --- Display File Details ---
|
|
220
263
|
table = Table(title="File Details", show_header=False, box=box.ROUNDED, border_style="cyan")
|
|
221
264
|
table.add_column("Field", style="bold")
|
|
222
265
|
table.add_column("Value")
|
|
@@ -226,27 +269,21 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
226
269
|
table.add_row("Password", "[green]Yes[/green]" if password else "[red]No[/red]")
|
|
227
270
|
table.add_row("One-Time Download", "[green]Yes[/green]" if one_time else "[red]No[/red]")
|
|
228
271
|
|
|
229
|
-
# --- Prepare Upload ---
|
|
230
272
|
upload_url = f"{url.rstrip('/')}"
|
|
231
273
|
filename = os.path.basename(filepath)
|
|
232
274
|
file_size = os.path.getsize(filepath)
|
|
233
275
|
|
|
234
|
-
# --- Calculate Hash ---
|
|
235
276
|
client_hash = calculate_file_hash(filepath)
|
|
236
277
|
table.add_row("File Hash", f"[cyan]{client_hash}[/cyan]")
|
|
237
278
|
console.print(table)
|
|
238
279
|
|
|
239
|
-
|
|
240
|
-
# --- Chunked Upload ---
|
|
241
280
|
response = None
|
|
242
281
|
session = get_retry_session()
|
|
243
282
|
try:
|
|
244
|
-
|
|
245
|
-
initiate_response = session.post(f"{upload_url}/upload/initiate")
|
|
283
|
+
initiate_response = session.post(f"{upload_url}/upload/initiate", timeout=REQUEST_TIMEOUT)
|
|
246
284
|
initiate_response.raise_for_status()
|
|
247
285
|
upload_id = initiate_response.json()['upload_id']
|
|
248
286
|
|
|
249
|
-
# 2. Upload Chunks
|
|
250
287
|
progress = Progress(
|
|
251
288
|
TextColumn("[bold blue]{task.description}", justify="right"),
|
|
252
289
|
BarColumn(bar_width=None),
|
|
@@ -267,45 +304,40 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
267
304
|
}
|
|
268
305
|
files = {'file': (f'chunk_{chunk_number}', chunk, 'application/octet-stream')}
|
|
269
306
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
chunk_response.raise_for_status()
|
|
281
|
-
progress.update(task_id, advance=len(chunk))
|
|
282
|
-
break
|
|
283
|
-
except (requests.exceptions.RequestException, ConnectionError) as e:
|
|
284
|
-
if attempt < max_chunk_retries - 1:
|
|
285
|
-
# Briefly wait before retrying the chunk
|
|
286
|
-
time.sleep(1 * (attempt + 1))
|
|
287
|
-
continue
|
|
288
|
-
else:
|
|
289
|
-
raise e
|
|
290
|
-
|
|
291
|
-
# 3. Finalize Upload
|
|
307
|
+
chunk_response = session.post(
|
|
308
|
+
f"{upload_url}/upload/chunk",
|
|
309
|
+
data=chunk_data,
|
|
310
|
+
files=files,
|
|
311
|
+
timeout=REQUEST_TIMEOUT
|
|
312
|
+
)
|
|
313
|
+
chunk_response.raise_for_status()
|
|
314
|
+
progress.update(task_id, advance=len(chunk))
|
|
315
|
+
|
|
292
316
|
console.print(Panel("[bold green]Finalizing upload...[/bold green]", border_style="green"))
|
|
293
317
|
finalize_data = {
|
|
294
318
|
'upload_id': upload_id,
|
|
295
319
|
'filename': filename,
|
|
296
320
|
'hours': str(hours),
|
|
297
321
|
'one_time': str(one_time).lower(),
|
|
298
|
-
'client_hash': client_hash,
|
|
322
|
+
'client_hash': client_hash,
|
|
299
323
|
}
|
|
324
|
+
if client_id:
|
|
325
|
+
finalize_data['client_id'] = client_id
|
|
300
326
|
if password:
|
|
301
327
|
finalize_data['password'] = password
|
|
302
328
|
|
|
303
|
-
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)
|
|
304
330
|
response.raise_for_status()
|
|
305
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
|
|
306
338
|
except FileNotFoundError:
|
|
307
|
-
console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.",
|
|
308
|
-
return
|
|
339
|
+
console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", border_style="red"))
|
|
340
|
+
return False
|
|
309
341
|
except requests.exceptions.RequestException as e:
|
|
310
342
|
error_message = str(e)
|
|
311
343
|
if e.response:
|
|
@@ -313,13 +345,12 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
313
345
|
error_message = e.response.json().get('detail', e.response.text)
|
|
314
346
|
except Exception:
|
|
315
347
|
error_message = e.response.text
|
|
316
|
-
console.print(Panel(f"[bold red]An error occurred:[/] {error_message}",
|
|
317
|
-
return
|
|
348
|
+
console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", border_style="red"))
|
|
349
|
+
return False
|
|
318
350
|
except Exception as e:
|
|
319
|
-
console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}",
|
|
320
|
-
return
|
|
351
|
+
console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", border_style="red"))
|
|
352
|
+
return False
|
|
321
353
|
|
|
322
|
-
# --- Handle Response ---
|
|
323
354
|
if response is not None:
|
|
324
355
|
if response.status_code == 200:
|
|
325
356
|
try:
|
|
@@ -343,8 +374,17 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
343
374
|
qr_code.make(fit=True)
|
|
344
375
|
qr_code.print_ascii()
|
|
345
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
|
+
|
|
346
387
|
except requests.exceptions.JSONDecodeError:
|
|
347
|
-
# Fallback for older servers or unexpected plain text responses
|
|
348
388
|
download_link = response.text.strip()
|
|
349
389
|
success_panel = Panel(f"[bold green]Upload successful![/bold green]\n\nDownload Link: {download_link}",
|
|
350
390
|
title="[bold cyan]Success[/bold cyan]", border_style="green")
|
|
@@ -355,13 +395,43 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
|
|
|
355
395
|
qr_code.add_data(download_link)
|
|
356
396
|
qr_code.make(fit=True)
|
|
357
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
|
|
358
408
|
else:
|
|
359
409
|
try:
|
|
360
410
|
error_details = response.json()
|
|
361
411
|
error_message = error_details.get('detail', 'No details provided.')
|
|
362
412
|
except requests.exceptions.JSONDecodeError:
|
|
363
413
|
error_message = response.text
|
|
364
|
-
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
|
|
365
435
|
|
|
366
436
|
|
|
367
437
|
def main():
|
|
@@ -372,22 +442,52 @@ def main():
|
|
|
372
442
|
"""
|
|
373
443
|
console = Console()
|
|
374
444
|
|
|
375
|
-
# --- Handle Download Command ---
|
|
376
445
|
if len(sys.argv) > 1 and sys.argv[1] == 'download':
|
|
377
446
|
parser = argparse.ArgumentParser(description="Download a file from Tempspace.")
|
|
378
447
|
parser.add_argument("url", help="The URL of the file to download.")
|
|
379
448
|
parser.add_argument("-p", "--password", help="The password for the file.")
|
|
380
|
-
|
|
381
|
-
# We process only the download arguments
|
|
449
|
+
|
|
382
450
|
if len(sys.argv) == 2 and (sys.argv[1] == '-h' or sys.argv[1] == '--help'):
|
|
383
451
|
parser.print_help()
|
|
384
452
|
sys.exit(0)
|
|
385
|
-
|
|
453
|
+
|
|
386
454
|
args = parser.parse_args(sys.argv[2:])
|
|
387
455
|
download_file(console, args.url, args.password)
|
|
388
456
|
return
|
|
389
457
|
|
|
390
|
-
|
|
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
|
+
|
|
391
491
|
console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
|
|
392
492
|
|
|
393
493
|
parser = argparse.ArgumentParser(
|
|
@@ -407,9 +507,7 @@ def main():
|
|
|
407
507
|
|
|
408
508
|
filepaths = args.filepaths
|
|
409
509
|
|
|
410
|
-
# --- Interactive Mode ---
|
|
411
510
|
if args.it:
|
|
412
|
-
# Interactive mode only supports a single file, so we overwrite the filepaths list
|
|
413
511
|
filepath = Prompt.ask("Enter the path to the file you want to upload")
|
|
414
512
|
filepaths = [filepath]
|
|
415
513
|
args.time = Prompt.ask("Set the file's expiration time (e.g., '24h', '7d')", default='24')
|
|
@@ -417,7 +515,6 @@ def main():
|
|
|
417
515
|
args.one_time = Confirm.ask("Delete the file after the first download?", default=False)
|
|
418
516
|
args.qr = Confirm.ask("Display a QR code of the download link?", default=False)
|
|
419
517
|
|
|
420
|
-
# --- Validate Inputs ---
|
|
421
518
|
if not filepaths:
|
|
422
519
|
console.print(Panel("[bold red]Error:[/] No file path(s) provided.", title="[bold red]Error[/bold red]", border_style="red"))
|
|
423
520
|
parser.print_help()
|
|
@@ -428,24 +525,41 @@ def main():
|
|
|
428
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"))
|
|
429
526
|
sys.exit(1)
|
|
430
527
|
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
|
|
433
539
|
for i, filepath in enumerate(filepaths):
|
|
434
540
|
actual_path = filepath
|
|
435
541
|
is_temp = False
|
|
436
|
-
|
|
542
|
+
temp_dir = None
|
|
543
|
+
|
|
437
544
|
if os.path.isdir(filepath):
|
|
438
545
|
console.print(f"[bold yellow]Zipping directory '{os.path.basename(filepath)}'...[/]")
|
|
439
|
-
actual_path = zip_directory(filepath)
|
|
546
|
+
actual_path, temp_dir = zip_directory(filepath)
|
|
440
547
|
is_temp = True
|
|
441
548
|
|
|
442
549
|
if len(filepaths) > 1:
|
|
443
550
|
console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(actual_path)}[/bold yellow]\n")
|
|
444
|
-
|
|
445
|
-
upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url)
|
|
446
|
-
|
|
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
|
+
|
|
447
556
|
if is_temp:
|
|
448
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)
|
|
449
563
|
|
|
450
564
|
|
|
451
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
|