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,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())
|
||||
Reference in New Issue
Block a user