Skip to content

Commit 2e0d92b

Browse files
fix: download logic (#577)
* fix: better url parsing * improve implementation * fix:downloading logic
1 parent 5f69850 commit 2e0d92b

4 files changed

Lines changed: 117 additions & 129 deletions

File tree

src/cli/app/builder/urls.py

Lines changed: 32 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
from urllib.parse import urljoin, urlparse, urlunparse
1+
from urllib.parse import urljoin, urlparse
22
from app.settings import settings
33
import typer
44

55

66
class UrlBuilder:
77
def __init__(self, backend_url: str, frontend_url: str):
8-
# Ensure schemes are present
9-
self.__backend_url = self.__ensure_scheme(backend_url)
10-
self.__frontend_url = self.__ensure_scheme(frontend_url)
8+
self._backend_url = self.__ensure_scheme(backend_url)
9+
self._frontend_url = self.__ensure_scheme(frontend_url)
1110

1211
@staticmethod
1312
def __ensure_scheme(url: str) -> str:
@@ -17,65 +16,50 @@ def __ensure_scheme(url: str) -> str:
1716

1817
@staticmethod
1918
def __ensure_trailing_slash(url: str) -> str:
20-
parsed = urlparse(url)
21-
if not parsed.path.endswith("/"):
22-
new_path = parsed.path + "/"
23-
parsed = parsed._replace(path=new_path)
24-
return urlunparse(parsed)
19+
if not url.endswith("/"):
20+
return url + "/"
21+
return url
2522

2623
@classmethod
2724
def resolve(cls, initial_url: str | None = None) -> "UrlBuilder":
28-
"""
29-
Logic:
30-
1. If a URL is passed from CLI/Link, use it.
31-
2. If not, check settings.
32-
3. If still nothing, prompt user for 'Same Domain' setup.
33-
"""
3425
url_candidate = initial_url or settings.INSTANCE_URL
3526

3627
if url_candidate:
37-
# If we already have a URL, we treat it as both for simplicity
38-
return cls(backend_url=url_candidate, frontend_url=url_candidate)
39-
40-
# Interactive Setup
41-
same_domain = typer.confirm(
42-
"Are the backend and frontend hosted on the same domain?", default=True
43-
)
28+
base = cls.__ensure_scheme(url_candidate)
29+
# If "api" isn't in the URL, we assume the backend is at /api/
30+
if "/api" not in base.lower():
31+
base_dir = cls.__ensure_trailing_slash(base)
32+
return cls(backend_url=urljoin(base_dir, "api/"), frontend_url=base_dir)
33+
return cls(backend_url=base, frontend_url=base)
4434

45-
if same_domain:
46-
domain = typer.prompt("Enter the base domain", default="chithi.dev").strip(
47-
"/"
48-
)
49-
# Standard structure: root for frontend, /api/ for backend
50-
return cls(
51-
backend_url=f"https://{domain}/api/", frontend_url=f"https://{domain}/"
52-
)
35+
# Interactive Fallback
36+
if typer.confirm("Are backend and frontend on the same domain?", default=True):
37+
domain = typer.prompt("Enter base domain", default="chithi.dev").strip("/")
38+
base = f"https://{domain}/"
39+
return cls(backend_url=urljoin(base, "api/"), frontend_url=base)
5340
else:
54-
frontend = typer.prompt("Enter the Frontend URL (e.g. https://chithi.dev)")
55-
backend = typer.prompt(
56-
"Enter the Backend URL (e.g. https://api.chithi.dev)"
57-
)
41+
frontend = typer.prompt("Frontend URL (e.g. https://chithi.dev)")
42+
backend = typer.prompt("Backend URL (e.g. https://api.chithi.dev)")
5843
return cls(backend_url=backend, frontend_url=frontend)
5944

6045
@property
61-
def instance_url(self):
62-
"""Standardized backend base URL."""
63-
return self.__ensure_trailing_slash(self.__backend_url)
46+
def backend_url(self) -> str:
47+
return self.__ensure_trailing_slash(self._backend_url)
6448

6549
@property
66-
def frontend_url(self):
67-
"""Standardized frontend base URL."""
68-
return self.__ensure_trailing_slash(self.__frontend_url)
50+
def frontend_url(self) -> str:
51+
return self.__ensure_trailing_slash(self._frontend_url)
6952

70-
def upload_url(self):
71-
return urljoin(self.instance_url, "upload")
53+
def upload_url(self) -> str:
54+
return urljoin(self.backend_url, "upload")
7255

73-
def config_url(self):
74-
return urljoin(self.instance_url, "config")
56+
def config_url(self) -> str:
57+
return urljoin(self.backend_url, "config")
7558

76-
def download_url(self):
77-
return urljoin(self.instance_url, "download/")
59+
def download_url(self) -> str:
60+
return urljoin(self.backend_url, "download/")
7861

7962
def share_url(self, slug: str, key_secret: str) -> str:
80-
# Share URLs should point to the Frontend UI
81-
return f"{self.frontend_url}download/{slug}#{key_secret}"
63+
# urljoin handles the path joining
64+
base_share = urljoin(self.frontend_url, f"download/{slug}")
65+
return f"{base_share}#{key_secret}"

src/cli/app/client.py

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,19 @@ def __getattr__(self, name):
3737

3838

3939
class Client:
40-
def __init__(self, instance_url: str | UrlBuilder):
41-
self.urls = (
42-
instance_url
43-
if isinstance(instance_url, UrlBuilder)
44-
else UrlBuilder(instance_url)
45-
)
40+
def __init__(self, urls: UrlBuilder):
41+
"""
42+
Initialize with a UrlBuilder instance.
43+
Uses backend_url for API calls and frontend_url for headers.
44+
"""
45+
self.urls = urls
4646
self._session = requests.Session()
47+
48+
# Set headers based on the frontend URL to avoid CORS/Security issues
4749
self._session.headers.update(
4850
{
49-
"Origin": self.urls.instance_url,
50-
"Referer": self.urls.instance_url,
51+
"Origin": self.urls.frontend_url.rstrip("/"),
52+
"Referer": self.urls.frontend_url,
5153
}
5254
)
5355

@@ -61,11 +63,18 @@ def close(self):
6163
self._session.close()
6264

6365
@classmethod
64-
def resolve(cls, instance_url: str | None = None) -> "Client":
65-
"""Resolve instance URL from arg/env/prompt and return Client."""
66-
urls = UrlBuilder.resolve(instance_url)
66+
def resolve(cls, initial_url: str | None = None) -> "Client":
67+
"""Resolve URLs via UrlBuilder and return a Client."""
68+
urls = UrlBuilder.resolve(initial_url)
6769
return cls(urls)
6870

71+
def get_config(self) -> dict:
72+
"""Fetch the server configuration using the backend URL."""
73+
url = self.urls.config_url()
74+
response = self._session.get(url, timeout=30)
75+
response.raise_for_status()
76+
return response.json()
77+
6978
def upload_file(
7079
self,
7180
file_path: Path,
@@ -74,21 +83,18 @@ def upload_file(
7483
expire_after: int | None = None,
7584
) -> dict:
7685
"""
77-
Stream-upload *file_path* to ``POST /upload`` on the backend.
78-
Returns the JSON response (contains the download key).
86+
Stream-upload *file_path* to the backend.
87+
Uses the resolved backend_url/upload endpoint.
7988
"""
80-
if expire_after_n_download is None:
81-
expire_after_n_download = settings.EXPIRE_AFTER_N_DOWNLOAD
82-
83-
if expire_after is None:
84-
expire_after = settings.EXPIRE_AFTER
89+
# Resolve expiration settings
90+
expire_n = expire_after_n_download or settings.EXPIRE_AFTER_N_DOWNLOAD
91+
expire_t = expire_after or settings.EXPIRE_AFTER
8592

86-
if expire_after_n_download is None or expire_after is None:
93+
# Fallback to server defaults if not set locally
94+
if expire_n is None or expire_t is None:
8795
config = self.get_config()
88-
if expire_after_n_download is None:
89-
expire_after_n_download = config["default_number_of_downloads"]
90-
if expire_after is None:
91-
expire_after = config["default_expiry"]
96+
expire_n = expire_n or config.get("default_number_of_downloads")
97+
expire_t = expire_t or config.get("default_expiry")
9298

9399
upload_url = self.urls.upload_url()
94100
display_name = filename or file_path.name
@@ -106,41 +112,52 @@ def upload_file(
106112
):
107113
wrapped = _ProgressReader(f, pbar)
108114
wrapped_io = cast(BinaryIO, wrapped)
115+
116+
# Multipart form data
109117
files = {"file": (display_name, wrapped_io, "application/octet-stream")}
110118
data = {
111119
"filename": display_name,
112-
"expire_after_n_download": str(expire_after_n_download),
113-
"expire_after": str(expire_after),
120+
"expire_after_n_download": str(expire_n),
121+
"expire_after": str(expire_t),
114122
}
115123

116124
response = self._session.post(
117125
upload_url,
118126
data=data,
119127
files=files,
120-
timeout=(30, None),
128+
timeout=(
129+
30,
130+
None,
131+
), # 30s connect timeout, infinite read timeout for large files
121132
)
133+
134+
# If the backend returned HTML (redirect/error), catch it here
135+
if "text/html" in response.headers.get("Content-Type", ""):
136+
raise ConnectionError(
137+
f"Upload failed: Server returned HTML instead of JSON from {upload_url}"
138+
)
139+
122140
response.raise_for_status()
123141
return response.json()
124142

125-
def get_config(self) -> dict:
126-
"""Fetch the server configuration."""
127-
url = self.urls.config_url()
128-
response = self._session.get(url, timeout=30)
129-
response.raise_for_status()
130-
return response.json()
131-
132143
def download_to_file(self, key: str, dest: Path) -> Path:
133-
"""
134-
Stream-download a file and write it to *dest* with a progress bar.
135-
Returns *dest*.
136-
"""
144+
"""Stream-download a file using the backend URL."""
137145
download_url = f"{self.urls.download_url()}{key}"
138146

139147
with self._session.get(
140148
download_url, stream=True, timeout=(30, None)
141149
) as response:
150+
# Validation: Catch frontend HTML responses before they hit the crypto layer
151+
content_type = response.headers.get("Content-Type", "")
152+
if "text/html" in content_type:
153+
raise ConnectionError(
154+
f"Expected binary file, but got HTML. Your API URL is likely wrong.\n"
155+
f"Attempted URL: {download_url}"
156+
)
157+
142158
response.raise_for_status()
143159
total = int(response.headers.get("content-length", 0)) or None
160+
144161
with (
145162
open(dest, "wb") as f,
146163
tqdm(

src/cli/app/commands/download.py

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,75 +2,62 @@
22
from typing import Annotated
33
from urllib.parse import urlparse
44
import tempfile
5-
import os
65
import typer
7-
86
from app import archive, client, crypto
97
from app.builder.urls import UrlBuilder
10-
from app.helpers.file import cleanup
118

12-
app = typer.Typer(help="Upload & download encrypted files via Chithi.")
9+
app = typer.Typer(help="Download encrypted files via Chithi.")
1310

1411

1512
@app.command()
1613
def download(
17-
link: Annotated[str, typer.Argument(help="Chithi share URL or 'slug#key'")],
14+
link: Annotated[str, typer.Argument(help="URL or 'slug#key'")],
1815
instance_url: Annotated[str | None, typer.Option("--url", "-u")] = None,
1916
password: Annotated[str | None, typer.Option("--password", "-p")] = None,
2017
output: Annotated[Path, typer.Option("--output", "-o")] = Path("."),
21-
) -> None:
22-
"""Download, decrypt, and extract a file."""
18+
):
2319
try:
24-
slug: str
25-
key_secret: str
26-
inferred_url: str | None = None
20+
slug, key_secret, inferred_url = "", "", None
2721

28-
# Case A: Full URL provided
22+
# Parse the input link
2923
if "://" in link:
3024
parsed = urlparse(link)
3125
key_secret = parsed.fragment
32-
path_parts = parsed.path.strip("/").split("/")
33-
34-
if len(path_parts) >= 1:
35-
slug = path_parts[-1]
36-
else:
37-
raise ValueError("Could not extract slug from URL.")
38-
39-
# Reconstruct the base (e.g., https://chithi.dev)
26+
path_parts = [p for p in parsed.path.split("/") if p]
27+
if not key_secret or not path_parts:
28+
raise ValueError(
29+
"Link must be in format: https://domain/download/SLUG#KEY"
30+
)
31+
slug = path_parts[-1]
4032
inferred_url = f"{parsed.scheme}://{parsed.netloc}"
41-
42-
# Case B: Just SLUG#KEY provided
4333
elif "#" in link:
4434
slug, key_secret = link.split("#", 1)
4535
else:
4636
raise ValueError("Invalid format. Use URL or SLUG#KEY")
4737

48-
except ValueError as e:
49-
typer.echo(f"✗ Input parsing failed: {e}", err=True)
50-
raise typer.Exit(1)
38+
urls = UrlBuilder.resolve(initial_url=(instance_url or inferred_url))
5139

52-
# Resolve URLs: Priority is --url option > Link metadata > Interactive Prompt
53-
urls = UrlBuilder.resolve(initial_url=(instance_url or inferred_url))
40+
# Use a TemporaryDirectory for thread-safe, secure file handling
41+
with tempfile.TemporaryDirectory(prefix="chithi_") as tmp_dir:
42+
tmp_path = Path(tmp_dir)
43+
tmp_dl = tmp_path / "encrypted.bin"
44+
tmp_zip = tmp_path / "decrypted.zip"
5445

55-
# Process Download
56-
fd, tmp_run = tempfile.mkstemp(prefix="chithi_")
57-
os.close(fd)
58-
tmp_dl = Path(f"{tmp_run}.dl")
59-
tmp_zip = Path(f"{tmp_run}.zip")
46+
# Download
47+
with client.Client(urls) as c:
48+
c.download_to_file(slug, tmp_dl)
6049

61-
try:
62-
with client.Client(urls) as c:
63-
c.download_to_file(slug, tmp_dl)
50+
# Decrypt
51+
ikm = crypto.base64url_to_ikm(key_secret)
52+
crypto.decrypt(tmp_dl, tmp_zip, ikm=ikm, password=password)
6453

65-
ikm = crypto.base64url_to_ikm(key_secret)
66-
crypto.decrypt(tmp_dl, tmp_zip, ikm=ikm, password=password)
54+
# Decompress
55+
out_path = output.resolve()
56+
archive.decompress(tmp_zip, out_path, password=password)
6757

68-
out_path = output.resolve()
69-
archive.decompress(tmp_zip, out_path, password=password)
70-
typer.echo(f"\n✓ Success! Extracted to {out_path}")
58+
typer.secho(f"\n✓ Success! Extracted to {out_path}", fg=typer.colors.GREEN)
7159

7260
except Exception as exc:
73-
typer.echo(f"✗ Download failed: {exc}", err=True)
61+
typer.secho(f"✗ Download failed: {exc}", fg=typer.colors.RED, err=True)
7462
raise typer.Exit(1)
75-
finally:
76-
cleanup(tmp_dl, tmp_zip)
63+
# No 'finally' block needed! TemporaryDirectory cleans itself up automatically.

src/cli/app/commands/upload.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from app.helpers.file import cleanup
1111
from app.helpers.print import print_compact_qr
1212

13-
app = typer.Typer(help="Upload & download encrypted files via Chithi.")
13+
app = typer.Typer(help="Upload encrypted files via Chithi.")
1414
console = Console()
1515

1616

@@ -74,7 +74,7 @@ def upload(
7474
finally:
7575
cleanup(tmp_zip, tmp_enc)
7676

77-
# --- UI Output Logic ---
77+
# UI Output Logic
7878
if minimal:
7979
# Clean output for scripts or pipes
8080
typer.echo(download_url)

0 commit comments

Comments
 (0)