fix(upload): preserve fields in multipart fallback
Build and Push Docker Container / build-and-push (push) Successful in 1m40s
Build and Push Docker Container / build-and-push (push) Successful in 1m40s
- Recover regular multipart fields when the file part is not exposed through request.files. - Preserve expires and note values for browser uploads that need body-based file recovery. - Add parser coverage for file parts without filenames and recovered expiry fields. - Extend upload route tests to assert recovered uploads keep expiry metadata. - Bump NanoShare to 1.27.3.
This commit is contained in:
@@ -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
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+18
-1
@@ -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))
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user