Compare commits
12 Commits
4ee4d6a0a4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
08d0d154c9
|
|||
|
d3c914285c
|
|||
|
ffdcc979e6
|
|||
|
472ed11e9f
|
|||
|
2b0215cd94
|
|||
|
993337be9f
|
|||
|
77c76b16c4
|
|||
|
9db36c2d5b
|
|||
|
6e4e5b837b
|
|||
|
d71f0838d6
|
|||
|
4f223e529d
|
|||
|
42064de633
|
@@ -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,112 @@
|
|||||||
|
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
|
||||||
|
self.done = threading.Event()
|
||||||
|
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')
|
||||||
|
outer.done.set()
|
||||||
|
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.')
|
||||||
|
outer.done.set()
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.started:
|
||||||
|
self.thread.start()
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def wait_for_code(self, timeout: float) -> str:
|
||||||
|
self.start()
|
||||||
|
received_callback = self.done.wait(timeout)
|
||||||
|
self.httpd.shutdown()
|
||||||
|
self.thread.join(5)
|
||||||
|
if self.error:
|
||||||
|
raise RuntimeError(self.error)
|
||||||
|
if not received_callback or 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)
|
||||||
|
server.start()
|
||||||
|
authorize_url = _make_authorize_url(args.base_url, server.url, state, args.scope)
|
||||||
|
|
||||||
|
print(f'Opening browser for NanoShare login: {authorize_url}', flush=True)
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(authorize_url)
|
||||||
|
else:
|
||||||
|
print(authorize_url, flush=True)
|
||||||
|
|
||||||
|
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,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .app import make_client
|
||||||
|
from .auth import login as auth_login
|
||||||
|
from .client import download, list_remote, upload
|
||||||
|
from .completion import script as completion_script
|
||||||
|
from .config import DEFAULT_CONFIG
|
||||||
|
from .remote_path import join_remote_path
|
||||||
|
from .sync import sync_once
|
||||||
|
from .table import format_table
|
||||||
|
from .watch import watch_loop
|
||||||
|
|
||||||
|
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()
|
||||||
|
if args.format == 'json':
|
||||||
|
print(json.dumps(files, indent=2, ensure_ascii=False))
|
||||||
|
elif args.format == 'tsv':
|
||||||
|
for item in files:
|
||||||
|
print(f"{item.get('file_id', '')}\t{item.get('file_path', '')}\t{item.get('file_name', '')}\t{item.get('file_size', '')}")
|
||||||
|
else:
|
||||||
|
rows = [
|
||||||
|
[item.get('file_id'), item.get('file_path', ''), item.get('file_name'), item.get('file_size', '')]
|
||||||
|
for item in files
|
||||||
|
]
|
||||||
|
print(format_table(['ID', 'Path', 'Name', 'Size'], rows))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _resolve_remote_file(files: list[dict], selector: str) -> dict:
|
||||||
|
id_matches = [item for item in files if item.get('file_id') == selector]
|
||||||
|
if id_matches:
|
||||||
|
return id_matches[0]
|
||||||
|
full_path_matches = [item for item in files if join_remote_path(str(item.get('file_name') or ''), str(item.get('file_path') or ''), str(item.get('file_id') or '')) == selector]
|
||||||
|
if full_path_matches:
|
||||||
|
if len(full_path_matches) > 1:
|
||||||
|
raise RuntimeError(f'multiple remote files at {selector!r}; use the file ID')
|
||||||
|
return full_path_matches[0]
|
||||||
|
name_matches = [item for item in files if item.get('file_name') == selector]
|
||||||
|
if not name_matches:
|
||||||
|
raise FileNotFoundError(f'no remote file matches: {selector}')
|
||||||
|
if len(name_matches) > 1:
|
||||||
|
raise RuntimeError(f'multiple remote files named {selector!r}; use the file ID or full path')
|
||||||
|
return name_matches[0]
|
||||||
|
|
||||||
|
def _cmd_download(args) -> int:
|
||||||
|
client = make_client(args)
|
||||||
|
try:
|
||||||
|
remote = _resolve_remote_file(list_remote(client, args.node), args.file)
|
||||||
|
file_id = str(remote['file_id'])
|
||||||
|
file_name = str(remote.get('file_name') or file_id)
|
||||||
|
remote_name = join_remote_path(file_name, str(remote.get('file_path') or ''), file_id)
|
||||||
|
dest = Path(args.output).expanduser() if args.output else Path(file_name)
|
||||||
|
download(client, args.node, file_id, dest)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'error: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
print(f'downloaded {remote_name} ({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:
|
||||||
|
return watch_loop(args, _cmd_sync)
|
||||||
|
|
||||||
|
def _cmd_completion(args) -> int:
|
||||||
|
try:
|
||||||
|
print(completion_script(args.shell))
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f'error: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
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.add_argument('--format', choices=['table', 'json', 'tsv'], default='table')
|
||||||
|
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', help='Remote file ID or exact file name.')
|
||||||
|
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='')
|
||||||
|
sync_cmd.add_argument('--expires', default='')
|
||||||
|
sync_cmd.add_argument('--delete', action='store_true', help='Delete remote files that were deleted locally.')
|
||||||
|
sync_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||||
|
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='')
|
||||||
|
watch_cmd.add_argument('--expires', default='')
|
||||||
|
watch_cmd.add_argument('--delete', action='store_true')
|
||||||
|
watch_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
|
||||||
|
watch_cmd.add_argument('--interval', type=float, default=10.0)
|
||||||
|
watch_cmd.set_defaults(func=_cmd_watch)
|
||||||
|
|
||||||
|
completion_cmd = sub.add_parser('completion', help='Print shell completion script.')
|
||||||
|
completion_cmd.add_argument('shell', choices=['zsh', 'bash'])
|
||||||
|
completion_cmd.set_defaults(func=_cmd_completion)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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, remote_path: 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 remote_path:
|
||||||
|
params['file_path'] = remote_path
|
||||||
|
if expires:
|
||||||
|
params['expires'] = expires
|
||||||
|
result = client.call(node, 'files.upload', params)
|
||||||
|
return result if isinstance(result, dict) else {'result': result}
|
||||||
|
|
||||||
|
def update_remote(client, node: str, file_id: str, file_name: str, file_path: str, note: str | None, expires: str) -> None:
|
||||||
|
params = {
|
||||||
|
'file_id': file_id,
|
||||||
|
'file_name': file_name,
|
||||||
|
'file_path': file_path,
|
||||||
|
}
|
||||||
|
if note is not None:
|
||||||
|
params['note'] = note
|
||||||
|
if expires:
|
||||||
|
params['expires'] = expires
|
||||||
|
client.call(node, 'files.update', params)
|
||||||
|
|
||||||
|
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 node_url(client, node: str, path: str) -> str:
|
||||||
|
base = client.registry.get(node)
|
||||||
|
if not base:
|
||||||
|
raise RuntimeError(f'unknown node: {node}')
|
||||||
|
return f"{base.rstrip('/')}{path}"
|
||||||
|
|
||||||
|
def download_url(client, node: str, file_id: str) -> str:
|
||||||
|
return node_url(client, node, f"/api/files/{quote(file_id, safe='')}/download")
|
||||||
|
|
||||||
|
def events_url(client, node: str) -> str:
|
||||||
|
return node_url(client, node, '/api/files/events')
|
||||||
|
|
||||||
|
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,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
_ZSH = r'''# nanoshare zsh completion -- add to ~/.zshrc: eval "$(nanoshare completion zsh)"
|
||||||
|
_nanoshare_files() {
|
||||||
|
local id path name size label
|
||||||
|
while IFS=$'\t' read -r id path name size; do
|
||||||
|
label="${path:+$path/}$name"
|
||||||
|
[[ -n $id ]] && printf '%s:%s\n' "$id" "$label"
|
||||||
|
done < <(nanoshare list --format tsv 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
_nanoshare() {
|
||||||
|
local -a commands common_opts login_opts sync_opts watch_opts download_opts
|
||||||
|
commands=(login upload list download sync watch completion)
|
||||||
|
common_opts=(--config --url --token --refresh-token --token-url --source --timeout --node)
|
||||||
|
login_opts=(--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser)
|
||||||
|
sync_opts=($common_opts --note --expires --delete --ignore)
|
||||||
|
watch_opts=($sync_opts --interval)
|
||||||
|
download_opts=($common_opts --output -o)
|
||||||
|
|
||||||
|
if (( CURRENT == 2 )); then
|
||||||
|
compadd -- $commands
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case ${words[2]} in
|
||||||
|
login)
|
||||||
|
compadd -- $login_opts
|
||||||
|
;;
|
||||||
|
upload)
|
||||||
|
compadd -- $common_opts --name --note --expires
|
||||||
|
_files
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
compadd -- $common_opts --format
|
||||||
|
;;
|
||||||
|
download)
|
||||||
|
if (( CURRENT == 3 )); then
|
||||||
|
local -a remote_files
|
||||||
|
remote_files=("${(@f)$(_nanoshare_files)}")
|
||||||
|
_describe 'remote file' remote_files
|
||||||
|
else
|
||||||
|
compadd -- $download_opts
|
||||||
|
_files
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
sync|watch)
|
||||||
|
compadd -- ${(P)${:-${words[2]}_opts}}
|
||||||
|
_files -/
|
||||||
|
;;
|
||||||
|
completion)
|
||||||
|
(( CURRENT == 3 )) && compadd -- zsh bash
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
compdef _nanoshare nanoshare
|
||||||
|
'''
|
||||||
|
|
||||||
|
_BASH = r'''# nanoshare bash completion -- add to ~/.bashrc: eval "$(nanoshare completion bash)"
|
||||||
|
_nanoshare_files() {
|
||||||
|
nanoshare list --format tsv 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_nanoshare() {
|
||||||
|
local cur="${COMP_WORDS[COMP_CWORD]}" cmd="${COMP_WORDS[1]}"
|
||||||
|
local commands="login upload list download sync watch completion"
|
||||||
|
local common_opts="--config --url --token --refresh-token --token-url --source --timeout --node"
|
||||||
|
local login_opts="--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser"
|
||||||
|
local sync_opts="$common_opts --note --expires --delete --ignore"
|
||||||
|
local watch_opts="$sync_opts --interval"
|
||||||
|
local download_opts="$common_opts --output -o"
|
||||||
|
|
||||||
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
||||||
|
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
login)
|
||||||
|
COMPREPLY=( $(compgen -W "$login_opts" -- "$cur") ) ;;
|
||||||
|
upload)
|
||||||
|
COMPREPLY=( $(compgen -W "$common_opts --name --note --expires" -- "$cur") ) ;;
|
||||||
|
list)
|
||||||
|
COMPREPLY=( $(compgen -W "$common_opts --format" -- "$cur") ) ;;
|
||||||
|
download)
|
||||||
|
if [ "$COMP_CWORD" -eq 2 ]; then
|
||||||
|
COMPREPLY=( $(compgen -W "$(_nanoshare_files | cut -f1)" -- "$cur") )
|
||||||
|
else
|
||||||
|
COMPREPLY=( $(compgen -W "$download_opts" -- "$cur") )
|
||||||
|
fi ;;
|
||||||
|
sync)
|
||||||
|
COMPREPLY=( $(compgen -W "$sync_opts" -- "$cur") ) ;;
|
||||||
|
watch)
|
||||||
|
COMPREPLY=( $(compgen -W "$watch_opts" -- "$cur") ) ;;
|
||||||
|
completion)
|
||||||
|
[ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "zsh bash" -- "$cur") ) ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
complete -F _nanoshare nanoshare
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def script(shell: str) -> str:
|
||||||
|
if shell == 'zsh':
|
||||||
|
return _ZSH
|
||||||
|
if shell == 'bash':
|
||||||
|
return _BASH
|
||||||
|
raise ValueError(f'unsupported shell: {shell}')
|
||||||
@@ -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,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IGNORE_FILE_NAME = '.nanoshareignore'
|
||||||
|
DEFAULT_IGNORE_PATTERNS = [
|
||||||
|
IGNORE_FILE_NAME,
|
||||||
|
'.comments/',
|
||||||
|
'.env',
|
||||||
|
'.env.*',
|
||||||
|
'.venv/',
|
||||||
|
'venv/',
|
||||||
|
'__pycache__/',
|
||||||
|
'.pytest_cache/',
|
||||||
|
'.mypy_cache/',
|
||||||
|
'.ruff_cache/',
|
||||||
|
'.git/',
|
||||||
|
'.hg/',
|
||||||
|
'.svn/',
|
||||||
|
'.DS_Store',
|
||||||
|
'Thumbs.db',
|
||||||
|
'desktop.ini',
|
||||||
|
'$RECYCLE.BIN/',
|
||||||
|
'System Volume Information/',
|
||||||
|
]
|
||||||
|
|
||||||
|
def load_ignore_patterns(root: Path, extra_patterns: list[str] | None = None) -> list[str]:
|
||||||
|
patterns = list(DEFAULT_IGNORE_PATTERNS)
|
||||||
|
ignore_file = root / IGNORE_FILE_NAME
|
||||||
|
if ignore_file.is_file():
|
||||||
|
for line in ignore_file.read_text().splitlines():
|
||||||
|
pattern = line.strip()
|
||||||
|
if pattern and not pattern.startswith('#'):
|
||||||
|
patterns.append(pattern)
|
||||||
|
patterns.extend(pattern for pattern in extra_patterns or [] if pattern)
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
def is_ignored(rel_path: str, patterns: list[str]) -> bool:
|
||||||
|
path = rel_path.strip('/')
|
||||||
|
parts = path.split('/')
|
||||||
|
for pattern in patterns:
|
||||||
|
normalized = pattern.strip().replace('\\', '/').strip('/')
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
if pattern.endswith('/'):
|
||||||
|
if path == normalized or path.startswith(f'{normalized}/'):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
if '/' in normalized:
|
||||||
|
if fnmatch(path, normalized):
|
||||||
|
return True
|
||||||
|
elif any(fnmatch(part, normalized) for part in parts):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
def split_remote_path(value: str, file_id: str = '') -> tuple[str, str]:
|
||||||
|
raw = (value or file_id).replace('\\', '/')
|
||||||
|
parts = [
|
||||||
|
part for part in PurePosixPath(raw).parts
|
||||||
|
if part not in ('', '.', '..', '/')
|
||||||
|
]
|
||||||
|
if not parts:
|
||||||
|
return file_id, ''
|
||||||
|
return parts[-1], '/'.join(parts[:-1])
|
||||||
|
|
||||||
|
def join_remote_path(file_name: str, file_path: str | None = None, file_id: str = '') -> str:
|
||||||
|
name, embedded_path = split_remote_path(file_name, file_id)
|
||||||
|
clean_path = split_remote_path(f'{file_path or ""}/placeholder')[1] if file_path else embedded_path
|
||||||
|
return f'{clean_path}/{name}' if clean_path else name
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .client import delete_remote, download, list_remote, update_remote, upload
|
||||||
|
from .ignore import is_ignored, load_ignore_patterns
|
||||||
|
from .remote_path import join_remote_path, split_remote_path
|
||||||
|
|
||||||
|
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, ignore_patterns: list[str] | None = None):
|
||||||
|
sync_dir = root / SYNC_DIR_NAME
|
||||||
|
patterns = ignore_patterns or []
|
||||||
|
for path in sorted(root.rglob('*')):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path == state_path(root) or sync_dir in path.parents:
|
||||||
|
continue
|
||||||
|
if is_ignored(relative(root, path), patterns):
|
||||||
|
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:
|
||||||
|
return join_remote_path(name, file_id=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(join_remote_path(item['file_name'], item.get('file_path') or ''), item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def parse_size(value: object) -> int | None:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value or '').strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
parts = text.split()
|
||||||
|
try:
|
||||||
|
number = float(parts[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
unit = parts[1].lower() if len(parts) > 1 else 'bytes'
|
||||||
|
factors = {
|
||||||
|
'byte': 1,
|
||||||
|
'bytes': 1,
|
||||||
|
'b': 1,
|
||||||
|
'kb': 1024,
|
||||||
|
'mb': 1024 ** 2,
|
||||||
|
'gb': 1024 ** 3,
|
||||||
|
'tb': 1024 ** 4,
|
||||||
|
}
|
||||||
|
factor = factors.get(unit)
|
||||||
|
return int(number * factor) if factor else None
|
||||||
|
|
||||||
|
def find_moved_entry(root: Path, known: dict, rel: str, local_hash: str, remote_ids: dict[str, dict]) -> tuple[str, dict] | None:
|
||||||
|
for old_rel, entry in known.items():
|
||||||
|
if old_rel == rel or not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
file_id = entry.get('file_id')
|
||||||
|
if entry.get('sha256') == local_hash and file_id in remote_ids and not (root / old_rel).is_file():
|
||||||
|
return old_rel, entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_adoptable_remote(client, node: str, path: Path, local_hash: str, remote_files: list[dict], known_ids: set[str]) -> dict | None:
|
||||||
|
size = path.stat().st_size
|
||||||
|
name = path.name
|
||||||
|
candidates = []
|
||||||
|
for remote in remote_files:
|
||||||
|
if not isinstance(remote, dict):
|
||||||
|
continue
|
||||||
|
file_id = remote.get('file_id')
|
||||||
|
if not file_id or file_id in known_ids:
|
||||||
|
continue
|
||||||
|
if remote.get('file_name') != name:
|
||||||
|
continue
|
||||||
|
remote_size = parse_size(remote.get('size_bytes') or remote.get('file_size'))
|
||||||
|
if remote_size is not None and remote_size != size:
|
||||||
|
continue
|
||||||
|
candidates.append(remote)
|
||||||
|
|
||||||
|
hash_matches = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix='nanoshare-adopt-') as temp_dir:
|
||||||
|
for remote in candidates:
|
||||||
|
temp_path = Path(temp_dir) / str(remote['file_id'])
|
||||||
|
try:
|
||||||
|
download(client, node, str(remote['file_id']), temp_path)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"warning: could not verify remote {remote['file_id']} for adoption: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
if sha256(temp_path) == local_hash:
|
||||||
|
hash_matches.append(remote)
|
||||||
|
return hash_matches[0] if len(hash_matches) == 1 else None
|
||||||
|
|
||||||
|
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:
|
||||||
|
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
|
||||||
|
remote_files = list_remote(client, args.node)
|
||||||
|
remote_ids = remote_by_id(remote_files)
|
||||||
|
remote_names = remote_by_name(remote_files)
|
||||||
|
|
||||||
|
for path in iter_local_files(root, ignore_patterns):
|
||||||
|
rel = relative(root, path)
|
||||||
|
try:
|
||||||
|
current_hash = sha256(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f'skip vanished local file: {rel}')
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
|
||||||
|
remote_file_name, remote_file_path = split_remote_path(rel)
|
||||||
|
if not entry:
|
||||||
|
moved = find_moved_entry(root, known, rel, current_hash, remote_ids)
|
||||||
|
if moved:
|
||||||
|
old_rel, moved_entry = moved
|
||||||
|
moved_id = moved_entry['file_id']
|
||||||
|
update_remote(client, args.node, moved_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '')
|
||||||
|
print(f'move remote: {old_rel} -> {rel}')
|
||||||
|
known.pop(old_rel, None)
|
||||||
|
known[rel] = {
|
||||||
|
'file_id': moved_id,
|
||||||
|
'sha256': current_hash,
|
||||||
|
'local_mtime': path.stat().st_mtime,
|
||||||
|
'file_name': remote_file_name,
|
||||||
|
'file_path': remote_file_path,
|
||||||
|
}
|
||||||
|
remote_files = list_remote(client, args.node)
|
||||||
|
remote_ids = remote_by_id(remote_files)
|
||||||
|
remote_names = remote_by_name(remote_files)
|
||||||
|
continue
|
||||||
|
|
||||||
|
adopted = find_adoptable_remote(client, args.node, path, current_hash, remote_files, {value.get('file_id') for value in known.values() if isinstance(value, dict)})
|
||||||
|
if adopted:
|
||||||
|
adopted_id = adopted['file_id']
|
||||||
|
current_remote_name = join_remote_path(adopted.get('file_name') or '', adopted.get('file_path') or '', adopted_id)
|
||||||
|
if current_remote_name != rel:
|
||||||
|
update_remote(client, args.node, adopted_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '')
|
||||||
|
print(f'move remote: {current_remote_name} -> {rel}')
|
||||||
|
known[rel] = {
|
||||||
|
'file_id': adopted_id,
|
||||||
|
'sha256': current_hash,
|
||||||
|
'local_mtime': path.stat().st_mtime,
|
||||||
|
'file_name': remote_file_name,
|
||||||
|
'file_path': remote_file_path,
|
||||||
|
}
|
||||||
|
remote_files = list_remote(client, args.node)
|
||||||
|
remote_ids = remote_by_id(remote_files)
|
||||||
|
remote_names = remote_by_name(remote_files)
|
||||||
|
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, remote_file_name, args.note or '', args.expires or '', remote_file_path)
|
||||||
|
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': remote_file_name,
|
||||||
|
'file_path': remote_file_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = join_remote_path(remote.get('file_name') or '', remote.get('file_path') or '', file_id)
|
||||||
|
if is_ignored(name, ignore_patterns):
|
||||||
|
continue
|
||||||
|
path = unique_path(root / name)
|
||||||
|
print(f'download: {file_id} -> {relative(root, path)}')
|
||||||
|
download(client, args.node, file_id, path)
|
||||||
|
remote_file_name, remote_file_path = split_remote_path(name)
|
||||||
|
known[relative(root, path)] = {
|
||||||
|
'file_id': file_id,
|
||||||
|
'sha256': sha256(path),
|
||||||
|
'local_mtime': path.stat().st_mtime,
|
||||||
|
'file_name': remote_file_name,
|
||||||
|
'file_path': remote_file_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.delete:
|
||||||
|
for rel, entry in list(known.items()):
|
||||||
|
if (root / rel).is_file():
|
||||||
|
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,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
def _display_width(value: str) -> int:
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
def _cell(value: object) -> str:
|
||||||
|
return '' if value is None else str(value)
|
||||||
|
|
||||||
|
def format_table(headers: list[str], rows: Iterable[Iterable[object]]) -> str:
|
||||||
|
string_rows = [[_cell(value) for value in row] for row in rows]
|
||||||
|
widths = [_display_width(header) for header in headers]
|
||||||
|
for row in string_rows:
|
||||||
|
for index, value in enumerate(row):
|
||||||
|
if index < len(widths):
|
||||||
|
widths[index] = max(widths[index], _display_width(value))
|
||||||
|
|
||||||
|
def render_row(values: list[str]) -> str:
|
||||||
|
padded = [value.ljust(widths[index]) for index, value in enumerate(values)]
|
||||||
|
return ' '.join(padded).rstrip()
|
||||||
|
|
||||||
|
separator = ' '.join('-' * width for width in widths).rstrip()
|
||||||
|
lines = [render_row(headers), separator]
|
||||||
|
lines.extend(render_row(row) for row in string_rows)
|
||||||
|
return '\n'.join(lines)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .client import events_url
|
||||||
|
from .ignore import is_ignored, load_ignore_patterns
|
||||||
|
from .sync import relative, state_path, SYNC_DIR_NAME
|
||||||
|
|
||||||
|
SyncFunc = Callable[[object], int]
|
||||||
|
|
||||||
|
class _ChangeFlag:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.event = threading.Event()
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
self.event.set()
|
||||||
|
|
||||||
|
def wait(self, timeout: float) -> bool:
|
||||||
|
return self.event.wait(timeout)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.event.clear()
|
||||||
|
|
||||||
|
def _is_relevant_path(root: Path, path: str, ignore_patterns: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
candidate = Path(path).expanduser().resolve()
|
||||||
|
rel = relative(root, candidate)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if candidate == state_path(root) or root / SYNC_DIR_NAME in candidate.parents:
|
||||||
|
return False
|
||||||
|
return not is_ignored(rel, ignore_patterns)
|
||||||
|
|
||||||
|
def _remote_events_thread(args, flag: _ChangeFlag, stop: threading.Event) -> threading.Thread:
|
||||||
|
def run() -> None:
|
||||||
|
while not stop.is_set():
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
from .app import make_client
|
||||||
|
client = make_client(args)
|
||||||
|
headers = {'Accept': 'text/event-stream', **client.auth_headers()}
|
||||||
|
with httpx.Client(timeout=None) as http:
|
||||||
|
with http.stream('GET', events_url(client, args.node), headers=headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if stop.is_set():
|
||||||
|
return
|
||||||
|
if line.startswith('event: files.changed'):
|
||||||
|
flag.notify()
|
||||||
|
except Exception as exc:
|
||||||
|
if not stop.is_set():
|
||||||
|
print(f'remote event stream disconnected; retrying in 30s: {exc}', file=sys.stderr)
|
||||||
|
stop.wait(30)
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
def _watchdog_observer(root: Path, ignore_patterns: list[str], flag: _ChangeFlag):
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
class Handler(FileSystemEventHandler):
|
||||||
|
def on_any_event(self, event):
|
||||||
|
paths = [event.src_path]
|
||||||
|
dest_path = getattr(event, 'dest_path', None)
|
||||||
|
if dest_path:
|
||||||
|
paths.append(dest_path)
|
||||||
|
if any(_is_relevant_path(root, path, ignore_patterns) for path in paths):
|
||||||
|
flag.notify()
|
||||||
|
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(Handler(), str(root), recursive=True)
|
||||||
|
observer.start()
|
||||||
|
return observer
|
||||||
|
|
||||||
|
def watch_loop(args, sync_func: SyncFunc) -> int:
|
||||||
|
root = Path(args.folder).expanduser().resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
|
||||||
|
interval = max(float(getattr(args, 'interval', 10.0)), 0.1)
|
||||||
|
flag = _ChangeFlag()
|
||||||
|
observer = None
|
||||||
|
stop_events = threading.Event()
|
||||||
|
events_thread = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
observer = _watchdog_observer(root, ignore_patterns, flag)
|
||||||
|
print(f'watching {args.folder} for local changes; remote events with {interval}s fallback')
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'watchdog unavailable, polling every {interval}s: {exc}', file=sys.stderr)
|
||||||
|
print(f'watching {args.folder} every {interval}s')
|
||||||
|
|
||||||
|
events_thread = _remote_events_thread(args, flag, stop_events)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
flag.clear()
|
||||||
|
code = sync_func(args)
|
||||||
|
if code:
|
||||||
|
print(f'watch sync failed; retrying in {interval}s', file=sys.stderr)
|
||||||
|
flag.wait(interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if flag.wait(interval):
|
||||||
|
flag.clear()
|
||||||
|
time.sleep(1.0)
|
||||||
|
while flag.wait(1.0):
|
||||||
|
flag.clear()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
stop_events.set()
|
||||||
|
if observer:
|
||||||
|
observer.stop()
|
||||||
|
observer.join(timeout=5)
|
||||||
|
if events_thread:
|
||||||
|
events_thread.join(timeout=5)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "nanoshare-cli"
|
||||||
|
version = "0.4.1"
|
||||||
|
description = "NanoShare desktop CLI and folder sync client"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"watchdog==6.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
nanoshare = "nanoshare_client.cli:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["nanoshare_client"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
@@ -30,15 +30,16 @@ class ConvexDB(ConvexDbBase):
|
|||||||
return [ {
|
return [ {
|
||||||
"file_id": x['file_id'],
|
"file_id": x['file_id'],
|
||||||
"file_name": x['file_name'],
|
"file_name": x['file_name'],
|
||||||
|
"file_path": x.get('file_path', ''),
|
||||||
"file_size": x['file_size'],
|
"file_size": x['file_size'],
|
||||||
"note": x['note'],
|
"note": x['note'],
|
||||||
"expires_at": int(x['expires_at']) if x.get('expires_at', None) else '',
|
"expires_at": int(x['expires_at']) if x.get('expires_at', None) else '',
|
||||||
"uploaded_at": int(x['uploaded_at']),
|
"uploaded_at": int(x['uploaded_at']),
|
||||||
} for x in data ]
|
} for x in data ]
|
||||||
|
|
||||||
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str):
|
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str, file_path:str=''):
|
||||||
args = {
|
args = {
|
||||||
'file_name': file_name, 'file_size': file_size, 'content_type': content_type,
|
'file_name': file_name, 'file_path': file_path, 'file_size': file_size, 'content_type': content_type,
|
||||||
'note': note,
|
'note': note,
|
||||||
'file_storage_id': storage_id, 'user_id': user_id
|
'file_storage_id': storage_id, 'user_id': user_id
|
||||||
}
|
}
|
||||||
@@ -51,13 +52,17 @@ class ConvexDB(ConvexDbBase):
|
|||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str):
|
async def update_file(self, file_id:str, file_name:str, note:str|None, expires_at:datetime|None, user_id:str, file_path:str='', preserve_missing:bool=False):
|
||||||
args = {
|
args = {
|
||||||
'file_id': file_id,
|
'file_id': file_id,
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
'note': note,
|
'file_path': file_path,
|
||||||
'user_id': user_id
|
'user_id': user_id
|
||||||
}
|
}
|
||||||
|
if note is not None:
|
||||||
|
args['note'] = note
|
||||||
|
elif not preserve_missing:
|
||||||
|
args['note'] = ''
|
||||||
if expires_at:
|
if expires_at:
|
||||||
args['expires_at'] = expires_at.isoformat()
|
args['expires_at'] = expires_at.isoformat()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanoshare"
|
name = "nanoshare"
|
||||||
version = "1.21.0"
|
version = "1.26.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+1
-1
Submodule quart_common updated: 5b3cf7b59a...d61514d755
@@ -19,5 +19,9 @@ from .side.main import side_main_bp
|
|||||||
|
|
||||||
from .side.upload import upload_bp
|
from .side.upload import upload_bp
|
||||||
|
|
||||||
|
from .api.cli_auth import cli_auth_bp
|
||||||
|
from .api.download import api_download_bp
|
||||||
|
from .api.events import api_events_bp
|
||||||
|
|
||||||
# Health
|
# Health
|
||||||
from .api.health import health_bp
|
from .api.health import health_bp
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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_DEFAULT_SCOPE = 'files'
|
||||||
|
CLI_AUTH_ALLOWED_SCOPES = {'all', 'files'}
|
||||||
|
|
||||||
|
def _normalize_cli_scope(scope: str | None) -> str:
|
||||||
|
requested_scope = (scope or CLI_AUTH_DEFAULT_SCOPE).strip() or CLI_AUTH_DEFAULT_SCOPE
|
||||||
|
if requested_scope not in CLI_AUTH_ALLOWED_SCOPES:
|
||||||
|
raise ValueError(f'unsupported CLI scope: {requested_scope}')
|
||||||
|
return requested_scope
|
||||||
|
|
||||||
|
@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()
|
||||||
|
try:
|
||||||
|
scope = _normalize_cli_scope(request.args.get('scope'))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
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'],
|
||||||
|
_normalize_cli_scope(login_request.get('scope')),
|
||||||
|
)
|
||||||
|
|
||||||
|
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']
|
||||||
|
try:
|
||||||
|
scope = _normalize_cli_scope(login_data.get('scope'))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||||
|
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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from my_modules.app.setup import LIMITER
|
||||||
|
from my_modules.decoratory.header import token_required
|
||||||
|
from quart import Blueprint, Response, current_app, stream_with_context
|
||||||
|
|
||||||
|
api_events_bp = Blueprint('api_events', __name__)
|
||||||
|
|
||||||
|
async def _snapshot(user_id: str) -> str:
|
||||||
|
files = await current_app.convex.get_files(user_id)
|
||||||
|
rows = []
|
||||||
|
for item in files or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
'file_id': item.get('file_id'),
|
||||||
|
'file_name': item.get('file_name'),
|
||||||
|
'file_path': item.get('file_path') or '',
|
||||||
|
'file_size': item.get('file_size'),
|
||||||
|
'expires_at': item.get('expires_at'),
|
||||||
|
'updated_at': item.get('updated_at') or item.get('uploaded_at'),
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda row: str(row.get('file_id') or ''))
|
||||||
|
return json.dumps(rows, sort_keys=True, separators=(',', ':'))
|
||||||
|
|
||||||
|
@api_events_bp.get('/api/files/events')
|
||||||
|
@LIMITER.limit('6 per minute;60 per hour;')
|
||||||
|
@token_required(['files', 'mesh'])
|
||||||
|
async def api_file_events(user: dict):
|
||||||
|
user_id = user['sub']
|
||||||
|
|
||||||
|
@stream_with_context
|
||||||
|
async def stream():
|
||||||
|
previous = await _snapshot(user_id)
|
||||||
|
yield 'event: ready\ndata: {}\n\n'
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
current = await _snapshot(user_id)
|
||||||
|
if current != previous:
|
||||||
|
previous = current
|
||||||
|
yield f'event: files.changed\ndata: {current}\n\n'
|
||||||
|
else:
|
||||||
|
yield ': keepalive\n\n'
|
||||||
|
|
||||||
|
response = Response(stream(), content_type='text/event-stream', headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
})
|
||||||
|
response.timeout = None
|
||||||
|
return response
|
||||||
+28
-11
@@ -4,7 +4,7 @@ Lets other nodes (browser-cli, website) push files in and read file metadata
|
|||||||
over the shared servicelink envelope at POST /rpc, alongside the existing web
|
over the shared servicelink envelope at POST /rpc, alongside the existing web
|
||||||
UI and /api routes.
|
UI and /api routes.
|
||||||
|
|
||||||
Every call needs a bearer token carrying the `mesh` scope; the endpoint is rate
|
Every call needs a bearer token carrying the `files` or `mesh` scope; the endpoint is rate
|
||||||
limited and body-size capped. Keep /rpc on the internal node network.
|
limited and body-size capped. Keep /rpc on the internal node network.
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,21 +12,33 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
from quart import current_app
|
from quart import current_app
|
||||||
|
|
||||||
from my_modules.app.setup import LIMITER
|
from my_modules.app.setup import LIMITER
|
||||||
from my_modules.expiry import ensure_utc, parse_expires
|
from my_modules.expiry import ensure_utc, parse_expires
|
||||||
from my_modules.file_meta import format_size, iso_stamp_filename
|
from my_modules.file_meta import format_size, iso_stamp_filename
|
||||||
from servicelink import InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
|
from servicelink import Forbidden, InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
|
||||||
|
|
||||||
MAX_RPC_BODY = 16 * 1024 * 1024
|
MAX_RPC_BODY = 16 * 1024 * 1024
|
||||||
MESH_SCOPE = 'mesh'
|
RPC_SCOPES = ('files', 'mesh')
|
||||||
|
|
||||||
router = Router('picoshare')
|
router = Router('picoshare')
|
||||||
|
|
||||||
|
def _safe_file_path(value: str) -> str:
|
||||||
|
raw = (value or '').replace('\\', '/')
|
||||||
|
parts = [
|
||||||
|
part for part in PurePosixPath(raw).parts
|
||||||
|
if part not in ('', '.', '..', '/')
|
||||||
|
]
|
||||||
|
return '/'.join(parts)
|
||||||
|
|
||||||
def _user_id(ctx):
|
def _user_id(ctx):
|
||||||
if ctx.principal is None:
|
if ctx.principal is None:
|
||||||
raise Unauthorized('authentication required')
|
raise Unauthorized('authentication required')
|
||||||
|
if not any(ctx.principal.has_scope(scope) for scope in RPC_SCOPES):
|
||||||
|
raise Forbidden('missing required scope: files')
|
||||||
return ctx.principal.subject
|
return ctx.principal.subject
|
||||||
|
|
||||||
@router.method('files.upload')
|
@router.method('files.upload')
|
||||||
@@ -45,10 +57,12 @@ async def files_upload(params, ctx):
|
|||||||
else:
|
else:
|
||||||
raise InvalidParams('provide text or content_b64')
|
raise InvalidParams('provide text or content_b64')
|
||||||
|
|
||||||
file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext)
|
file_name = PurePosixPath(str(params.get('file_name') or iso_stamp_filename('mesh', default_ext)).replace('\\', '/')).name
|
||||||
|
file_path = _safe_file_path(str(params.get('file_path') or ''))
|
||||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
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_name=file_name,
|
||||||
|
file_path=file_path,
|
||||||
file_size=format_size(len(data)),
|
file_size=format_size(len(data)),
|
||||||
note=params.get('note', ''),
|
note=params.get('note', ''),
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
@@ -56,7 +70,8 @@ async def files_upload(params, ctx):
|
|||||||
storage_id=storage_id,
|
storage_id=storage_id,
|
||||||
user_id=user_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, 'file_path': file_path, 'size': len(data), 'content_type': content_type}
|
||||||
|
|
||||||
@router.method('files.list')
|
@router.method('files.list')
|
||||||
async def files_list(params, ctx):
|
async def files_list(params, ctx):
|
||||||
@@ -82,15 +97,17 @@ async def files_info(params, ctx):
|
|||||||
@router.method('files.update')
|
@router.method('files.update')
|
||||||
async def files_update(params, ctx):
|
async def files_update(params, ctx):
|
||||||
file_id = params.get('file_id')
|
file_id = params.get('file_id')
|
||||||
file_name = params.get('file_name')
|
file_name = PurePosixPath(str(params.get('file_name') or '').replace('\\', '/')).name
|
||||||
if not file_id or not file_name:
|
if not file_id or not file_name:
|
||||||
raise InvalidParams('file_id and file_name are required')
|
raise InvalidParams('file_id and file_name are required')
|
||||||
await current_app.convex.update_file(
|
await current_app.convex.update_file(
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
file_name=file_name,
|
file_name=file_name,
|
||||||
note=params.get('note', ''),
|
file_path=_safe_file_path(str(params.get('file_path') or '')),
|
||||||
|
note=params.get('note') if 'note' in params else None,
|
||||||
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
||||||
user_id=_user_id(ctx),
|
user_id=_user_id(ctx),
|
||||||
|
preserve_missing=True,
|
||||||
)
|
)
|
||||||
return {'updated': True}
|
return {'updated': True}
|
||||||
|
|
||||||
@@ -109,11 +126,11 @@ async def _decode_access_token(token):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _build_verify():
|
def _build_verify():
|
||||||
# Accept a JWT access token with the mesh scope (public path) OR, on the
|
# Accept a JWT access token with a file/RPC scope (public path) OR, on the
|
||||||
# trusted Docker network, a static shared secret from SERVICELINK_MESH_SECRET.
|
# trusted Docker network, a static shared secret from SERVICELINK_MESH_SECRET.
|
||||||
jwt = bearer_verifier(_decode_access_token, require_scope=MESH_SCOPE)
|
jwt = bearer_verifier(_decode_access_token)
|
||||||
secret = os.getenv('SERVICELINK_MESH_SECRET')
|
secret = os.getenv('SERVICELINK_MESH_SECRET')
|
||||||
return any_verifier(shared_secret_verifier(secret, scopes=(MESH_SCOPE,)), jwt) if secret else jwt
|
return any_verifier(shared_secret_verifier(secret, scopes=('mesh',)), jwt) if secret else jwt
|
||||||
|
|
||||||
verify = _build_verify()
|
verify = _build_verify()
|
||||||
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
|
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from routes import (
|
|||||||
basic_bp, auth_login_bp,
|
basic_bp, auth_login_bp,
|
||||||
side_main_bp,
|
side_main_bp,
|
||||||
upload_bp,
|
upload_bp,
|
||||||
|
cli_auth_bp,
|
||||||
|
api_download_bp,
|
||||||
|
api_events_bp,
|
||||||
health_bp
|
health_bp
|
||||||
)
|
)
|
||||||
from routes.api.link import link_bp as servicelink_bp
|
from routes.api.link import link_bp as servicelink_bp
|
||||||
@@ -21,6 +24,9 @@ app.register_blueprint(auth_login_bp)
|
|||||||
|
|
||||||
app.register_blueprint(side_main_bp)
|
app.register_blueprint(side_main_bp)
|
||||||
app.register_blueprint(upload_bp)
|
app.register_blueprint(upload_bp)
|
||||||
|
app.register_blueprint(cli_auth_bp)
|
||||||
|
app.register_blueprint(api_download_bp)
|
||||||
|
app.register_blueprint(api_events_bp)
|
||||||
|
|
||||||
# ServiceLink node-to-node mesh endpoint (POST /rpc)
|
# ServiceLink node-to-node mesh endpoint (POST /rpc)
|
||||||
app.register_blueprint(servicelink_bp)
|
app.register_blueprint(servicelink_bp)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
class _NoOpLimiter:
|
||||||
|
def limit(self, *args, **kwargs):
|
||||||
|
return lambda fn: fn
|
||||||
|
|
||||||
|
def _load_cli_auth_module():
|
||||||
|
setup_stub = types.ModuleType('my_modules.app.setup')
|
||||||
|
setup_stub.LIMITER = _NoOpLimiter()
|
||||||
|
setup_stub.cache = object()
|
||||||
|
sys.modules['my_modules.app.setup'] = setup_stub
|
||||||
|
|
||||||
|
module_path = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'cli_auth.py'
|
||||||
|
spec = importlib.util.spec_from_file_location('api_cli_auth_under_test', module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
def test_normalize_cli_scope_keeps_files_scope():
|
||||||
|
module = _load_cli_auth_module()
|
||||||
|
|
||||||
|
assert module._normalize_cli_scope('files') == 'files'
|
||||||
|
|
||||||
|
def test_normalize_cli_scope_defaults_to_files():
|
||||||
|
module = _load_cli_auth_module()
|
||||||
|
|
||||||
|
assert module._normalize_cli_scope(None) == 'files'
|
||||||
|
assert module._normalize_cli_scope('') == 'files'
|
||||||
|
|
||||||
|
def test_normalize_cli_scope_rejects_unsupported_scope():
|
||||||
|
module = _load_cli_auth_module()
|
||||||
|
|
||||||
|
try:
|
||||||
|
module._normalize_cli_scope('printer')
|
||||||
|
except ValueError as exc:
|
||||||
|
assert 'unsupported CLI scope' in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError('expected unsupported scope to raise')
|
||||||
@@ -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,406 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli'))
|
||||||
|
|
||||||
|
from nanoshare_client import cli
|
||||||
|
from nanoshare_client.auth import _CallbackServer
|
||||||
|
from nanoshare_client.completion import script as completion_script
|
||||||
|
from nanoshare_client.ignore import load_ignore_patterns, is_ignored
|
||||||
|
from nanoshare_client.table import format_table
|
||||||
|
from nanoshare_client.watch import _is_relevant_path
|
||||||
|
|
||||||
|
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_path': params.get('file_path', ''),
|
||||||
|
'file_size': '1 B',
|
||||||
|
})
|
||||||
|
return {'file_id': file_id, 'file_name': params['file_name'], 'file_path': params.get('file_path', '')}
|
||||||
|
if method == 'files.update':
|
||||||
|
for item in self.remote_files:
|
||||||
|
if item['file_id'] == params['file_id']:
|
||||||
|
item['file_name'] = params['file_name']
|
||||||
|
item['file_path'] = params.get('file_path', '')
|
||||||
|
if 'note' in params:
|
||||||
|
item['note'] = params['note']
|
||||||
|
return {'updated': True}
|
||||||
|
return {'updated': False}
|
||||||
|
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 fake_download(client, node, file_id, path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(f'downloaded {file_id}')
|
||||||
|
|
||||||
|
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_uploads_nested_local_files(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
|
||||||
|
nested = tmp_path / 'docs' / 'notes' / 'hello.txt'
|
||||||
|
nested.parent.mkdir(parents=True)
|
||||||
|
nested.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'
|
||||||
|
assert fake.uploads[0][1]['file_path'] == 'docs/notes'
|
||||||
|
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||||
|
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_sync_updates_remote_path_when_tracked_file_moves(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'old.txt', 'file_path': '', 'file_size': '5 Bytes', 'expires_at': 123456}]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
|
||||||
|
state_dir = tmp_path / '.nanoshare-sync'
|
||||||
|
state_dir.mkdir()
|
||||||
|
(state_dir / 'state.json').write_text(json.dumps({
|
||||||
|
'version': 1,
|
||||||
|
'files': {
|
||||||
|
'old.txt': {
|
||||||
|
'file_id': 'file_1',
|
||||||
|
'sha256': '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
||||||
|
'local_mtime': 1,
|
||||||
|
'file_name': 'old.txt',
|
||||||
|
'file_path': '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
nested = tmp_path / 'docs' / 'new.txt'
|
||||||
|
nested.parent.mkdir(parents=True)
|
||||||
|
nested.write_text('hello')
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
'--delete',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert fake.uploads == []
|
||||||
|
assert fake.deleted == []
|
||||||
|
assert fake.remote_files[0]['file_name'] == 'new.txt'
|
||||||
|
assert fake.remote_files[0]['file_path'] == 'docs'
|
||||||
|
assert fake.remote_files[0]['expires_at'] == 123456
|
||||||
|
assert 'note' not in fake.remote_files[0]
|
||||||
|
state = json.loads((state_dir / 'state.json').read_text())
|
||||||
|
assert 'old.txt' not in state['files']
|
||||||
|
assert state['files']['docs/new.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_sync_adopts_and_moves_existing_remote_file(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
def matching_download(client, node, file_id, path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text('hello')
|
||||||
|
|
||||||
|
monkeypatch.setattr('nanoshare_client.sync.download', matching_download)
|
||||||
|
|
||||||
|
nested = tmp_path / 'docs' / 'hello.txt'
|
||||||
|
nested.parent.mkdir(parents=True)
|
||||||
|
nested.write_text('hello')
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert fake.uploads == []
|
||||||
|
assert fake.remote_files[0]['file_name'] == 'hello.txt'
|
||||||
|
assert fake.remote_files[0]['file_path'] == 'docs'
|
||||||
|
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||||
|
assert state['files']['docs/hello.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_sync_does_not_adopt_same_name_with_different_hash(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||||
|
|
||||||
|
local = tmp_path / 'hello.txt'
|
||||||
|
local.write_text('other')
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert len(fake.uploads) == 1
|
||||||
|
assert fake.uploads[0][1]['file_name'] == 'hello.txt'
|
||||||
|
|
||||||
|
def test_sync_downloads_remote_subfolders(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': 'docs/notes', 'file_size': '5 B'}]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert (tmp_path / 'docs' / 'notes' / 'hello.txt').read_text() == 'downloaded file_1'
|
||||||
|
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||||
|
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_sync_ignores_local_files_from_nanoshareignore(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
|
||||||
|
(tmp_path / '.nanoshareignore').write_text('cache/\n*.tmp\n')
|
||||||
|
(tmp_path / 'keep.txt').write_text('keep')
|
||||||
|
(tmp_path / 'cache').mkdir()
|
||||||
|
(tmp_path / 'cache' / 'ignored.txt').write_text('ignored')
|
||||||
|
(tmp_path / 'scratch.tmp').write_text('ignored')
|
||||||
|
(tmp_path / '.comments').mkdir()
|
||||||
|
(tmp_path / '.comments' / 'ignored.xml').write_text('<comment />')
|
||||||
|
(tmp_path / '.env').write_text('SECRET=ignored')
|
||||||
|
(tmp_path / '.venv').mkdir()
|
||||||
|
(tmp_path / '.venv' / 'ignored.py').write_text('ignored')
|
||||||
|
(tmp_path / 'Thumbs.db').write_text('ignored')
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert [upload[1]['file_name'] for upload in fake.uploads] == ['keep.txt']
|
||||||
|
|
||||||
|
def test_sync_ignores_remote_files_from_cli_pattern(tmp_path, monkeypatch):
|
||||||
|
fake = FakeClient()
|
||||||
|
fake.remote_files = [
|
||||||
|
{'file_id': 'file_1', 'file_name': 'keep.txt', 'file_path': '', 'file_size': '5 B'},
|
||||||
|
{'file_id': 'file_2', 'file_name': 'ignored.txt', 'file_path': 'cache', 'file_size': '5 B'},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||||
|
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||||
|
|
||||||
|
code = cli.main([
|
||||||
|
'sync',
|
||||||
|
'--url', 'picoshare=https://example.com',
|
||||||
|
'--token', 'token',
|
||||||
|
'--ignore', 'cache/',
|
||||||
|
str(tmp_path),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert (tmp_path / 'keep.txt').is_file()
|
||||||
|
assert not (tmp_path / 'cache' / 'ignored.txt').exists()
|
||||||
|
|
||||||
|
def test_ignore_patterns_match_files_and_directories():
|
||||||
|
patterns = ['cache/', '*.tmp', 'docs/*.draft.md']
|
||||||
|
|
||||||
|
assert is_ignored('cache/file.txt', patterns)
|
||||||
|
assert is_ignored('nested/scratch.tmp', patterns)
|
||||||
|
assert is_ignored('docs/page.draft.md', patterns)
|
||||||
|
assert not is_ignored('docs/page.md', patterns)
|
||||||
|
|
||||||
|
def test_default_ignore_patterns_skip_common_generated_files(tmp_path):
|
||||||
|
patterns = load_ignore_patterns(tmp_path)
|
||||||
|
|
||||||
|
assert is_ignored('.comments/comment.xml', patterns)
|
||||||
|
assert is_ignored('.env', patterns)
|
||||||
|
assert is_ignored('.env.local', patterns)
|
||||||
|
assert is_ignored('.venv/bin/python', patterns)
|
||||||
|
assert is_ignored('venv/bin/python', patterns)
|
||||||
|
assert is_ignored('__pycache__/mod.pyc', patterns)
|
||||||
|
assert is_ignored('nested/Thumbs.db', patterns)
|
||||||
|
assert not is_ignored('docs/note.txt', patterns)
|
||||||
|
|
||||||
|
def test_watch_loop_retries_after_sync_error(monkeypatch, tmp_path):
|
||||||
|
from nanoshare_client import watch as watch_module
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeFlag:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self, timeout):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fake_sync(args):
|
||||||
|
calls.append(args.folder)
|
||||||
|
if len(calls) == 1:
|
||||||
|
return 1
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
monkeypatch.setattr(watch_module, '_ChangeFlag', FakeFlag)
|
||||||
|
monkeypatch.setattr(watch_module, '_watchdog_observer', lambda *args: None)
|
||||||
|
args = type('Args', (), {'folder': str(tmp_path), 'ignore': [], 'interval': 0.1})()
|
||||||
|
|
||||||
|
code = watch_module.watch_loop(args, fake_sync)
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert calls == [str(tmp_path), str(tmp_path)]
|
||||||
|
|
||||||
|
def test_watch_relevance_ignores_sync_state_and_default_ignores(tmp_path):
|
||||||
|
patterns = load_ignore_patterns(tmp_path)
|
||||||
|
|
||||||
|
assert _is_relevant_path(tmp_path, str(tmp_path / 'note.txt'), patterns)
|
||||||
|
assert not _is_relevant_path(tmp_path, str(tmp_path / '.nanoshare-sync' / 'state.json'), patterns)
|
||||||
|
assert not _is_relevant_path(tmp_path, str(tmp_path / '.comments' / 'comment.xml'), patterns)
|
||||||
|
assert not _is_relevant_path(tmp_path, str(tmp_path / '.env'), patterns)
|
||||||
|
|
||||||
|
def test_completion_scripts_include_commands():
|
||||||
|
zsh = completion_script('zsh')
|
||||||
|
bash = completion_script('bash')
|
||||||
|
|
||||||
|
assert 'compdef _nanoshare nanoshare' in zsh
|
||||||
|
assert 'complete -F _nanoshare nanoshare' in bash
|
||||||
|
assert 'login upload list download sync watch completion' in zsh
|
||||||
|
assert 'login upload list download sync watch completion' in bash
|
||||||
|
|
||||||
|
def test_resolve_remote_file_accepts_id_exact_name_or_path():
|
||||||
|
files = [
|
||||||
|
{'file_id': 'file_1', 'file_name': 'report.pdf', 'file_path': 'docs'},
|
||||||
|
{'file_id': 'file_2', 'file_name': 'photo.png'},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert cli._resolve_remote_file(files, 'file_1')['file_name'] == 'report.pdf'
|
||||||
|
assert cli._resolve_remote_file(files, 'photo.png')['file_id'] == 'file_2'
|
||||||
|
assert cli._resolve_remote_file(files, 'docs/report.pdf')['file_id'] == 'file_1'
|
||||||
|
|
||||||
|
def test_resolve_remote_file_rejects_duplicate_names():
|
||||||
|
files = [
|
||||||
|
{'file_id': 'file_1', 'file_name': 'report.pdf'},
|
||||||
|
{'file_id': 'file_2', 'file_name': 'report.pdf'},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cli._resolve_remote_file(files, 'report.pdf')
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert 'multiple remote files' in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError('expected duplicate names to raise')
|
||||||
|
|
||||||
|
def test_format_table_aligns_columns():
|
||||||
|
output = format_table(
|
||||||
|
['ID', 'Path', 'Name', 'Size'],
|
||||||
|
[
|
||||||
|
['abc', '', 'short.txt', '1 KB'],
|
||||||
|
['longer-id', 'docs', 'a much longer file name.png', '22 MB'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.splitlines() == [
|
||||||
|
'ID Path Name Size',
|
||||||
|
'--------- ---- --------------------------- -----',
|
||||||
|
'abc short.txt 1 KB',
|
||||||
|
'longer-id docs a much longer file name.png 22 MB',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_login_callback_returns_without_waiting_for_timeout():
|
||||||
|
server = _CallbackServer('127.0.0.1', 0, 'expected-state')
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
def wait_for_code():
|
||||||
|
result['code'] = server.wait_for_code(30)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=wait_for_code)
|
||||||
|
thread.start()
|
||||||
|
with urllib.request.urlopen(f'{server.url}?code=login-code&state=expected-state', timeout=5) as response:
|
||||||
|
assert response.status == 200
|
||||||
|
assert b'login complete' in response.read()
|
||||||
|
thread.join(5)
|
||||||
|
|
||||||
|
assert not thread.is_alive()
|
||||||
|
assert result['code'] == 'login-code'
|
||||||
|
|
||||||
|
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'
|
||||||
@@ -43,9 +43,10 @@ class FakeConvex:
|
|||||||
async def send_to_storage(self, data, content_type):
|
async def send_to_storage(self, data, content_type):
|
||||||
return 'storage_1'
|
return 'storage_1'
|
||||||
|
|
||||||
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id):
|
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id, file_path=''):
|
||||||
self.files[file_name] = {
|
self.files[file_name] = {
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
|
'file_path': file_path,
|
||||||
'note': note,
|
'note': note,
|
||||||
'content_type': content_type,
|
'content_type': content_type,
|
||||||
'storage_id': storage_id,
|
'storage_id': storage_id,
|
||||||
@@ -56,6 +57,9 @@ class FakeConvex:
|
|||||||
async def get_file(self, file_id):
|
async def get_file(self, file_id):
|
||||||
return self.files.get(file_id)
|
return self.files.get(file_id)
|
||||||
|
|
||||||
|
async def get_files(self, user_id):
|
||||||
|
return list(self.files.values())
|
||||||
|
|
||||||
def _call(envelope, token=None):
|
def _call(envelope, token=None):
|
||||||
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
|
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
|
||||||
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
|
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
|
||||||
@@ -100,7 +104,7 @@ def test_missing_token_is_unauthorized():
|
|||||||
assert status == 401
|
assert status == 401
|
||||||
assert body['error']['code'] == 'unauthorized'
|
assert body['error']['code'] == 'unauthorized'
|
||||||
|
|
||||||
def test_token_without_mesh_scope_is_forbidden():
|
def test_token_with_files_scope_is_allowed():
|
||||||
status, body = _call(_request('files.list'), token='nomesh')
|
status, body = _call(_request('files.list'), token='nomesh')
|
||||||
assert status == 403
|
assert status == 200
|
||||||
assert body['error']['code'] == 'forbidden'
|
assert body['ok'] is True
|
||||||
|
|||||||
@@ -457,15 +457,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "4.3.0"
|
version = "4.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "hpack" },
|
{ name = "hpack" },
|
||||||
{ name = "hyperframe" },
|
{ name = "hyperframe" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -720,7 +720,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nanoshare"
|
name = "nanoshare"
|
||||||
version = "1.21.0"
|
version = "1.26.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Reference in New Issue
Block a user