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:
+37
-29
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user