Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- **PR #2382** by @Michaelyklam (closes #2380) — `api/file/raw` now falls back to the requesting session's attachment inbox when a chat-upload filename is not present in the workspace. This keeps existing `api/file/raw?session_id=...&path=<filename>` image URLs working after uploads moved under `~/.hermes/webui/attachments/<session_id>/`, while preserving traversal protection and cross-session isolation.

## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)

### Added
Expand Down
28 changes: 26 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sprint2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion tests/test_sprint6.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down
Loading