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
1 change: 1 addition & 0 deletions src/cli/app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


def main() -> None:
"""Invoke the CLI application."""
app()


Expand Down
12 changes: 12 additions & 0 deletions src/cli/app/builder/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,30 @@


class UrlBuilder:
"""Builds backend and frontend URLs for the CLI."""

def __init__(self, backend_url: str, frontend_url: str) -> None:
"""Create a URL builder from base backend and frontend URLs."""
self._backend_url = self.__ensure_scheme(backend_url)
self._frontend_url = self.__ensure_scheme(frontend_url)

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

@staticmethod
def __ensure_trailing_slash(url: str) -> str:
"""Ensure the URL ends with a trailing slash."""
if not url.endswith("/"):
return url + "/"
return url

@classmethod
def resolve(cls, initial_url: str | None = None) -> "UrlBuilder":
"""Resolve URLs from input, settings, or interactive prompts."""
url_candidate = initial_url or settings.INSTANCE_URL

if url_candidate:
Expand All @@ -46,22 +52,28 @@ def resolve(cls, initial_url: str | None = None) -> "UrlBuilder":

@property
def backend_url(self) -> str:
"""Return the normalized backend base URL."""
return self.__ensure_trailing_slash(self._backend_url)

@property
def frontend_url(self) -> str:
"""Return the normalized frontend base URL."""
return self.__ensure_trailing_slash(self._frontend_url)

def upload_url(self) -> str:
"""Return the upload endpoint URL."""
return urljoin(self.backend_url, "upload")

def config_url(self) -> str:
"""Return the config endpoint URL."""
return urljoin(self.backend_url, "config")

def download_url(self) -> str:
"""Return the download endpoint base URL."""
return urljoin(self.backend_url, "download/")

def share_url(self, slug: str, key_secret: str) -> str:
"""Build a shareable frontend URL including the key fragment."""
# urljoin handles the path joining
base_share = urljoin(self.frontend_url, f"download/{slug}")
return f"{base_share}#{key_secret}"
10 changes: 10 additions & 0 deletions src/cli/app/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,33 @@ class _ProgressReader:
"""Wraps a file object and updates a tqdm bar on every read."""

def __init__(self, fp: BinaryIO, pbar: tqdm) -> None:
"""Create a progress-aware wrapper around a binary file object."""
self._fp = fp
self._pbar = pbar

def read(self, size: int = -1) -> bytes:
"""Read bytes and advance the progress bar."""
data = self._fp.read(size)
if data:
self._pbar.update(len(data))
return data

def seek(self, offset: int, whence: int = 0) -> int:
"""Seek within the wrapped file object."""
return self._fp.seek(offset, whence)

def tell(self) -> int:
"""Return the current stream position."""
return self._fp.tell()

def __getattr__(self, name: str) -> Any:
"""Delegate missing attributes to the wrapped file object."""
return getattr(self._fp, name)


class Client:
"""Async API client for uploads and downloads."""

def __init__(self, urls: UrlBuilder):
"""
Initialize with a UrlBuilder instance.
Expand All @@ -57,6 +64,7 @@ def __init__(self, urls: UrlBuilder):
)

async def __aenter__(self) -> Self:
"""Enter the async context manager."""
return self

async def __aexit__(
Expand All @@ -65,9 +73,11 @@ async def __aexit__(
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""Exit the async context manager and close the session."""
await self.close()

async def close(self) -> None:
"""Close the underlying HTTP session."""
await self._session.aclose()

@classmethod
Expand Down
19 changes: 10 additions & 9 deletions src/cli/app/commands/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
from app.helpers.file import cleanup
from app.helpers.print import print_compact_qr

app = typer.AsyncTyper(help="Upload encrypted files via Chithi.")
console = Console()
app: typer.AsyncTyper = typer.AsyncTyper(help="Upload encrypted files via Chithi.")
console: Console = Console()
error_console: Console = Console(stderr=True)


@app.async_command()
Expand All @@ -32,7 +33,7 @@ async def upload(
bool, typer.Option("--no-qr", help="Do not print the QR code.")
] = False,
) -> None:
"""Compress, encrypt, and upload a file or folder."""
"""Compress, encrypt, and upload a file or folder, then print the share link."""
try:
# Resolve URLs based on input/prompts
urls = UrlBuilder.resolve(instance_url)
Expand Down Expand Up @@ -93,18 +94,18 @@ async def upload(
# UI Output Logic
if minimal:
# Clean output for scripts or pipes
typer.echo(download_url)
console.print(download_url, highlight=False, markup=False)
else:
# Pretty output
typer.echo("\n✓ Upload complete!")
console.print("\n[green]✓ Upload complete![/green]")
if not no_qr:
print_compact_qr(download_url, console)
typer.echo(f"\n Download URL : {download_url}")
console.print(f"\n Download URL : {download_url}")
if password:
typer.echo(
" ⚠ Password-protected. Recipients will need the password to decrypt."
console.print(
" [yellow]⚠ Password-protected. Recipients will need the password to decrypt.[/yellow]"
)

except Exception as exc:
typer.echo(f"✗ Upload failed: {exc}", err=True)
error_console.print(f"[red]✗ Upload failed: {exc}[/red]")
raise typer.Exit(code=1)
1 change: 1 addition & 0 deletions src/cli/app/helpers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@


def _get_source_size(source: Path) -> int:
"""Return the size of a file or total size of a directory tree."""
if source.is_file():
return source.stat().st_size
return sum(f.stat().st_size for f in source.rglob("*") if f.is_file())
Expand Down
4 changes: 4 additions & 0 deletions src/cli/app/helpers/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ def generate_ikm() -> bytes:


def ikm_to_base64url(ikm: bytes) -> str:
"""Encode IKM as URL-safe base64 without padding."""
return base64.urlsafe_b64encode(ikm).rstrip(b"=").decode("ascii")


def base64url_to_ikm(s: str) -> bytes:
"""Decode URL-safe base64 into IKM bytes."""
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)

Expand Down Expand Up @@ -137,6 +139,7 @@ def _derive_secrets(ikm: bytes, password: str | None) -> Tuple[bytes, bytes]:


def _argon2(password: bytes, salt: bytes, iterations: int, memory: int) -> bytes:
"""Run Argon2id with provided parameters."""
kdf = Argon2id(
salt=salt,
length=32,
Expand All @@ -148,6 +151,7 @@ def _argon2(password: bytes, salt: bytes, iterations: int, memory: int) -> bytes


def _get_chunk_iv(base_iv: bytes, index: int) -> bytes:
"""Derive a per-chunk IV by XORing the index into the base IV."""
iv = bytearray(base_iv)
existing = struct.unpack_from("!I", iv, 8)[0]
struct.pack_into("!I", iv, 8, existing ^ index)
Expand Down
2 changes: 2 additions & 0 deletions src/cli/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@


class Settings(BaseSettings):
"""Environment-driven settings for the CLI."""

INSTANCE_URL: str | None = None
EXPIRE_AFTER_N_DOWNLOAD: int | None = None
EXPIRE_AFTER: int | None = None
Expand Down
6 changes: 6 additions & 0 deletions src/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@


class NuitkaBuilder:
"""Builds a standalone CLI binary using Nuitka."""

def __init__(self, target: str = "./app", output_name: str | None = None) -> None:
"""Create a builder for a target module path."""
self.target = target
self.output_name = output_name
self.base_args: list[str] = [
Expand All @@ -27,6 +30,7 @@ def __init__(self, target: str = "./app", output_name: str | None = None) -> Non
]

def build_windows(self, debug: bool = False) -> None:
"""Build a Windows binary with optional debug flags."""
args = self.base_args.copy()
args.extend(
[
Expand All @@ -42,11 +46,13 @@ def build_windows(self, debug: bool = False) -> None:
self._run(args)

def build_linux(self) -> None:
"""Build a Linux binary using clang."""
args = self.base_args.copy()
args.append("--clang")
self._run(args)

def _run(self, args: list[str]) -> None:
"""Run the Nuitka build with the assembled arguments."""
if self.output_name:
args.append(f"--output-filename={self.output_name}")

Expand Down
49 changes: 27 additions & 22 deletions src/frontend/src/lib/components/QRCode.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn } from '$lib/utils';
import QRCode from 'qrcode';
import { renderSVG } from 'uqr';

interface Props {
value: string;
Expand All @@ -18,36 +18,41 @@
class: klass
}: Props = $props();

let canvas = $state<null | HTMLCanvasElement>(null);
let svgMarkup = $state('');

$effect(() => {
if (canvas) {
QRCode.toCanvas(
canvas,
value,
{
width: size,
margin: 1,
color: {
dark: color,
light: backgroundColor
}
},
(error) => {
if (error) console.error(error);
}
);
const svg = renderSVG(value, {
border: 1,
pixelSize: 1,
blackColor: color,
whiteColor: backgroundColor
});

if (klass) {
const safeClass = klass.replace(/"/g, '&quot;');
svgMarkup = svg.replace('<svg', `<svg class="${safeClass}"`);
return;
}

svgMarkup = svg;
});
</script>

<div
class={cn(
`grid h-fit w-fit`,
// I dont like the fact that canvas is a image and people can right click canvas to get the image
// So i am making the canvas unclickable using the same technique facebook/instagram uses
`qr-code grid h-fit w-fit`,
// Prevent right-click save on the rendered SVG by overlaying a transparent layer.
`after:col-start-1 after:row-start-1 after:h-full after:w-full after:content-['']`
)}
style={`width: ${size}px; height: ${size}px;`}
>
<canvas bind:this={canvas} class={cn(klass, 'col-start-1 row-start-1')}></canvas>
{@html svgMarkup}
</div>

<style>
.qr-code :global(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
Loading