From 42064de63366f149978bd0dfacb87c4a2725eecc Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Mon, 27 Jul 2026 15:52:34 +0200 Subject: [PATCH] feat(cli): add private NanoShare sync client - Add a separate installable NanoShare CLI package under cli/. - Implement browser login with local callback and refresh-token config. - Add upload, list, download, sync, and watch CLI commands. - Add private owner-only file download endpoint for CLI downloads. - Add CLI auth endpoints for browser login and token refresh. - Return file_id from ServiceLink uploads for reliable sync state. - Exclude the CLI package from NanoShare container builds. - Include tests for private downloads and sync state updates. --- .dockerignore | 15 +++ cli/README.md | 20 ++++ cli/nanoshare_client/__init__.py | 1 + cli/nanoshare_client/__main__.py | 8 ++ cli/nanoshare_client/app.py | 68 +++++++++++ cli/nanoshare_client/auth.py | 101 +++++++++++++++++ cli/nanoshare_client/cli.py | 143 +++++++++++++++++++++++ cli/nanoshare_client/client.py | 54 +++++++++ cli/nanoshare_client/config.py | 20 ++++ cli/nanoshare_client/envelope.py | 31 +++++ cli/nanoshare_client/http_client.py | 94 +++++++++++++++ cli/nanoshare_client/sync.py | 170 ++++++++++++++++++++++++++++ cli/pyproject.toml | 16 +++ quart_common | 2 +- routes/__init__.py | 3 + routes/api/cli_auth.py | 99 ++++++++++++++++ routes/api/download.py | 54 +++++++++ routes/api/link.py | 5 +- run.py | 4 + tests/test_api_file_download.py | 127 +++++++++++++++++++++ tests/test_nanoshare_cli.py | 86 ++++++++++++++ 21 files changed, 1118 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 cli/README.md create mode 100644 cli/nanoshare_client/__init__.py create mode 100644 cli/nanoshare_client/__main__.py create mode 100644 cli/nanoshare_client/app.py create mode 100644 cli/nanoshare_client/auth.py create mode 100644 cli/nanoshare_client/cli.py create mode 100644 cli/nanoshare_client/client.py create mode 100644 cli/nanoshare_client/config.py create mode 100644 cli/nanoshare_client/envelope.py create mode 100644 cli/nanoshare_client/http_client.py create mode 100644 cli/nanoshare_client/sync.py create mode 100644 cli/pyproject.toml create mode 100644 routes/api/cli_auth.py create mode 100644 routes/api/download.py create mode 100644 tests/test_api_file_download.py create mode 100644 tests/test_nanoshare_cli.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5050b2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.venv +__pycache__ +.pytest_cache +.ruff_cache +*.pyc +*.pyo +*.pyd +.env +testing/.env +access.log +valkey_data +redisinsight +uploads +cli diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..7241bc8 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,20 @@ +# NanoShare CLI +Desktop CLI and folder sync client for NanoShare. + +## Install locally +```bash +uv tool install ./cli +``` + +## Login +```bash +nanoshare login https://your-nanoshare.example +``` + +## Sync +```bash +nanoshare sync ~/NanoShare +nanoshare watch ~/NanoShare --interval 10 +``` + +The CLI stores config in `~/.config/nanoshare/config.toml` and sync state in `.nanoshare-sync/state.json` inside the synced folder. diff --git a/cli/nanoshare_client/__init__.py b/cli/nanoshare_client/__init__.py new file mode 100644 index 0000000..b325169 --- /dev/null +++ b/cli/nanoshare_client/__init__.py @@ -0,0 +1 @@ +"""NanoShare CLI client helpers.""" diff --git a/cli/nanoshare_client/__main__.py b/cli/nanoshare_client/__main__.py new file mode 100644 index 0000000..cc2f0ae --- /dev/null +++ b/cli/nanoshare_client/__main__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys + +from .cli import main + +if __name__ == '__main__': + sys.exit(main()) diff --git a/cli/nanoshare_client/app.py b/cli/nanoshare_client/app.py new file mode 100644 index 0000000..747a9a3 --- /dev/null +++ b/cli/nanoshare_client/app.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +try: + import tomllib +except ImportError: # pragma: no cover + tomllib = None + +from .config import DEFAULT_CONFIG +from .http_client import NanoShareClient + +def parse_registry_env(value: str) -> dict[str, str]: + registry: dict[str, str] = {} + for pair in value.split(','): + pair = pair.strip() + if not pair: + continue + name, _, url = pair.partition('=') + if name.strip() and url.strip(): + registry[name.strip()] = url.strip() + return registry + +def load_config(path: str | os.PathLike | None) -> dict: + if not path or tomllib is None: + return {} + config_path = Path(path).expanduser() + if not config_path.is_file(): + return {} + with config_path.open('rb') as handle: + return tomllib.load(handle) + +def resolve(args) -> tuple[dict[str, str], str, dict[str, str | None]]: + config_path = args.config or os.getenv('NANOSHARE_CONFIG') or DEFAULT_CONFIG + config = load_config(config_path) + + registry: dict[str, str] = dict(config.get('nodes', {})) + env_registry = os.getenv('NANOSHARE_REGISTRY') + if env_registry: + registry.update(parse_registry_env(env_registry)) + for item in (args.url or []): + name, _, url = item.partition('=') + if not url: + raise SystemExit(f'--url expects NODE=URL, got: {item!r}') + registry[name.strip()] = url.strip() + + source = args.source or os.getenv('NANOSHARE_SOURCE') or config.get('source') or 'nanoshare-cli' + auth = { + 'token': args.token or os.getenv('NANOSHARE_TOKEN') or config.get('token'), + 'refresh_token': getattr(args, 'refresh_token', None) or os.getenv('NANOSHARE_REFRESH_TOKEN') or config.get('refresh_token'), + 'token_url': getattr(args, 'token_url', None) or os.getenv('NANOSHARE_TOKEN_URL') or config.get('token_url'), + } + return registry, source, auth + +def make_client(args) -> NanoShareClient: + registry, source, auth = resolve(args) + if not registry: + raise SystemExit('no NanoShare node configured (run `nanoshare login BASE_URL`, pass --url, or set NANOSHARE_REGISTRY).') + return NanoShareClient( + source=source, + registry=registry, + token=auth.get('token'), + refresh_token=auth.get('refresh_token'), + token_url=auth.get('token_url'), + timeout=getattr(args, 'timeout', 30.0), + ) diff --git a/cli/nanoshare_client/auth.py b/cli/nanoshare_client/auth.py new file mode 100644 index 0000000..df74af4 --- /dev/null +++ b/cli/nanoshare_client/auth.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import secrets +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit + +import httpx + +from .config import DEFAULT_CONFIG, write_config + +def _make_authorize_url(base_url: str, callback_url: str, state: str, scope: str) -> str: + url_parts = urlsplit(f'{base_url.rstrip("/")}/auth/cli/login') + query = dict(parse_qs(url_parts.query)) + flat_query = {key: value[-1] if isinstance(value, list) else value for key, value in query.items()} + flat_query.update({'redirect_uri': callback_url, 'state': state, 'scope': scope}) + return urlunsplit(url_parts._replace(query=urlencode(flat_query))) + +class _CallbackServer: + def __init__(self, host: str, port: int, expected_state: str): + self.code: str | None = None + self.error: str | None = None + self.expected_state = expected_state + outer = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + return + + def do_GET(self): + params = parse_qs(urlsplit(self.path).query) + state = params.get('state', [''])[0] + if state != outer.expected_state: + outer.error = 'state mismatch' + self.send_response(400) + self.end_headers() + self.wfile.write(b'NanoShare CLI login failed: state mismatch') + return + outer.code = params.get('code', [''])[0] + outer.error = params.get('error', [''])[0] or None + self.send_response(200 if outer.code else 400) + self.end_headers() + if outer.code: + self.wfile.write(b'NanoShare CLI login complete. You can close this browser tab.') + else: + self.wfile.write(b'NanoShare CLI login failed.') + + self.httpd = ThreadingHTTPServer((host, port), Handler) + self.url = f'http://{host}:{self.httpd.server_port}/callback' + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + + def wait_for_code(self, timeout: float) -> str: + self.thread.start() + self.thread.join(timeout) + self.httpd.shutdown() + if self.error: + raise RuntimeError(self.error) + if not self.code: + raise TimeoutError('login timed out') + return self.code + +def login(args) -> int: + state = secrets.token_urlsafe(24) + server = _CallbackServer(args.callback_host, args.callback_port, state) + authorize_url = _make_authorize_url(args.base_url, server.url, state, args.scope) + + print(f'Opening browser for NanoShare login: {authorize_url}') + if not args.no_browser: + webbrowser.open(authorize_url) + else: + print(authorize_url) + + try: + code = server.wait_for_code(args.login_timeout) + response = httpx.post( + f'{args.base_url.rstrip("/")}/api/cli/token', + json={'code': code, 'state': state}, + timeout=args.timeout, + headers={'Accept': 'application/json'}, + ) + response.raise_for_status() + data = response.json() + refresh_token = data.get('refresh_token') + if not refresh_token: + raise RuntimeError('server returned no refresh_token') + config_path = args.config or DEFAULT_CONFIG + write_config( + config_path, + node=args.node, + url=args.base_url, + refresh_token=refresh_token, + token_url=f'{args.base_url.rstrip("/")}/token/refresh', + source=args.source, + ) + except Exception as exc: + print(f'login failed: {exc}') + return 1 + + print(f'Logged in. Config written to {config_path}') + return 0 diff --git a/cli/nanoshare_client/cli.py b/cli/nanoshare_client/cli.py new file mode 100644 index 0000000..46b3f63 --- /dev/null +++ b/cli/nanoshare_client/cli.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +from .app import make_client +from .auth import login as auth_login +from .client import download, list_remote, upload +from .config import DEFAULT_CONFIG +from .sync import sync_once + +DEFAULT_NODE = 'picoshare' + +def _cmd_login(args) -> int: + return auth_login(args) + +def _cmd_upload(args) -> int: + path = Path(args.file).expanduser().resolve() + if not path.is_file(): + print(f'not a file: {path}', file=sys.stderr) + return 1 + client = make_client(args) + try: + result = upload(client, args.node, path, args.name or path.name, args.note or '', args.expires or '') + except Exception as exc: + print(f'error: {exc}', file=sys.stderr) + return 1 + finally: + client.close() + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + +def _cmd_list(args) -> int: + client = make_client(args) + try: + files = list_remote(client, args.node) + except Exception as exc: + print(f'error: {exc}', file=sys.stderr) + return 1 + finally: + client.close() + for item in files: + print(f"{item.get('file_id')}\t{item.get('file_name')}\t{item.get('file_size', '')}") + return 0 + +def _cmd_download(args) -> int: + dest = Path(args.output).expanduser() if args.output else Path(args.file_id) + client = make_client(args) + try: + download(client, args.node, args.file_id, dest) + except Exception as exc: + print(f'error: {exc}', file=sys.stderr) + return 1 + finally: + client.close() + print(f'downloaded {args.file_id} -> {dest}') + return 0 + +def _cmd_sync(args) -> int: + client = make_client(args) + try: + return sync_once(args, client) + finally: + client.close() + +def _cmd_watch(args) -> int: + print(f'watching {args.folder} every {args.interval}s') + while True: + code = _cmd_sync(args) + if code: + return code + time.sleep(args.interval) + +def _add_common(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--config', default=str(DEFAULT_CONFIG), help='Path to config.toml.') + parser.add_argument('--url', action='append', metavar='NODE=URL', help='Override/add a node URL.') + parser.add_argument('--token', help='Static bearer token.') + parser.add_argument('--refresh-token', dest='refresh_token', help='Refresh token to auto-exchange for access tokens.') + parser.add_argument('--token-url', dest='token_url', help='Token-refresh endpoint.') + parser.add_argument('--source', default='nanoshare-cli', help='Source node name.') + parser.add_argument('--timeout', type=float, default=30.0) + parser.add_argument('--node', default=DEFAULT_NODE, help='NanoShare node name.') + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog='nanoshare', description='NanoShare CLI and folder sync client.') + sub = parser.add_subparsers(dest='command', required=True) + + login = sub.add_parser('login', help='Login via browser and write CLI config.') + login.add_argument('base_url', help='NanoShare base URL, e.g. https://nanoshare.example') + login.add_argument('--config', type=Path, default=DEFAULT_CONFIG) + login.add_argument('--node', default=DEFAULT_NODE) + login.add_argument('--source', default='nanoshare-cli') + login.add_argument('--scope', default='files') + login.add_argument('--callback-host', default='127.0.0.1') + login.add_argument('--callback-port', type=int, default=0) + login.add_argument('--login-timeout', type=float, default=300.0) + login.add_argument('--timeout', type=float, default=30.0) + login.add_argument('--no-browser', action='store_true') + login.set_defaults(func=_cmd_login) + + upload_cmd = sub.add_parser('upload', help='Upload one file.') + _add_common(upload_cmd) + upload_cmd.add_argument('file') + upload_cmd.add_argument('--name', help='Remote file name.') + upload_cmd.add_argument('--note', default='uploaded from nanoshare cli') + upload_cmd.add_argument('--expires', default='') + upload_cmd.set_defaults(func=_cmd_upload) + + list_cmd = sub.add_parser('list', help='List remote files.') + _add_common(list_cmd) + list_cmd.set_defaults(func=_cmd_list) + + download_cmd = sub.add_parser('download', help='Download one file via private API endpoint.') + _add_common(download_cmd) + download_cmd.add_argument('file_id') + download_cmd.add_argument('-o', '--output') + download_cmd.set_defaults(func=_cmd_download) + + sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.') + _add_common(sync_cmd) + sync_cmd.add_argument('folder') + sync_cmd.add_argument('--note', default='synced from nanoshare cli') + sync_cmd.add_argument('--expires', default='') + sync_cmd.add_argument('--delete', action='store_true', help='Delete remote files that were deleted locally.') + sync_cmd.set_defaults(func=_cmd_sync) + + watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.') + _add_common(watch_cmd) + watch_cmd.add_argument('folder') + watch_cmd.add_argument('--note', default='synced from nanoshare cli') + watch_cmd.add_argument('--expires', default='') + watch_cmd.add_argument('--delete', action='store_true') + watch_cmd.add_argument('--interval', type=float, default=10.0) + watch_cmd.set_defaults(func=_cmd_watch) + + args = parser.parse_args(argv) + return args.func(args) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/cli/nanoshare_client/client.py b/cli/nanoshare_client/client.py new file mode 100644 index 0000000..ea0b387 --- /dev/null +++ b/cli/nanoshare_client/client.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import base64 +import mimetypes +from pathlib import Path +from urllib.parse import quote + +import httpx + +def content_type(path: Path) -> str: + return mimetypes.guess_type(path.name)[0] or 'application/octet-stream' + +def upload(client, node: str, path: Path, remote_name: str, note: str, expires: str) -> dict: + content_b64 = base64.b64encode(path.read_bytes()).decode('ascii') + params = { + 'file_name': remote_name, + 'content_b64': content_b64, + 'content_type': content_type(path), + 'note': note, + } + if expires: + params['expires'] = expires + result = client.call(node, 'files.upload', params) + return result if isinstance(result, dict) else {'result': result} + +def delete_remote(client, node: str, file_id: str) -> None: + client.call(node, 'files.delete', {'file_id': file_id}) + +def list_remote(client, node: str) -> list[dict]: + result = client.call(node, 'files.list', {}) + files = result.get('files', []) if isinstance(result, dict) else [] + return files if isinstance(files, list) else [] + +def download_url(client, node: str, file_id: str) -> str: + base = client.registry.get(node) + if not base: + raise RuntimeError(f'unknown node: {node}') + return f"{base.rstrip('/')}/api/files/{quote(file_id, safe='')}/download" + +def download(client, node: str, file_id: str, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + headers = {'Accept': '*/*', **client.auth_headers()} + with httpx.Client(timeout=client.timeout) as http: + with http.stream('GET', download_url(client, node, file_id), headers=headers) as response: + if response.status_code == 404: + raise FileNotFoundError(file_id) + if response.status_code == 410: + raise RuntimeError(f'remote file expired: {file_id}') + response.raise_for_status() + tmp = path.with_name(f'{path.name}.{Path.cwd().stat().st_ino}.tmp') + with tmp.open('wb') as handle: + for chunk in response.iter_bytes(): + handle.write(chunk) + tmp.replace(path) diff --git a/cli/nanoshare_client/config.py b/cli/nanoshare_client/config.py new file mode 100644 index 0000000..f3fb6ed --- /dev/null +++ b/cli/nanoshare_client/config.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +DEFAULT_CONFIG = Path('~/.config/nanoshare/config.toml').expanduser() + +def write_config(path: Path, *, node: str, url: str, refresh_token: str, token_url: str, source: str = 'nanoshare-cli') -> None: + path = path.expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + content = '\n'.join([ + f'source = "{source}"', + f'refresh_token = "{refresh_token}"', + f'token_url = "{token_url}"', + '', + '[nodes]', + f'{node} = "{url.rstrip("/")}"', + '', + ]) + path.write_text(content) + path.chmod(0o600) diff --git a/cli/nanoshare_client/envelope.py b/cli/nanoshare_client/envelope.py new file mode 100644 index 0000000..788d960 --- /dev/null +++ b/cli/nanoshare_client/envelope.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import time +import uuid + +PROTOCOL_VERSION = 1 +JSON_CT = 'application/json' + +def request_envelope(method: str, params: dict | None = None, *, source: str, target: str) -> dict: + return { + 'sl': PROTOCOL_VERSION, + 'id': uuid.uuid4().hex, + 'kind': 'request', + 'method': method, + 'source': source, + 'target': target, + 'params': dict(params or {}), + 'meta': {'ts': time.time()}, + } + +def unwrap_response(data: dict): + if data.get('kind') == 'response': + if data.get('ok') is True: + return data.get('result') + error = data.get('error') if isinstance(data.get('error'), dict) else {} + code = error.get('code', 'remote_error') + message = error.get('message', 'remote error') + raise RuntimeError(f'{code}: {message}') + if data.get('ok') is False and data.get('error'): + raise RuntimeError(str(data.get('error'))) + return data diff --git a/cli/nanoshare_client/http_client.py b/cli/nanoshare_client/http_client.py new file mode 100644 index 0000000..2e501fd --- /dev/null +++ b/cli/nanoshare_client/http_client.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import hashlib +import json +import os +import time +from pathlib import Path + +import httpx + +from .envelope import JSON_CT, request_envelope, unwrap_response + +def _normalize_authorization(token: str) -> str: + return token if token.lower().startswith('bearer ') else f'Bearer {token}' + +class RefreshingTokenProvider: + def __init__(self, refresh_token: str, token_url: str, *, timeout: float = 30.0): + self.refresh_token = refresh_token + self.token_url = token_url + self.timeout = timeout + self.access_token: str | None = None + self.expires_at = 0.0 + self.cache_path = self._cache_path(refresh_token, token_url) + self._load_cache() + + @staticmethod + def _cache_path(refresh_token: str, token_url: str) -> Path: + key = hashlib.sha256(f'{token_url}|{refresh_token}'.encode()).hexdigest()[:16] + return Path('/tmp/.nanoshare-cli') / f'token-{key}.json' + + def __call__(self) -> str: + if self.access_token and time.time() < self.expires_at - 60: + return self.access_token + headers = {'Authorization': _normalize_authorization(self.refresh_token), 'Accept': 'application/json'} + response = httpx.post(self.token_url, headers=headers, timeout=self.timeout) + response.raise_for_status() + data = response.json() + self.access_token = data['access_token'] + self.expires_at = float(data.get('expires_at') or (time.time() + int(data.get('expires_in', 3600)))) + self._store_cache() + return self.access_token + + def _load_cache(self) -> None: + if not self.cache_path.is_file(): + return + try: + data = json.loads(self.cache_path.read_text()) + except (OSError, ValueError): + return + if isinstance(data.get('access_token'), str): + self.access_token = data['access_token'] + self.expires_at = float(data.get('expires_at') or 0) + + def _store_cache(self) -> None: + try: + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(self.cache_path.parent, 0o700) + tmp = self.cache_path.with_name(f'{self.cache_path.name}.{os.getpid()}.tmp') + tmp.write_text(json.dumps({'access_token': self.access_token, 'expires_at': self.expires_at})) + os.chmod(tmp, 0o600) + os.replace(tmp, self.cache_path) + except OSError: + pass + +class NanoShareClient: + def __init__(self, *, source: str, registry: dict[str, str], token: str | None = None, refresh_token: str | None = None, token_url: str | None = None, timeout: float = 30.0): + self.source = source + self.registry = {name: url.rstrip('/') for name, url in registry.items()} + self.token = token + self.timeout = timeout + self.token_provider = RefreshingTokenProvider(refresh_token, token_url, timeout=timeout) if refresh_token and token_url else None + self._client = httpx.Client(timeout=timeout) + + def close(self) -> None: + self._client.close() + + def auth_headers(self) -> dict[str, str]: + token = self.token or (self.token_provider() if self.token_provider else None) + return {'Authorization': _normalize_authorization(token)} if token else {} + + def url(self, node: str, path: str = '/rpc') -> str: + if node not in self.registry: + raise RuntimeError(f'unknown node: {node}') + return self.registry[node].rstrip('/') + path + + def call(self, node: str, method: str, params: dict | None = None): + envelope = request_envelope(method, params, source=self.source, target=node) + response = self._client.post( + self.url(node, '/rpc'), + json=envelope, + headers={'Accept': JSON_CT, 'Content-Type': JSON_CT, **self.auth_headers()}, + ) + response.raise_for_status() + return unwrap_response(response.json()) diff --git a/cli/nanoshare_client/sync.py b/cli/nanoshare_client/sync.py new file mode 100644 index 0000000..396b069 --- /dev/null +++ b/cli/nanoshare_client/sync.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sys +import time +from pathlib import Path + +from .client import delete_remote, download, list_remote, upload + +SYNC_DIR_NAME = '.nanoshare-sync' +STATE_FILE_NAME = 'state.json' + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + +def state_path(root: Path) -> Path: + return root / SYNC_DIR_NAME / STATE_FILE_NAME + +def load_state(root: Path) -> dict: + path = state_path(root) + if not path.is_file(): + return {'version': 1, 'files': {}} + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return {'version': 1, 'files': {}} + if not isinstance(data, dict): + return {'version': 1, 'files': {}} + data.setdefault('version', 1) + data.setdefault('files', {}) + return data + +def save_state(root: Path, state: dict) -> None: + path = state_path(root) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f'{path.name}.{os.getpid()}.tmp') + tmp.write_text(json.dumps(state, indent=2, sort_keys=True)) + os.replace(tmp, path) + +def iter_local_files(root: Path): + sync_dir = root / SYNC_DIR_NAME + for path in sorted(root.rglob('*')): + if not path.is_file(): + continue + if path == state_path(root) or sync_dir in path.parents: + continue + yield path + +def relative(root: Path, path: Path) -> str: + return path.relative_to(root).as_posix() + +def safe_remote_name(name: str, file_id: str) -> str: + candidate = Path(name or file_id).name + return candidate or file_id + +def unique_path(path: Path) -> Path: + if not path.exists(): + return path + stamp = time.strftime('%Y%m%d-%H%M%S') + suffix = ''.join(path.suffixes) + stem = path.name[:-len(suffix)] if suffix else path.name + candidate = path.with_name(f'{stem}.conflict-{stamp}{suffix}') + counter = 2 + while candidate.exists(): + candidate = path.with_name(f'{stem}.conflict-{stamp}-{counter}{suffix}') + counter += 1 + return candidate + +def remote_by_id(remote_files: list[dict]) -> dict[str, dict]: + return {item['file_id']: item for item in remote_files if isinstance(item, dict) and item.get('file_id')} + +def remote_by_name(remote_files: list[dict]) -> dict[str, dict]: + result = {} + for item in remote_files: + if isinstance(item, dict) and item.get('file_name') and item.get('file_id'): + result.setdefault(item['file_name'], item) + return result + +def sync_once(args, client) -> int: + root = Path(args.folder).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + state = load_state(root) + known = state.setdefault('files', {}) + try: + remote_files = list_remote(client, args.node) + remote_ids = remote_by_id(remote_files) + remote_names = remote_by_name(remote_files) + local_paths = {relative(root, path): path for path in iter_local_files(root)} + + for rel, path in local_paths.items(): + current_hash = sha256(path) + entry = known.get(rel) + remote_id = entry.get('file_id') if isinstance(entry, dict) else None + old_hash = entry.get('sha256') if isinstance(entry, dict) else None + + if old_hash == current_hash and remote_id in remote_ids: + continue + + if remote_id and remote_id not in remote_ids and old_hash != current_hash: + conflict_path = unique_path(path) + shutil.copy2(path, conflict_path) + print(f'conflict: kept changed local copy at {conflict_path}') + + print(f'upload: {rel}') + result = upload(client, args.node, path, rel, args.note or '', args.expires or '') + new_file_id = result.get('file_id') or result.get('id') + if not new_file_id: + matching = remote_names.get(rel) + refreshed = list_remote(client, args.node) + new_file_id = (remote_by_name(refreshed).get(rel) or matching or {}).get('file_id') + remote_files = refreshed + remote_ids = remote_by_id(remote_files) + remote_names = remote_by_name(remote_files) + if not new_file_id: + print(f'warning: upload succeeded but file_id is unknown for {rel}', file=sys.stderr) + continue + if remote_id and remote_id in remote_ids and remote_id != new_file_id: + try: + delete_remote(client, args.node, remote_id) + print(f'delete old remote: {rel}') + remote_files = [item for item in remote_files if item.get('file_id') != remote_id] + remote_ids = remote_by_id(remote_files) + remote_names = remote_by_name(remote_files) + except Exception as exc: + print(f'warning: could not delete old remote {remote_id}: {exc}', file=sys.stderr) + known[rel] = { + 'file_id': new_file_id, + 'sha256': current_hash, + 'local_mtime': path.stat().st_mtime, + 'file_name': rel, + } + + known_ids = {entry.get('file_id') for entry in known.values() if isinstance(entry, dict)} + for remote in remote_files: + file_id = remote.get('file_id') + if not file_id or file_id in known_ids: + continue + name = safe_remote_name(remote.get('file_name') or '', file_id) + path = unique_path(root / name) + print(f'download: {file_id} -> {relative(root, path)}') + download(client, args.node, file_id, path) + known[relative(root, path)] = { + 'file_id': file_id, + 'sha256': sha256(path), + 'local_mtime': path.stat().st_mtime, + 'file_name': relative(root, path), + } + + if args.delete: + for rel, entry in list(known.items()): + if rel in local_paths: + continue + file_id = entry.get('file_id') if isinstance(entry, dict) else None + if file_id and file_id in remote_ids: + print(f'delete remote missing locally: {rel}') + delete_remote(client, args.node, file_id) + known.pop(rel, None) + + save_state(root, state) + except Exception as exc: + print(f'error: {exc}', file=sys.stderr) + return 1 + return 0 diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..81c270a --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "nanoshare-cli" +version = "0.1.0" +description = "NanoShare desktop CLI and folder sync client" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "httpx==0.28.1", +] + +[project.scripts] +nanoshare = "nanoshare_client.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/quart_common b/quart_common index 5b3cf7b..d61514d 160000 --- a/quart_common +++ b/quart_common @@ -1 +1 @@ -Subproject commit 5b3cf7b59a304319110e3b8622b2dfc844f4c802 +Subproject commit d61514d755c00f920d9b1a552d05a10ae2ff55cd diff --git a/routes/__init__.py b/routes/__init__.py index 3fbe54d..4988857 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -19,5 +19,8 @@ from .side.main import side_main_bp from .side.upload import upload_bp +from .api.cli_auth import cli_auth_bp +from .api.download import api_download_bp + # Health from .api.health import health_bp diff --git a/routes/api/cli_auth.py b/routes/api/cli_auth.py new file mode 100644 index 0000000..f7b629d --- /dev/null +++ b/routes/api/cli_auth.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import secrets +from urllib.parse import urlencode + +from my_modules.app.setup import LIMITER, cache +from quart import Blueprint, jsonify, redirect, request, session, url_for +from quart_common.web.auth import get_auth_token +from quart_common.web.wide_event import add_wide_event_context +from quart import current_app + +cli_auth_bp = Blueprint('cli_auth', __name__) + +CLI_CODE_TTL_SECONDS = 300 + +@cli_auth_bp.get('/auth/cli/login') +@LIMITER.limit('10 per minute;60 per hour;') +async def cli_login(): + redirect_uri = request.args.get('redirect_uri', '').strip() + state = request.args.get('state', '').strip() + scope = request.args.get('scope', 'files').strip() or 'files' + add_wide_event_context(auth={'method': 'cli_browser', 'operation': 'cli_login_start'}) + + if not redirect_uri or not state: + return jsonify({'ok': False, 'error': 'redirect_uri and state are required'}), 400 + + user = session.get('user') + if not user: + session['cli_login_request'] = {'redirect_uri': redirect_uri, 'state': state, 'scope': scope} + session['post_login_redirect'] = url_for('cli_auth.cli_login_complete') + return redirect(url_for('auth_login.login')) + + return await _redirect_with_cli_code(user['sub'], redirect_uri, state, scope) + +@cli_auth_bp.get('/auth/cli/complete') +@LIMITER.limit('10 per minute;60 per hour;') +async def cli_login_complete(): + user = session.get('user') + login_request = session.pop('cli_login_request', None) + if not user or not login_request: + return jsonify({'ok': False, 'error': 'no pending CLI login'}), 400 + + return await _redirect_with_cli_code( + user['sub'], + login_request['redirect_uri'], + login_request['state'], + login_request.get('scope') or 'files', + ) + +async def _redirect_with_cli_code(user_id: str, redirect_uri: str, state: str, scope: str): + code = secrets.token_urlsafe(32) + await cache.set( + f'cli:login:{code}', + {'user_id': user_id, 'scope': scope}, + ttl=CLI_CODE_TTL_SECONDS, + ) + separator = '&' if '?' in redirect_uri else '?' + query = urlencode({'code': code, 'state': state}) + return redirect(f'{redirect_uri}{separator}{query}') + +@cli_auth_bp.post('/api/cli/token') +@LIMITER.limit('20 per minute;200 per hour;') +async def cli_token_exchange(): + payload = await request.get_json(silent=True) or {} + code = str(payload.get('code', '')).strip() + if not code: + return jsonify({'ok': False, 'error': 'code is required'}), 400 + + login_data = await cache.get(f'cli:login:{code}') + if not login_data: + return jsonify({'ok': False, 'error': 'invalid or expired code'}), 400 + await cache.delete(f'cli:login:{code}') + + user_id = login_data['user_id'] + scope = login_data.get('scope') or 'files' + token_data = await current_app.convex.add_refresh_token( + token_name='nanoshare-cli', + user_id=user_id, + scope=scope, + enabled=True, + ) + refresh_token = token_data.get('refresh_token') if isinstance(token_data, dict) else None + if not refresh_token: + return jsonify({'ok': False, 'error': 'refresh token creation failed'}), 500 + return jsonify({'ok': True, 'refresh_token': refresh_token, 'scope': scope}) + +@cli_auth_bp.post('/token/refresh') +@LIMITER.limit('60 per minute;1000 per hour;') +async def token_refresh(): + refresh_token = await get_auth_token() + if not refresh_token: + response = jsonify({'ok': False, 'error': 'refresh token is missing'}) + response.headers['WWW-Authenticate'] = 'Bearer' + return response, 401 + + token, refresh_id, user = await current_app.convex.generate_new_access_token(refresh_token) + if not isinstance(token, dict) or not token.get('access_token'): + return jsonify(token), 401 + return jsonify(token) diff --git a/routes/api/download.py b/routes/api/download.py new file mode 100644 index 0000000..7119284 --- /dev/null +++ b/routes/api/download.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from my_modules.app.setup import LIMITER +from my_modules.decoratory.header import token_required +from quart_common.web.wide_event import add_wide_event_context + +from quart import Blueprint, abort, current_app, send_file + +api_download_bp = Blueprint('api_download', __name__) + +def _last_modified_from_file(file_data: dict): + uploaded_at = file_data.get('uploaded_at') + if uploaded_at is None: + return None + try: + return int(uploaded_at) / 1000 + except (TypeError, ValueError): + return None + +@api_download_bp.get('/api/files//download') +@LIMITER.limit('60 per minute;1000 per hour;') +@token_required(['files', 'mesh']) +async def api_file_download(file_id: str, user: dict): + add_wide_event_context(nanoshare={'operation': 'api_file_download', 'file_id': file_id}) + + file_data = await current_app.convex.get_file_informations(file_id=file_id, user_id=user['sub']) + if not file_data: + add_wide_event_context(nanoshare={'operation_status': 'not_found'}) + abort(404) + + if file_data.get('expired'): + add_wide_event_context(nanoshare={'operation_status': 'expired'}) + abort(410) + + storage_id = file_data.get('db_image_url') + if not storage_id: + add_wide_event_context(nanoshare={'operation_status': 'missing_storage'}) + abort(404) + + content_type = file_data.get('content_type') or 'application/octet-stream' + file_name = file_data.get('file_name') or file_id + add_wide_event_context(nanoshare={'operation_status': 'served', 'content_type': content_type}) + + response = await send_file( + filename_or_io=await current_app.convex.get_from_storage(storage_id), + mimetype=content_type, + as_attachment=True, + attachment_filename=file_name, + conditional=True, + cache_timeout=0, + last_modified=_last_modified_from_file(file_data), + ) + response.headers['Cache-Control'] = 'private, no-store' + return response diff --git a/routes/api/link.py b/routes/api/link.py index 44d733c..e24f81b 100644 --- a/routes/api/link.py +++ b/routes/api/link.py @@ -47,7 +47,7 @@ async def files_upload(params, ctx): file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext) storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type) - await current_app.convex.add_file( + file_record = await current_app.convex.add_file( file_name=file_name, file_size=format_size(len(data)), note=params.get('note', ''), @@ -56,7 +56,8 @@ async def files_upload(params, ctx): storage_id=storage_id, user_id=user_id, ) - return {'file_name': file_name, 'size': len(data), 'content_type': content_type} + file_id = file_record.get('file_id') if isinstance(file_record, dict) else None + return {'file_id': file_id, 'file_name': file_name, 'size': len(data), 'content_type': content_type} @router.method('files.list') async def files_list(params, ctx): diff --git a/run.py b/run.py index d62a0de..181b996 100755 --- a/run.py +++ b/run.py @@ -11,6 +11,8 @@ from routes import ( basic_bp, auth_login_bp, side_main_bp, upload_bp, + cli_auth_bp, + api_download_bp, health_bp ) from routes.api.link import link_bp as servicelink_bp @@ -21,6 +23,8 @@ app.register_blueprint(auth_login_bp) app.register_blueprint(side_main_bp) app.register_blueprint(upload_bp) +app.register_blueprint(cli_auth_bp) +app.register_blueprint(api_download_bp) # ServiceLink node-to-node mesh endpoint (POST /rpc) app.register_blueprint(servicelink_bp) diff --git a/tests/test_api_file_download.py b/tests/test_api_file_download.py new file mode 100644 index 0000000..1541788 --- /dev/null +++ b/tests/test_api_file_download.py @@ -0,0 +1,127 @@ +import asyncio +import importlib.util +import sys +import types +from io import BytesIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from quart import Quart, g + +class _NoOpLimiter: + def limit(self, *args, **kwargs): + return lambda fn: fn + +async def _fake_token_required(required_scope=None): + def decorator(func): + async def wrapper(*args, **kwargs): + return await func(user={'sub': 'user_123', 'scope': 'files'}, *args, **kwargs) + wrapper.__name__ = func.__name__ + return wrapper + return decorator + +def _load_download_module(): + setup_stub = types.ModuleType('my_modules.app.setup') + setup_stub.LIMITER = _NoOpLimiter() + + header_stub = types.ModuleType('my_modules.decoratory.header') + + def token_required(required_scope=None): + def decorator(func): + async def wrapper(*args, **kwargs): + return await func(user={'sub': 'user_123', 'scope': 'files'}, *args, **kwargs) + wrapper.__name__ = func.__name__ + return wrapper + return decorator + + header_stub.token_required = token_required + + sys.modules['my_modules.app.setup'] = setup_stub + sys.modules['my_modules.decoratory.header'] = header_stub + + module_path = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'download.py' + spec = importlib.util.spec_from_file_location('api_download_under_test', module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +class FakeConvex: + def __init__(self, file_data=None): + self.file_data = file_data + self.info_calls = [] + self.storage_calls = [] + + async def get_file_informations(self, file_id, user_id): + self.info_calls.append((file_id, user_id)) + return self.file_data + + async def get_from_storage(self, storage_id): + self.storage_calls.append(storage_id) + return BytesIO(b'private bytes') + +def _client(file_data): + module = _load_download_module() + app = Quart(__name__) + app.secret_key = 'test-secret' + app.convex = FakeConvex(file_data) + app.register_blueprint(module.api_download_bp) + + @app.before_request + async def wide_event_context(): + g.wide_event = {} + + return app, app.test_client() + +def test_private_download_serves_owner_file(): + async def run_test(): + app, client = _client({ + 'file_id': 'file_123', + 'file_name': 'report.txt', + 'content_type': 'text/plain', + 'db_image_url': 'storage_123', + 'uploaded_at': 1700000000000, + 'user_id': 'user_123', + }) + + response = await client.get('/api/files/file_123/download') + + assert response.status_code == 200 + assert await response.get_data() == b'private bytes' + assert response.headers['Content-Type'].startswith('text/plain') + assert 'report.txt' in response.headers['Content-Disposition'] + assert response.headers['Cache-Control'] == 'private, no-store' + assert app.convex.info_calls == [('file_123', 'user_123')] + assert app.convex.storage_calls == ['storage_123'] + + asyncio.run(run_test()) + +def test_private_download_returns_404_when_file_is_not_owned(): + async def run_test(): + app, client = _client(None) + + response = await client.get('/api/files/missing/download') + + assert response.status_code == 404 + assert app.convex.info_calls == [('missing', 'user_123')] + assert app.convex.storage_calls == [] + + asyncio.run(run_test()) + +def test_private_download_returns_410_for_expired_file(): + async def run_test(): + app, client = _client({ + 'file_id': 'file_123', + 'file_name': 'report.txt', + 'content_type': 'text/plain', + 'db_image_url': 'storage_123', + 'uploaded_at': 1700000000000, + 'expired': True, + }) + + response = await client.get('/api/files/file_123/download') + + assert response.status_code == 410 + assert app.convex.storage_calls == [] + + asyncio.run(run_test()) diff --git a/tests/test_nanoshare_cli.py b/tests/test_nanoshare_cli.py new file mode 100644 index 0000000..9ab2ba6 --- /dev/null +++ b/tests/test_nanoshare_cli.py @@ -0,0 +1,86 @@ +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli')) + +from nanoshare_client import cli + +class FakeClient: + def __init__(self): + self.registry = {'picoshare': 'https://example.com'} + self.uploads = [] + self.deleted = [] + self.remote_files = [] + + def call(self, node, method, params): + if method == 'files.list': + return {'files': list(self.remote_files)} + if method == 'files.upload': + file_id = f'file_{len(self.uploads) + 1}' + self.uploads.append((node, params, file_id)) + self.remote_files.append({ + 'file_id': file_id, + 'file_name': params['file_name'], + 'file_size': '1 B', + }) + return {'file_id': file_id, 'file_name': params['file_name']} + if method == 'files.delete': + self.deleted.append(params['file_id']) + self.remote_files = [item for item in self.remote_files if item['file_id'] != params['file_id']] + return {'deleted': True} + raise AssertionError(method) + + def close(self): + pass + +def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch): + fake = FakeClient() + monkeypatch.setattr(cli, 'make_client', lambda args: fake) + + (tmp_path / 'hello.txt').write_text('hello') + + code = cli.main([ + 'sync', + '--url', 'picoshare=https://example.com', + '--token', 'token', + str(tmp_path), + ]) + + assert code == 0 + assert fake.uploads[0][1]['file_name'] == 'hello.txt' + state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text()) + assert state['files']['hello.txt']['file_id'] == 'file_1' + +def test_sync_deletes_old_remote_when_local_file_changes(tmp_path, monkeypatch): + fake = FakeClient() + fake.remote_files = [{'file_id': 'old_file', 'file_name': 'hello.txt', 'file_size': '5 B'}] + monkeypatch.setattr(cli, 'make_client', lambda args: fake) + + path = tmp_path / 'hello.txt' + path.write_text('changed') + state_dir = tmp_path / '.nanoshare-sync' + state_dir.mkdir() + (state_dir / 'state.json').write_text(json.dumps({ + 'version': 1, + 'files': { + 'hello.txt': { + 'file_id': 'old_file', + 'sha256': 'old_hash', + 'local_mtime': 1, + 'file_name': 'hello.txt', + } + } + })) + + code = cli.main([ + 'sync', + '--url', 'picoshare=https://example.com', + '--token', 'token', + str(tmp_path), + ]) + + assert code == 0 + assert fake.deleted == ['old_file'] + state = json.loads((state_dir / 'state.json').read_text()) + assert state['files']['hello.txt']['file_id'] == 'file_1'