feat(cli): add private NanoShare sync client
Build and Push Docker Container / build-and-push (push) Successful in 1m3s

- 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.
This commit is contained in:
2026-07-27 15:52:34 +02:00
parent 4ee4d6a0a4
commit 42064de633
21 changed files with 1118 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
"""NanoShare CLI client helpers."""
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
import sys
from .cli import main
if __name__ == '__main__':
sys.exit(main())
+68
View File
@@ -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),
)
+101
View File
@@ -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
+143
View File
@@ -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())
+54
View File
@@ -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)
+20
View File
@@ -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)
+31
View File
@@ -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
+94
View File
@@ -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())
+170
View File
@@ -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