feat(cli): add private NanoShare sync client
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
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:
@@ -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
|
||||
Reference in New Issue
Block a user