Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 60 additions & 26 deletions src/cli/app/builder/urls.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,81 @@
from urllib.parse import urljoin, urlparse

from urllib.parse import urljoin, urlparse, urlunparse
from app.settings import settings
import typer


class UrlBuilder:
def __init__(self, instance_url: str):
# Add https:// if scheme is missing
parsed = urlparse(instance_url)
if not parsed.scheme:
instance_url = "https://" + instance_url
def __init__(self, backend_url: str, frontend_url: str):
# Ensure schemes are present
self.__backend_url = self.__ensure_scheme(backend_url)
self.__frontend_url = self.__ensure_scheme(frontend_url)

@staticmethod
def __ensure_scheme(url: str) -> str:
if not urlparse(url).scheme:
return "https://" + url.lstrip("/")
return url

self.__instance_url = instance_url.rstrip("/") + "/"
@staticmethod
def __ensure_trailing_slash(url: str) -> str:
parsed = urlparse(url)
if not parsed.path.endswith("/"):
new_path = parsed.path + "/"
parsed = parsed._replace(path=new_path)
return urlunparse(parsed)

@classmethod
def resolve(cls, instance_url: str | None = None) -> "UrlBuilder":
"""Resolve instance URL from arg/env/prompt and return UrlBuilder."""
import typer # local import to avoid hard dep at module level

url = instance_url or settings.INSTANCE_URL
if not url:
url = typer.prompt(
"Enter the Chithi instance URL (e.g. https://chithi.dev)"
def resolve(cls, initial_url: str | None = None) -> "UrlBuilder":
"""
Logic:
1. If a URL is passed from CLI/Link, use it.
2. If not, check settings.
3. If still nothing, prompt user for 'Same Domain' setup.
"""
url_candidate = initial_url or settings.INSTANCE_URL

if url_candidate:
# If we already have a URL, we treat it as both for simplicity
return cls(backend_url=url_candidate, frontend_url=url_candidate)

# Interactive Setup
same_domain = typer.confirm(
"Are the backend and frontend hosted on the same domain?", default=True
)

if same_domain:
domain = typer.prompt("Enter the base domain", default="chithi.dev").strip(
"/"
)
# Standard structure: root for frontend, /api/ for backend
return cls(
backend_url=f"https://{domain}/api/", frontend_url=f"https://{domain}/"
)
else:
frontend = typer.prompt("Enter the Frontend URL (e.g. https://chithi.dev)")
backend = typer.prompt(
"Enter the Backend URL (e.g. https://api.chithi.dev)"
)
return cls(url)
return cls(backend_url=backend, frontend_url=frontend)

@property
def instance_url(self):
return self.__instance_url
"""Standardized backend base URL."""
return self.__ensure_trailing_slash(self.__backend_url)

@property
def __api_url(self):
return urljoin(self.instance_url, "api/")
def frontend_url(self):
"""Standardized frontend base URL."""
return self.__ensure_trailing_slash(self.__frontend_url)

def upload_url(self):
# SENSITIVE URL
return urljoin(self.__api_url, "upload")
return urljoin(self.instance_url, "upload")

def config_url(self):
return urljoin(self.__api_url, "config")
return urljoin(self.instance_url, "config")

def download_url(self):
return urljoin(self.__api_url, "download/")
return urljoin(self.instance_url, "download/")

def share_url(self, slug: str, key_secret: str) -> str:
"""Web-facing download link: ``https://instance/download/<slug>#<key>``."""
return f"{self.instance_url}download/{slug}#{key_secret}"
# Share URLs should point to the Frontend UI
return f"{self.frontend_url}download/{slug}#{key_secret}"
65 changes: 16 additions & 49 deletions src/cli/app/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,96 +14,63 @@

@app.command()
def download(
link: Annotated[
str,
typer.Argument(
help="Chithi share URL (https://…/download/<slug>#<key>) or '<slug>#<key>'.",
),
],
instance_url: Annotated[
str | None,
typer.Option("--url", "-u", help="Chithi backend URL."),
] = None,
password: Annotated[
str | None,
typer.Option(
"--password",
"-p",
help="Decryption password (required if the file was password-protected).",
prompt=False,
),
] = None,
output: Annotated[
Path,
typer.Option(
"--output", "-o", help="Destination directory for extracted files."
),
] = Path("."),
link: Annotated[str, typer.Argument(help="Chithi share URL or 'slug#key'")],
instance_url: Annotated[str | None, typer.Option("--url", "-u")] = None,
password: Annotated[str | None, typer.Option("--password", "-p")] = None,
output: Annotated[Path, typer.Option("--output", "-o")] = Path("."),
) -> None:
"""Download, decrypt, and extract a file."""
# Parse Input
try:
slug: str
key_secret: str
inferred_url: str | None = None

# Full URL -> https://instance.com/download/SLUG#KEY
# Case A: Full URL provided
if "://" in link:
parsed = urlparse(link)
fragment = parsed.fragment
key_secret = parsed.fragment
path_parts = parsed.path.strip("/").split("/")

# Expecting path ending in /download/SLUG
if len(path_parts) >= 2 and path_parts[-2] == "download":
slug = path_parts[-1]
elif len(path_parts) >= 1:
if len(path_parts) >= 1:
slug = path_parts[-1]
else:
raise ValueError("Could not extract slug from URL.")

# Reconstruct base URL (everything before /download/...)
# Reconstruct the base (e.g., https://chithi.dev)
inferred_url = f"{parsed.scheme}://{parsed.netloc}"
key_secret = fragment

# SLUG#KEY
# Case B: Just SLUG#KEY provided
elif "#" in link:
slug, key_secret = link.split("#", 1)

else:
raise ValueError("Invalid link format. Expected URL or SLUG#KEY")
raise ValueError("Invalid format. Use URL or SLUG#KEY")

except ValueError as e:
typer.echo(f"✗ Input parsing failed: {e}", err=True)
raise typer.Exit(code=1)
raise typer.Exit(1)

base_url = instance_url or inferred_url
urls = UrlBuilder.resolve(base_url)
# Resolve URLs: Priority is --url option > Link metadata > Interactive Prompt
urls = UrlBuilder.resolve(initial_url=(instance_url or inferred_url))

# Process
# Process Download
fd, tmp_run = tempfile.mkstemp(prefix="chithi_")
os.close(fd)

tmp_dl = Path(f"{tmp_run}.dl")
tmp_zip = Path(f"{tmp_run}.zip")

try:
# Download
with client.Client(urls) as c:
c.download_to_file(slug, tmp_dl)

# Decrypt
ikm = crypto.base64url_to_ikm(key_secret)
crypto.decrypt(tmp_dl, tmp_zip, ikm=ikm, password=password)

# Extract
out_path = output.resolve()
archive.decompress(tmp_zip, out_path, password=password)

typer.echo(f"\n✓ Download complete! Files extracted to {out_path}")
typer.echo(f"\n✓ Success! Extracted to {out_path}")

except Exception as exc:
typer.echo(f"✗ Download failed: {exc}", err=True)
raise typer.Exit(code=1)

raise typer.Exit(1)
finally:
cleanup(tmp_dl, tmp_zip)
Loading
Loading