Stage 368: PR #2382 — fix: serve raw chat attachments from inbox by @Michaelyklam

This commit is contained in:
Hermes Agent committed 2026-05-16 17:19:06 +00:00
commit 57199f16ec
3 files changed
+59 -4

No files matched your search

+26 -2
View File
@@ -6360,10 +6360,34 @@ def _handle_media(handler, parsed):
or html_inline_ok
)
) else "attachment"
# _serve_file_bytes sends Content-Security-Policy when csp is set.
csp = "sandbox allow-scripts" if html_inline_ok else None
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp)
def _file_raw_target(session, sid: str, rel: str) -> Path | None:
"""Resolve /api/file/raw paths from the workspace or this session's uploads."""
try:
target = safe_resolve(Path(session.workspace), rel)
except ValueError:
target = None
if target and target.exists() and target.is_file():
return target
# Chat uploads now live in a per-session attachment inbox outside the
# workspace. Keep the public URL stable while scoping fallback lookup to
# the requesting session's own attachment directory.
try:
from api.upload import _session_attachment_dir
attachment_target = safe_resolve(_session_attachment_dir(sid), rel)
except Exception:
return None
if attachment_target.exists() and attachment_target.is_file():
return attachment_target
return None
def _handle_file_raw(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get("session_id", [""])[0]
@@ -6375,8 +6399,8 @@ def _handle_file_raw(handler, parsed):
return bad(handler, "Session not found", 404)
rel = qs.get("path", [""])[0]
force_download = qs.get("download", [""])[0] == "1"
target = safe_resolve(Path(s.workspace), rel)
if not target.exists() or not target.is_file():
target = _file_raw_target(s, sid, rel)
if target is None:
return j(handler, {"error": "not found"}, status=404)
ext = target.suffix.lower()
mime = MIME_MAP.get(ext, "application/octet-stream")
+1 -1
View File
@@ -67,7 +67,7 @@ def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions):
get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd")
assert False
except urllib.error.HTTPError as e:
assert e.code in (400, 500)
assert e.code in (400, 404, 500)
def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
+32 -1
View File
@@ -1,5 +1,5 @@
"""Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export."""
import json, uuid, pathlib, urllib.request, urllib.error
import json, uuid, pathlib, urllib.parse, urllib.request, urllib.error
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
from tests._pytest_port import BASE
@@ -74,6 +74,37 @@ def test_file_raw_unknown_session():
except urllib.error.HTTPError as e:
assert e.code == 404
def test_file_raw_serves_session_attachment_inbox(cleanup_test_sessions):
from api.upload import _session_attachment_dir
sid, workspace = make_session_tracked(cleanup_test_sessions)
filename = f"uploaded-chat-image-{uuid.uuid4().hex}.png"
attachment_dir = _session_attachment_dir(sid)
attachment_dir.mkdir(parents=True, exist_ok=True)
payload = b"fake-png-bytes"
(attachment_dir / filename).write_bytes(payload)
assert not (workspace / filename).exists(), "regression must exercise attachment fallback"
raw, headers, status = get_raw(
f"/api/file/raw?session_id={sid}&path={urllib.parse.quote(filename)}"
)
assert status == 200
assert raw == payload
assert "image/png" in headers.get("Content-Type", "")
def test_file_raw_attachment_fallback_rejects_traversal(cleanup_test_sessions):
from api.upload import _session_attachment_dir
sid, _ = make_session_tracked(cleanup_test_sessions)
attachment_dir = _session_attachment_dir(sid)
attachment_dir.mkdir(parents=True, exist_ok=True)
(attachment_dir / "safe.txt").write_text("safe", encoding="utf-8")
try:
get_raw(f"/api/file/raw?session_id={sid}&path={urllib.parse.quote('../../safe.txt')}")
assert False, "Expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404
# ── Cron create ──
def test_cron_create_requires_prompt():