router: Multipart-Parser auf email.parser.BytesParser umgestellt

- Robustes MIME-Parsing mit Standardbibliothek (email.parser.BytesParser)
- Handhabt quoted und unquoted Boundaries (Open-WebUI-Kompatibilität)
- Beliebiges Feldreihenfolge, zusätzliche Header, binäre Payloads
- Regressionstests: dev/test_multipart.py (8 Tests)
- Fix für Open-WebUI HTTP 400 bei POST /v1/audio/transcriptions
This commit is contained in:
Mikei386
2026-08-19 14:37:24 +02:00
parent 51ef07c874
commit e81f2ab515
2 changed files with 271 additions and 29 deletions
+37 -29
View File
@@ -45,6 +45,7 @@ Nur Python-Standardbibliothek. Logging nach stdout (journald).
from __future__ import annotations
import base64
import email
import json
import logging
import os
@@ -56,6 +57,8 @@ import threading
import time
import uuid
import http.client
from email.parser import BytesParser
from email.policy import compat32
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ---------------------------------------------------------------------------
@@ -1127,42 +1130,47 @@ class Handler(BaseHTTPRequestHandler):
def _parse_multipart(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]:
"""Parst multipart/form-data. Liefert (file_data, filename, fields)."""
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part[len("boundary="):]
break
if not boundary:
raise ValueError("Kein Boundary in Content-Type")
"""Parst multipart/form-data. Liefert (file_data, filename, fields).
Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
Feldreihenfolge, zusätzliche Header und binäre Payloads.
"""
# MIME-Message aus rohen Bytes + Content-Type-Header bauen
raw = (f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8") + data
msg = BytesParser(policy=compat32).parsebytes(raw)
if not msg.is_multipart():
raise ValueError("Kein multipart/form-data")
boundary_bytes = boundary.encode("utf-8")
file_data = b""
filename = ""
fields = {}
parts = data.split(b"--" + boundary_bytes)
for part in parts:
if part in (b"", b"--", b"--\r\n", b"\r\n"):
for part in msg.get_payload():
disposition = part.get("Content-Disposition", "")
name = None
part_filename = None
for kv in disposition.split(";"):
kv = kv.strip()
if kv.startswith("name="):
name = kv[len("name="):].strip('"')
elif kv.startswith("filename="):
part_filename = kv[len("filename="):].strip('"')
if name is None:
continue
if b"\r\n\r\n" not in part:
continue
header_part, body_part = part.split(b"\r\n\r\n", 1)
if body_part.endswith(b"\r\n"):
body_part = body_part[:-2]
header_text = header_part.decode("utf-8", errors="replace")
for line in header_text.split("\r\n"):
if "name=" in line and "filename=" in line:
for kv in line.split(";"):
kv = kv.strip()
if kv.startswith("filename="):
filename = kv[len("filename="):].strip('"')
file_data = body_part
elif "name=" in line:
name = line.split("name=")[1].strip().strip('"')
fields[name] = body_part.decode("utf-8", errors="replace")
payload = part.get_payload(decode=True)
if payload is None:
payload = b""
if part_filename is not None:
# Dateifeld (binär, nicht dekodieren)
file_data = payload
filename = part_filename or ""
else:
# Textfeld
fields[name] = payload.decode("utf-8", errors="replace")
return file_data, filename, fields