42064de633
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.
95 lines
3.6 KiB
Python
95 lines
3.6 KiB
Python
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())
|