diff --git a/my_modules/upload_content.py b/my_modules/upload_content.py new file mode 100644 index 0000000..4df575f --- /dev/null +++ b/my_modules/upload_content.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from io import BytesIO + +from quart.datastructures import FileStorage +from werkzeug.datastructures import Headers +from werkzeug.http import parse_options_header +from werkzeug.sansio.multipart import Data, Epilogue, Field, File, NeedData, MultipartDecoder + +@dataclass +class MultipartUploadParts: + fields: dict[str, str] = field(default_factory=dict) + file: FileStorage | None = None + +def _content_type(headers: Headers) -> str: + return headers.get('content-type') or 'application/octet-stream' + +def _fallback_filename(headers: Headers, default: str = 'file') -> str: + disposition = headers.get('content-disposition', '') + _, options = parse_options_header(disposition) + filename = options.get('filename') + return filename if filename is not None else default + +def parse_multipart_upload_body(body: bytes, boundary: str | bytes | None, file_field_name: str = 'file') -> MultipartUploadParts: + parts = MultipartUploadParts() + if not body or not boundary: + return parts + + boundary_bytes = boundary.encode() if isinstance(boundary, str) else boundary + parser = MultipartDecoder(boundary_bytes) + parser.receive_data(body) + parser.receive_data(None) + + current_part = None + current_chunks: list[bytes] = [] + + while True: + event = parser.next_event() + if isinstance(event, (Epilogue, NeedData)): + break + if isinstance(event, (Field, File)): + current_part = event + current_chunks = [] + continue + if not isinstance(event, Data) or current_part is None: + continue + + current_chunks.append(event.data) + if event.more_data: + continue + + data = b''.join(current_chunks) + if current_part.name == file_field_name and data: + headers = current_part.headers + parts.file = FileStorage( + stream=BytesIO(data), + filename=getattr(current_part, 'filename', None) or _fallback_filename(headers), + name=file_field_name, + content_type=_content_type(headers), + headers=headers, + ) + elif isinstance(current_part, Field): + _, options = parse_options_header(current_part.headers.get('content-type', '')) + charset = options.get('charset') or 'utf-8' + parts.fields[current_part.name] = data.decode(charset, 'replace') + + return parts + +def recover_file_from_multipart_body(body: bytes, boundary: str | bytes | None, field_name: str = 'file') -> FileStorage | None: + return parse_multipart_upload_body(body, boundary, field_name).file diff --git a/pyproject.toml b/pyproject.toml index b1f5416..842839d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanoshare" -version = "1.27.2" +version = "1.27.3" description = "Add your description here" readme = "README.md" requires-python = ">=3.14" diff --git a/routes/side/upload.py b/routes/side/upload.py index 840f10c..3bb46a0 100644 --- a/routes/side/upload.py +++ b/routes/side/upload.py @@ -1,6 +1,7 @@ from my_modules.decoratory.header import login_required from my_modules.expiry import parse_expires, ensure_utc from my_modules.file_meta import iso_stamp_filename, format_size +from my_modules.upload_content import parse_multipart_upload_body from quart_common.web.wide_event import add_wide_event_context from quart import Blueprint, request, jsonify, current_app @@ -75,9 +76,25 @@ async def api_upload(user): orphan_registry = getattr(current_app, 'orphan_storage_registry', None) uploaded = files.get('file') + recovered_upload = False + recovered_fields = {} + if uploaded is None and request.mimetype == 'multipart/form-data': + recovered_parts = parse_multipart_upload_body( + await request.get_data(cache=True), + request.mimetype_params.get('boundary'), + ) + uploaded = recovered_parts.file + recovered_fields = recovered_parts.fields + recovered_upload = uploaded is not None + + if recovered_fields: + note = recovered_fields.get('note', note) + expires_raw = recovered_fields.get('expires', expires_raw) + text = recovered_fields.get('text', text) + has_uploaded_file = uploaded is not None - add_wide_event_context(nanoshare={"operation": "upload", "has_file": has_uploaded_file, "has_text": bool(text.strip())}) + add_wide_event_context(nanoshare={"operation": "upload", "has_file": has_uploaded_file, "has_text": bool(text.strip()), "recovered_file": recovered_upload, "recovered_field_count": len(recovered_fields)}) expires_at_dt = ensure_utc(parse_expires(expires_raw)) diff --git a/tests/test_upload_content.py b/tests/test_upload_content.py new file mode 100644 index 0000000..7b900f9 --- /dev/null +++ b/tests/test_upload_content.py @@ -0,0 +1,28 @@ +from my_modules.upload_content import parse_multipart_upload_body, recover_file_from_multipart_body + +def test_recovers_file_part_without_filename(): + boundary = '----nanoshare-test-boundary' + body = ( + f'--{boundary}\r\n' + 'Content-Disposition: form-data; name="file"\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n' + ).encode() + b'hello upload' + ( + '\r\n' + f'--{boundary}\r\n' + 'Content-Disposition: form-data; name="expires"\r\n' + '\r\n' + '7d\r\n' + f'--{boundary}--\r\n' + ).encode() + + uploaded = recover_file_from_multipart_body(body, boundary) + parts = parse_multipart_upload_body(body, boundary) + + assert uploaded is not None + assert uploaded.filename == 'file' + assert uploaded.mimetype == 'application/octet-stream' + assert uploaded.stream.read() == b'hello upload' + assert parts.fields == {'expires': '7d'} + assert parts.file is not None + assert parts.file.stream.read() == b'hello upload' diff --git a/tests/test_upload_route.py b/tests/test_upload_route.py index 4d5dceb..2b9b026 100644 --- a/tests/test_upload_route.py +++ b/tests/test_upload_route.py @@ -40,9 +40,15 @@ class AwaitableValue: return get_value().__await__() class FakeRequest: - def __init__(self, form, files): + def __init__(self, form, files, body=b'', boundary=None): self.form = AwaitableValue(form) self.files = AwaitableValue(files) + self.mimetype = 'multipart/form-data' if boundary else '' + self.mimetype_params = {'boundary': boundary} if boundary else {} + self._body = body + + async def get_data(self, cache=True): + return self._body class FakeConvex: def __init__(self): @@ -91,5 +97,50 @@ def test_upload_accepts_file_field_without_filename(monkeypatch): assert await response.get_json() == {'ok': True} assert app.convex.sent == [(b'hello', 'application/octet-stream')] assert app.convex.files[0]['file_name'].endswith('.bin') + assert app.convex.files[0]['expires_at'] is not None + + asyncio.run(run_test()) + +def test_upload_recovers_file_field_from_multipart_body(monkeypatch): + async def run_test(): + upload = load_upload_module(monkeypatch) + + app = Quart(__name__) + app.secret_key = 'test-secret' + app.convex = FakeConvex() + app.orphan_storage_registry = None + + boundary = '----nanoshare-test-boundary' + body = ( + f'--{boundary}\r\n' + 'Content-Disposition: form-data; name="file"\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n' + ).encode() + b'hello recovered' + ( + '\r\n' + f'--{boundary}\r\n' + 'Content-Disposition: form-data; name="expires"\r\n' + '\r\n' + '7d\r\n' + f'--{boundary}--\r\n' + ).encode() + + async with app.test_request_context('/api/upload', method='POST'): + session['user'] = {'sub': 'user_1'} + fake_request = FakeRequest( + form={'expires': '7d', 'note': 'test', 'text': ''}, + files={}, + body=body, + boundary=boundary, + ) + monkeypatch.setattr(upload, 'request', fake_request) + + response = await upload.api_upload() + + assert await response.get_json() == {'ok': True} + assert app.convex.sent == [(b'hello recovered', 'application/octet-stream')] + assert app.convex.files[0]['file_name'].endswith('.bin') + assert app.convex.files[0]['expires_at'] is not None + assert app.convex.files[0]['note'] == 'test' asyncio.run(run_test()) diff --git a/uv.lock b/uv.lock index 0ddd418..810adab 100644 --- a/uv.lock +++ b/uv.lock @@ -636,7 +636,7 @@ wheels = [ [[package]] name = "nanoshare" -version = "1.27.2" +version = "1.27.3" source = { virtual = "." } dependencies = [ { name = "aiohttp" },