Build and Push Docker Container / build-and-push (push) Successful in 2m37s
File cache: - Add my_modules/filecache with metadata, blob, validator and store layers - Cache convex.get_file via AsyncCache, TTL clamped to the file expiry - Skip caching for missing, incomplete or already expired records - Add a process-local, size-bounded LRU for immutable storage blobs - Build the cache stack from FILE_CACHE_* env vars in build_file_cache - Attach the cache to the app in my_modules/app/setup.py - Expose app-bound helpers in filecache/access.py for routes Serving: - Send strong ETag, Last-Modified and Cache-Control on served files - Answer If-None-Match and If-Modified-Since with 304 from metadata only - Avoid pulling the blob from Convex on a revalidation hit - Add a short Cloudflare-CDN-Cache-Control edge window for deleted files - Record cache_status in the wide event on a 304 - Invalidate cached metadata after file update and delete in both routes Text viewer: - Add my_modules/textview to render text/plain and text/markdown as HTML - Linkify http(s) URLs and trim trailing sentence punctuation - Resolve links pointing at this instance to the referenced file - Inline image previews, mark missing and expired targets - Cap resolution at MAX_LOOKUPS backend lookups per document - Serve the viewer only for real browser navigations, never for curl or CLI - Keep raw bytes for ?raw=1, downloads and Accept headers without text/html - Add templates/side/views/webpage/files/text_view.htm with a raw link Other: - Add tests for cache layers, serving, text rendering and the route - Bump multidict to 7.2.1 in uv.lock - Update the my_helpers submodule pointer
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
'''ServiceLink mesh endpoint for the picoshare/NanoShare node.
|
|
|
|
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
|
|
UI and /api routes.
|
|
|
|
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.
|
|
'''
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
|
|
from pathlib import PurePosixPath
|
|
|
|
from quart import current_app
|
|
|
|
from my_modules.app.setup import LIMITER
|
|
from my_modules.expiry import ensure_utc, parse_expires
|
|
from my_modules.file_meta import format_size, iso_stamp_filename
|
|
from my_modules.filecache.access import drop_file_cache
|
|
from servicelink import Forbidden, InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
|
|
|
|
MAX_RPC_BODY = 16 * 1024 * 1024
|
|
RPC_SCOPES = ('files', 'mesh')
|
|
|
|
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):
|
|
if ctx.principal is None:
|
|
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
|
|
|
|
@router.method('files.upload')
|
|
async def files_upload(params, ctx):
|
|
user_id = _user_id(ctx)
|
|
text = params.get('text')
|
|
content_b64 = params.get('content_b64')
|
|
if content_b64:
|
|
data = base64.b64decode(content_b64)
|
|
content_type = params.get('content_type') or 'application/octet-stream'
|
|
default_ext = 'bin'
|
|
elif text is not None:
|
|
data = text.encode('utf-8')
|
|
content_type = 'text/plain'
|
|
default_ext = 'txt'
|
|
else:
|
|
raise InvalidParams('provide text or content_b64')
|
|
|
|
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)
|
|
file_record = await current_app.convex.add_file(
|
|
file_name=file_name,
|
|
file_path=file_path,
|
|
file_size=format_size(len(data)),
|
|
note=params.get('note', ''),
|
|
content_type=content_type,
|
|
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
|
storage_id=storage_id,
|
|
user_id=user_id,
|
|
)
|
|
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')
|
|
async def files_list(params, ctx):
|
|
return {'files': await current_app.convex.get_files(user_id=_user_id(ctx))}
|
|
|
|
@router.method('files.get')
|
|
async def files_get(params, ctx):
|
|
file_id = params.get('file_id')
|
|
if not file_id:
|
|
raise InvalidParams('file_id is required')
|
|
meta = await current_app.convex.get_file(file_id)
|
|
if not meta:
|
|
raise NotFound('no such file', data={'file_id': file_id})
|
|
return meta
|
|
|
|
@router.method('files.info')
|
|
async def files_info(params, ctx):
|
|
file_id = params.get('file_id')
|
|
if not file_id:
|
|
raise InvalidParams('file_id is required')
|
|
return await current_app.convex.get_file_informations(file_id, _user_id(ctx))
|
|
|
|
@router.method('files.update')
|
|
async def files_update(params, ctx):
|
|
file_id = params.get('file_id')
|
|
file_name = PurePosixPath(str(params.get('file_name') or '').replace('\\', '/')).name
|
|
if not file_id or not file_name:
|
|
raise InvalidParams('file_id and file_name are required')
|
|
await current_app.convex.update_file(
|
|
file_id=file_id,
|
|
file_name=file_name,
|
|
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', ''))),
|
|
user_id=_user_id(ctx),
|
|
preserve_missing=True,
|
|
)
|
|
await drop_file_cache(file_id)
|
|
return {'updated': True}
|
|
|
|
@router.method('files.delete')
|
|
async def files_delete(params, ctx):
|
|
file_id = params.get('file_id')
|
|
if not file_id:
|
|
raise InvalidParams('file_id is required')
|
|
await current_app.convex.delete_file(file_id, _user_id(ctx))
|
|
await drop_file_cache(file_id)
|
|
return {'deleted': True}
|
|
|
|
async def _decode_access_token(token):
|
|
payload = await current_app.convex.decode_access_token_payload(access_token=token)
|
|
if not payload or payload.get('error') or not payload.get('sub'):
|
|
raise ValueError((payload or {}).get('error', 'invalid token'))
|
|
return payload
|
|
|
|
def _build_verify():
|
|
# 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.
|
|
jwt = bearer_verifier(_decode_access_token)
|
|
secret = os.getenv('SERVICELINK_MESH_SECRET')
|
|
return any_verifier(shared_secret_verifier(secret, scopes=('mesh',)), jwt) if secret else jwt
|
|
|
|
verify = _build_verify()
|
|
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
|