feat(webui): support document attachments with ingress safeguards (#4771)

* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

* refactor(webui): move attachment policy out of channel

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
This commit is contained in:
chengyongru
2026-07-14 14:47:42 +08:00
committed by GitHub
parent b2759e8a6b
commit b7048cf76a
36 changed files with 1398 additions and 332 deletions
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from nanobot.webui.attachment_ingress import (
extract_data_url_mime,
store_inbound_attachments,
)
from nanobot.webui.ingress_policy import AttachmentIngressLimits
def _data_url(mime: str, payload: bytes) -> str:
encoded = base64.b64encode(payload).decode()
return f"data:{mime};base64,{encoded}"
@pytest.mark.parametrize(
("url", "expected"),
[
("data:image/png;base64,AAAA", "image/png"),
("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"),
("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"),
("data:text/plain;base64,AAAA", "text/plain"),
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
("data:image/png,AAAA", None),
("data:;base64,AAAA", None),
("https://example.invalid/image.png", None),
("", None),
(None, None),
],
)
def test_extract_data_url_mime_normalizes_only_base64_data_urls(
url: Any,
expected: str | None,
) -> None:
assert extract_data_url_mime(url) == expected
def test_store_inbound_document_preserves_safe_name(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{
"data_url": _data_url("text/csv", b"name,value\nnanobot,1"),
"name": "report.csv",
},
],
media_dir=tmp_path,
logger=MagicMock(),
)
assert rejection is None
assert len(paths) == 1
saved = Path(paths[0])
assert saved.parent == tmp_path
assert saved.name.endswith("_report.csv")
assert saved.read_bytes() == b"name,value\nnanobot,1"
def test_invalid_batch_removes_files_already_persisted(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{"data_url": _data_url("image/png", b"valid-first-item")},
{"data_url": _data_url("image/svg+xml", b"<svg/>")},
],
media_dir=tmp_path,
logger=MagicMock(),
)
assert paths == []
assert rejection == "mime"
assert list(tmp_path.iterdir()) == []
def test_invalid_base64_cannot_create_an_empty_attachment(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[{"data_url": "data:text/plain;base64,@@@@", "name": "empty.txt"}],
media_dir=tmp_path,
logger=MagicMock(),
)
assert paths == []
assert rejection == "decode"
assert list(tmp_path.iterdir()) == []
def test_single_file_limit_is_attachment_policy_not_transport(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[{"data_url": _data_url("text/plain", b"12345"), "name": "large.txt"}],
media_dir=tmp_path,
logger=MagicMock(),
limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=20),
)
assert paths == []
assert rejection == "size"
assert list(tmp_path.iterdir()) == []
def test_total_attachment_policy_rolls_back_the_batch(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{"data_url": _data_url("text/plain", b"1234"), "name": "one.txt"},
{"data_url": _data_url("text/plain", b"5678"), "name": "two.txt"},
],
media_dir=tmp_path,
logger=MagicMock(),
limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=6),
)
assert paths == []
assert rejection == "total_size"
assert list(tmp_path.iterdir()) == []
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from nanobot.webui.ingress_policy import WebUIIngressPolicy
def test_text_limit_counts_utf8_bytes() -> None:
policy = WebUIIngressPolicy()
assert policy.validate_text("x" * policy.message.max_text_bytes) is None
assert policy.validate_text("" * 22_000) == "text_too_large"
def test_bootstrap_keeps_transport_and_business_limits_separate() -> None:
policy = WebUIIngressPolicy()
payload = policy.bootstrap_limits(max_frame_bytes=1_048_576)
assert payload["transport"] == {
"max_frame_bytes": 1_048_576,
"envelope_reserve_bytes": 65_536,
}
assert payload["message"] == {"max_text_bytes": 65_536}
assert payload["attachments"] == {
"max_count": 4,
"max_file_bytes": 6_291_456,
"max_total_bytes": 25_165_824,
}
assert policy.minimum_full_policy_frame_bytes() < 36 * 1024 * 1024