fix(upload): preserve fields in multipart fallback
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:
2026-09-10 00:18:57 +02:00
parent 21aca1b9d7
commit 3194e534e0
6 changed files with 171 additions and 4 deletions
+71
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanoshare" name = "nanoshare"
version = "1.27.2" version = "1.27.3"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
+18 -1
View File
@@ -1,6 +1,7 @@
from my_modules.decoratory.header import login_required from my_modules.decoratory.header import login_required
from my_modules.expiry import parse_expires, ensure_utc from my_modules.expiry import parse_expires, ensure_utc
from my_modules.file_meta import iso_stamp_filename, format_size 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_common.web.wide_event import add_wide_event_context
from quart import Blueprint, request, jsonify, current_app 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) orphan_registry = getattr(current_app, 'orphan_storage_registry', None)
uploaded = files.get('file') 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 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)) expires_at_dt = ensure_utc(parse_expires(expires_raw))
+28
View File
@@ -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'
+52 -1
View File
@@ -40,9 +40,15 @@ class AwaitableValue:
return get_value().__await__() return get_value().__await__()
class FakeRequest: class FakeRequest:
def __init__(self, form, files): def __init__(self, form, files, body=b'', boundary=None):
self.form = AwaitableValue(form) self.form = AwaitableValue(form)
self.files = AwaitableValue(files) 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: class FakeConvex:
def __init__(self): def __init__(self):
@@ -91,5 +97,50 @@ def test_upload_accepts_file_field_without_filename(monkeypatch):
assert await response.get_json() == {'ok': True} assert await response.get_json() == {'ok': True}
assert app.convex.sent == [(b'hello', 'application/octet-stream')] assert app.convex.sent == [(b'hello', 'application/octet-stream')]
assert app.convex.files[0]['file_name'].endswith('.bin') 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()) asyncio.run(run_test())
Generated
+1 -1
View File
@@ -636,7 +636,7 @@ wheels = [
[[package]] [[package]]
name = "nanoshare" name = "nanoshare"
version = "1.27.2" version = "1.27.3"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },