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:
1 parent
51ef07c874
commit
e81f2ab515
2 files changed
+271
-29
No files matched your search
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regressionstests für den Multipart-Parser im Router.
|
||||||
|
|
||||||
|
Testet quoted/unquoted Boundaries, Open-WebUI-artige Requests,
|
||||||
|
beliebige Feldreihenfolge und binäre Payloads.
|
||||||
|
|
||||||
|
Aufruf: python3 dev/test_multipart.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import compat32
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multipart(data: bytes, content_type: str) -> tuple[bytes, str, dict]:
|
||||||
|
"""Identische Logik wie router/ai_profile_router.py::_parse_multipart."""
|
||||||
|
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")
|
||||||
|
file_data = b""
|
||||||
|
filename = ""
|
||||||
|
fields = {}
|
||||||
|
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
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload is None:
|
||||||
|
payload = b""
|
||||||
|
if part_filename is not None:
|
||||||
|
file_data = payload
|
||||||
|
filename = part_filename or ""
|
||||||
|
else:
|
||||||
|
fields[name] = payload.decode("utf-8", errors="replace")
|
||||||
|
return file_data, filename, fields
|
||||||
|
|
||||||
|
|
||||||
|
def build(body_parts: list[tuple[str, str | bytes, str | None]],
|
||||||
|
boundary: str, quoted: bool = False) -> tuple[bytes, str]:
|
||||||
|
"""Baut einen Multipart-Body. body_parts: (name, value, filename)."""
|
||||||
|
b = f'"{boundary}"' if quoted else boundary
|
||||||
|
ct = f"multipart/form-data; boundary={b}"
|
||||||
|
parts = []
|
||||||
|
for name, value, filename in body_parts:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
header = (f'Content-Disposition: form-data; name="{name}"')
|
||||||
|
if filename:
|
||||||
|
header += f'; filename="{filename}"'
|
||||||
|
header += f'\r\nContent-Type: application/octet-stream\r\n\r\n'
|
||||||
|
parts.append(header.encode() + value + b"\r\n")
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
sep = f"--{boundary}\r\n".encode()
|
||||||
|
body = sep + sep.join(parts) + (f"--{boundary}--\r\n").encode()
|
||||||
|
return body, ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_boundary():
|
||||||
|
"""Quoted boundary (boundary=\"...\")."""
|
||||||
|
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
|
||||||
|
webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50
|
||||||
|
body, ct = build(
|
||||||
|
[("model", "whisper-1", None), ("file", webm, "t.webm")],
|
||||||
|
boundary, quoted=True,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == webm, "file_data mismatch"
|
||||||
|
assert fn == "t.webm", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
print(" quoted boundary: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unquoted_boundary():
|
||||||
|
"""Unquoted boundary (boundary=...)."""
|
||||||
|
boundary = "AaB03x"
|
||||||
|
body, ct = build(
|
||||||
|
[("file", b"RIFF", "a.wav"), ("language", "de", None)],
|
||||||
|
boundary, quoted=False,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == b"RIFF", "file_data mismatch"
|
||||||
|
assert fn == "a.wav", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["language"] == "de", f"language mismatch: {fl!r}"
|
||||||
|
print(" unquoted boundary: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_openwebui_style():
|
||||||
|
"""Open-WebUI-artiger Request (quoted, extra Header, File vor model)."""
|
||||||
|
boundary = "----formdata-boundary"
|
||||||
|
webm = b"\x1a\x45\xdf\xa3" + b"\xde\xad\xbe\xef" * 30
|
||||||
|
ct = f'multipart/form-data; boundary="{boundary}"'
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="rec.webm"\r\n'
|
||||||
|
f"Content-Type: audio/webm; codecs=opus\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
).encode() + webm + (
|
||||||
|
f"\r\n--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="model"\r\n'
|
||||||
|
f"\r\n"
|
||||||
|
f"whisper-1\r\n"
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="temperature"\r\n'
|
||||||
|
f"\r\n"
|
||||||
|
f"0.0\r\n"
|
||||||
|
f"--{boundary}--\r\n"
|
||||||
|
).encode()
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == webm, "file_data mismatch"
|
||||||
|
assert fn == "rec.webm", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}"
|
||||||
|
print(" Open-WebUI-artig: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_before_model():
|
||||||
|
"""File-Feld vor model-Feld."""
|
||||||
|
boundary = "boundary123"
|
||||||
|
body, ct = build(
|
||||||
|
[("file", b"DATA", "f.wav"), ("model", "whisper-1", None)],
|
||||||
|
boundary, quoted=False,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == b"DATA", "file_data mismatch"
|
||||||
|
assert fn == "f.wav", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
print(" File vor model: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_after_model():
|
||||||
|
"""model-Feld vor File-Feld."""
|
||||||
|
boundary = "boundary456"
|
||||||
|
body, ct = build(
|
||||||
|
[("model", "whisper-1", None), ("file", b"DATA", "g.wav")],
|
||||||
|
boundary, quoted=False,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == b"DATA", "file_data mismatch"
|
||||||
|
assert fn == "g.wav", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
print(" File nach model: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_webm_binary_payload():
|
||||||
|
"""WebM-Binärpayload (alle 256 Byte-Werte)."""
|
||||||
|
boundary = "boundary789"
|
||||||
|
webm = bytes(range(256)) * 10 # 2560 bytes
|
||||||
|
body, ct = build(
|
||||||
|
[("file", webm, "bin.webm")],
|
||||||
|
boundary, quoted=False,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == webm, f"file_data mismatch ({len(fd)} vs {len(webm)})"
|
||||||
|
assert fn == "bin.webm", f"filename mismatch: {fn!r}"
|
||||||
|
print(" WebM-Binärpayload: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_headers_ignored():
|
||||||
|
"""Zusätzliche Multipart-Header werden ignoriert."""
|
||||||
|
boundary = "boundaryExtra"
|
||||||
|
webm = b"\x1a\x45\xdf\xa3" + b"\x00" * 100
|
||||||
|
ct = f'multipart/form-data; boundary="{boundary}"'
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="x.webm"\r\n'
|
||||||
|
f"Content-Type: audio/webm\r\n"
|
||||||
|
f"X-Custom-Header: ignored\r\n"
|
||||||
|
f"Content-Transfer-Encoding: binary\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
).encode() + webm + (
|
||||||
|
f"\r\n--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="model"\r\n'
|
||||||
|
f"\r\n"
|
||||||
|
f"whisper-1\r\n"
|
||||||
|
f"--{boundary}--\r\n"
|
||||||
|
).encode()
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == webm, "file_data mismatch"
|
||||||
|
assert fn == "x.webm", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
print(" Extra-Header ignoriert: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_fields():
|
||||||
|
"""Alle unterstützten Felder (model, language, prompt, response_format, temperature)."""
|
||||||
|
boundary = "boundaryAll"
|
||||||
|
body, ct = build(
|
||||||
|
[
|
||||||
|
("file", b"AUDIO", "a.webm"),
|
||||||
|
("model", "whisper-1", None),
|
||||||
|
("language", "de", None),
|
||||||
|
("prompt", "Kontext", None),
|
||||||
|
("response_format", "verbose_json", None),
|
||||||
|
("temperature", "0.5", None),
|
||||||
|
],
|
||||||
|
boundary, quoted=True,
|
||||||
|
)
|
||||||
|
fd, fn, fl = parse_multipart(body, ct)
|
||||||
|
assert fd == b"AUDIO", "file_data mismatch"
|
||||||
|
assert fn == "a.webm", f"filename mismatch: {fn!r}"
|
||||||
|
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
|
||||||
|
assert fl["language"] == "de", f"language mismatch: {fl!r}"
|
||||||
|
assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}"
|
||||||
|
assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}"
|
||||||
|
assert fl["temperature"] == "0.5", f"temperature mismatch: {fl!r}"
|
||||||
|
print(" Alle Felder: OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Multipart-Parser Regressionstests:")
|
||||||
|
test_quoted_boundary()
|
||||||
|
test_unquoted_boundary()
|
||||||
|
test_openwebui_style()
|
||||||
|
test_file_before_model()
|
||||||
|
test_file_after_model()
|
||||||
|
test_webm_binary_payload()
|
||||||
|
test_extra_headers_ignored()
|
||||||
|
test_all_fields()
|
||||||
|
print("\nAlle Multipart-Parser-Tests bestanden!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+37
-29
@@ -45,6 +45,7 @@ Nur Python-Standardbibliothek. Logging nach stdout (journald).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import email
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -56,6 +57,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import http.client
|
import http.client
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import compat32
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1127,42 +1130,47 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _parse_multipart(self, data: bytes, content_type: str
|
def _parse_multipart(self, data: bytes, content_type: str
|
||||||
) -> tuple[bytes, str, dict]:
|
) -> tuple[bytes, str, dict]:
|
||||||
"""Parst multipart/form-data. Liefert (file_data, filename, fields)."""
|
"""Parst multipart/form-data. Liefert (file_data, filename, fields).
|
||||||
boundary = None
|
|
||||||
for part in content_type.split(";"):
|
Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
|
||||||
part = part.strip()
|
MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
|
||||||
if part.startswith("boundary="):
|
Feldreihenfolge, zusätzliche Header und binäre Payloads.
|
||||||
boundary = part[len("boundary="):]
|
"""
|
||||||
break
|
# MIME-Message aus rohen Bytes + Content-Type-Header bauen
|
||||||
if not boundary:
|
raw = (f"Content-Type: {content_type}\r\n\r\n"
|
||||||
raise ValueError("Kein Boundary in Content-Type")
|
).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""
|
file_data = b""
|
||||||
filename = ""
|
filename = ""
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
||||||
parts = data.split(b"--" + boundary_bytes)
|
for part in msg.get_payload():
|
||||||
for part in parts:
|
disposition = part.get("Content-Disposition", "")
|
||||||
if part in (b"", b"--", b"--\r\n", b"\r\n"):
|
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
|
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")
|
payload = part.get_payload(decode=True)
|
||||||
for line in header_text.split("\r\n"):
|
if payload is None:
|
||||||
if "name=" in line and "filename=" in line:
|
payload = b""
|
||||||
for kv in line.split(";"):
|
|
||||||
kv = kv.strip()
|
if part_filename is not None:
|
||||||
if kv.startswith("filename="):
|
# Dateifeld (binär, nicht dekodieren)
|
||||||
filename = kv[len("filename="):].strip('"')
|
file_data = payload
|
||||||
file_data = body_part
|
filename = part_filename or ""
|
||||||
elif "name=" in line:
|
else:
|
||||||
name = line.split("name=")[1].strip().strip('"')
|
# Textfeld
|
||||||
fields[name] = body_part.decode("utf-8", errors="replace")
|
fields[name] = payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
return file_data, filename, fields
|
return file_data, filename, fields
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user