feat(cli): add private NanoShare sync client
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
- Add a separate installable NanoShare CLI package under cli/. - Implement browser login with local callback and refresh-token config. - Add upload, list, download, sync, and watch CLI commands. - Add private owner-only file download endpoint for CLI downloads. - Add CLI auth endpoints for browser login and token refresh. - Return file_id from ServiceLink uploads for reliable sync state. - Exclude the CLI package from NanoShare container builds. - Include tests for private downloads and sync state updates.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from my_modules.app.setup import LIMITER, cache
|
||||
from quart import Blueprint, jsonify, redirect, request, session, url_for
|
||||
from quart_common.web.auth import get_auth_token
|
||||
from quart_common.web.wide_event import add_wide_event_context
|
||||
from quart import current_app
|
||||
|
||||
cli_auth_bp = Blueprint('cli_auth', __name__)
|
||||
|
||||
CLI_CODE_TTL_SECONDS = 300
|
||||
|
||||
@cli_auth_bp.get('/auth/cli/login')
|
||||
@LIMITER.limit('10 per minute;60 per hour;')
|
||||
async def cli_login():
|
||||
redirect_uri = request.args.get('redirect_uri', '').strip()
|
||||
state = request.args.get('state', '').strip()
|
||||
scope = request.args.get('scope', 'files').strip() or 'files'
|
||||
add_wide_event_context(auth={'method': 'cli_browser', 'operation': 'cli_login_start'})
|
||||
|
||||
if not redirect_uri or not state:
|
||||
return jsonify({'ok': False, 'error': 'redirect_uri and state are required'}), 400
|
||||
|
||||
user = session.get('user')
|
||||
if not user:
|
||||
session['cli_login_request'] = {'redirect_uri': redirect_uri, 'state': state, 'scope': scope}
|
||||
session['post_login_redirect'] = url_for('cli_auth.cli_login_complete')
|
||||
return redirect(url_for('auth_login.login'))
|
||||
|
||||
return await _redirect_with_cli_code(user['sub'], redirect_uri, state, scope)
|
||||
|
||||
@cli_auth_bp.get('/auth/cli/complete')
|
||||
@LIMITER.limit('10 per minute;60 per hour;')
|
||||
async def cli_login_complete():
|
||||
user = session.get('user')
|
||||
login_request = session.pop('cli_login_request', None)
|
||||
if not user or not login_request:
|
||||
return jsonify({'ok': False, 'error': 'no pending CLI login'}), 400
|
||||
|
||||
return await _redirect_with_cli_code(
|
||||
user['sub'],
|
||||
login_request['redirect_uri'],
|
||||
login_request['state'],
|
||||
login_request.get('scope') or 'files',
|
||||
)
|
||||
|
||||
async def _redirect_with_cli_code(user_id: str, redirect_uri: str, state: str, scope: str):
|
||||
code = secrets.token_urlsafe(32)
|
||||
await cache.set(
|
||||
f'cli:login:{code}',
|
||||
{'user_id': user_id, 'scope': scope},
|
||||
ttl=CLI_CODE_TTL_SECONDS,
|
||||
)
|
||||
separator = '&' if '?' in redirect_uri else '?'
|
||||
query = urlencode({'code': code, 'state': state})
|
||||
return redirect(f'{redirect_uri}{separator}{query}')
|
||||
|
||||
@cli_auth_bp.post('/api/cli/token')
|
||||
@LIMITER.limit('20 per minute;200 per hour;')
|
||||
async def cli_token_exchange():
|
||||
payload = await request.get_json(silent=True) or {}
|
||||
code = str(payload.get('code', '')).strip()
|
||||
if not code:
|
||||
return jsonify({'ok': False, 'error': 'code is required'}), 400
|
||||
|
||||
login_data = await cache.get(f'cli:login:{code}')
|
||||
if not login_data:
|
||||
return jsonify({'ok': False, 'error': 'invalid or expired code'}), 400
|
||||
await cache.delete(f'cli:login:{code}')
|
||||
|
||||
user_id = login_data['user_id']
|
||||
scope = login_data.get('scope') or 'files'
|
||||
token_data = await current_app.convex.add_refresh_token(
|
||||
token_name='nanoshare-cli',
|
||||
user_id=user_id,
|
||||
scope=scope,
|
||||
enabled=True,
|
||||
)
|
||||
refresh_token = token_data.get('refresh_token') if isinstance(token_data, dict) else None
|
||||
if not refresh_token:
|
||||
return jsonify({'ok': False, 'error': 'refresh token creation failed'}), 500
|
||||
return jsonify({'ok': True, 'refresh_token': refresh_token, 'scope': scope})
|
||||
|
||||
@cli_auth_bp.post('/token/refresh')
|
||||
@LIMITER.limit('60 per minute;1000 per hour;')
|
||||
async def token_refresh():
|
||||
refresh_token = await get_auth_token()
|
||||
if not refresh_token:
|
||||
response = jsonify({'ok': False, 'error': 'refresh token is missing'})
|
||||
response.headers['WWW-Authenticate'] = 'Bearer'
|
||||
return response, 401
|
||||
|
||||
token, refresh_id, user = await current_app.convex.generate_new_access_token(refresh_token)
|
||||
if not isinstance(token, dict) or not token.get('access_token'):
|
||||
return jsonify(token), 401
|
||||
return jsonify(token)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from my_modules.app.setup import LIMITER
|
||||
from my_modules.decoratory.header import token_required
|
||||
from quart_common.web.wide_event import add_wide_event_context
|
||||
|
||||
from quart import Blueprint, abort, current_app, send_file
|
||||
|
||||
api_download_bp = Blueprint('api_download', __name__)
|
||||
|
||||
def _last_modified_from_file(file_data: dict):
|
||||
uploaded_at = file_data.get('uploaded_at')
|
||||
if uploaded_at is None:
|
||||
return None
|
||||
try:
|
||||
return int(uploaded_at) / 1000
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@api_download_bp.get('/api/files/<path:file_id>/download')
|
||||
@LIMITER.limit('60 per minute;1000 per hour;')
|
||||
@token_required(['files', 'mesh'])
|
||||
async def api_file_download(file_id: str, user: dict):
|
||||
add_wide_event_context(nanoshare={'operation': 'api_file_download', 'file_id': file_id})
|
||||
|
||||
file_data = await current_app.convex.get_file_informations(file_id=file_id, user_id=user['sub'])
|
||||
if not file_data:
|
||||
add_wide_event_context(nanoshare={'operation_status': 'not_found'})
|
||||
abort(404)
|
||||
|
||||
if file_data.get('expired'):
|
||||
add_wide_event_context(nanoshare={'operation_status': 'expired'})
|
||||
abort(410)
|
||||
|
||||
storage_id = file_data.get('db_image_url')
|
||||
if not storage_id:
|
||||
add_wide_event_context(nanoshare={'operation_status': 'missing_storage'})
|
||||
abort(404)
|
||||
|
||||
content_type = file_data.get('content_type') or 'application/octet-stream'
|
||||
file_name = file_data.get('file_name') or file_id
|
||||
add_wide_event_context(nanoshare={'operation_status': 'served', 'content_type': content_type})
|
||||
|
||||
response = await send_file(
|
||||
filename_or_io=await current_app.convex.get_from_storage(storage_id),
|
||||
mimetype=content_type,
|
||||
as_attachment=True,
|
||||
attachment_filename=file_name,
|
||||
conditional=True,
|
||||
cache_timeout=0,
|
||||
last_modified=_last_modified_from_file(file_data),
|
||||
)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
+3
-2
@@ -47,7 +47,7 @@ async def files_upload(params, ctx):
|
||||
|
||||
file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext)
|
||||
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||
await current_app.convex.add_file(
|
||||
file_record = await current_app.convex.add_file(
|
||||
file_name=file_name,
|
||||
file_size=format_size(len(data)),
|
||||
note=params.get('note', ''),
|
||||
@@ -56,7 +56,8 @@ async def files_upload(params, ctx):
|
||||
storage_id=storage_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {'file_name': file_name, 'size': len(data), 'content_type': content_type}
|
||||
file_id = file_record.get('file_id') if isinstance(file_record, dict) else None
|
||||
return {'file_id': file_id, 'file_name': file_name, 'size': len(data), 'content_type': content_type}
|
||||
|
||||
@router.method('files.list')
|
||||
async def files_list(params, ctx):
|
||||
|
||||
Reference in New Issue
Block a user