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,15 @@
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
testing/.env
|
||||
access.log
|
||||
valkey_data
|
||||
redisinsight
|
||||
uploads
|
||||
cli
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
"""NanoShare CLI client helpers."""
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+1
-1
Submodule quart_common updated: 5b3cf7b59a...d61514d755
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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/<path:file_id>/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
|
||||
+3
-2
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user