feat: add GET /api/files/{filename} route for citation PDF links
Browse filesAdds a file-serving endpoint that returns uploaded PDFs from data/raw/
with path traversal protection (400) and 404 for missing files.
UPLOAD_DIR defined as a module-level constant for test monkeypatching.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- server/main.py +18 -1
- tests/test_file_serving.py +45 -0
server/main.py
CHANGED
|
@@ -2,8 +2,9 @@ from contextlib import asynccontextmanager
|
|
| 2 |
import time
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from fastapi import FastAPI, Request, Response
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
|
| 9 |
from server.retriever import get_retriever, get_vectorstore, has_documents
|
|
@@ -13,6 +14,9 @@ from server.memory import create_memory
|
|
| 13 |
from server.chain import build_qa_chain
|
| 14 |
from server.utils import configure_logging, setup_logger, log_memory_mb
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
configure_logging()
|
| 17 |
logger = setup_logger(__name__)
|
| 18 |
|
|
@@ -82,6 +86,19 @@ app.include_router(workspaces.router, prefix="/api")
|
|
| 82 |
async def health():
|
| 83 |
return {"status": "ok", "version": "3.0.0"}
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# Serve React frontend build if it exists
|
| 86 |
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 87 |
if frontend_dist.exists():
|
|
|
|
| 2 |
import time
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Request, Response
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.responses import FileResponse
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
|
| 10 |
from server.retriever import get_retriever, get_vectorstore, has_documents
|
|
|
|
| 14 |
from server.chain import build_qa_chain
|
| 15 |
from server.utils import configure_logging, setup_logger, log_memory_mb
|
| 16 |
|
| 17 |
+
# Module-level constant so tests can monkeypatch it
|
| 18 |
+
UPLOAD_DIR = Path("data/raw")
|
| 19 |
+
|
| 20 |
configure_logging()
|
| 21 |
logger = setup_logger(__name__)
|
| 22 |
|
|
|
|
| 86 |
async def health():
|
| 87 |
return {"status": "ok", "version": "3.0.0"}
|
| 88 |
|
| 89 |
+
|
| 90 |
+
@app.get("/api/files/{filename}")
|
| 91 |
+
async def serve_file(filename: str) -> FileResponse:
|
| 92 |
+
"""Serve uploaded files from data/raw/ for citation PDF links."""
|
| 93 |
+
upload_root = UPLOAD_DIR.resolve()
|
| 94 |
+
target = (UPLOAD_DIR / filename).resolve()
|
| 95 |
+
if not str(target).startswith(str(upload_root)):
|
| 96 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 97 |
+
if not target.exists():
|
| 98 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 99 |
+
return FileResponse(str(target))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
# Serve React frontend build if it exists
|
| 103 |
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 104 |
if frontend_dist.exists():
|
tests/test_file_serving.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_serve_existing_file(tmp_path, monkeypatch):
|
| 7 |
+
"""Returns 200 + file content for a file that exists."""
|
| 8 |
+
fake_raw = tmp_path / "raw"
|
| 9 |
+
fake_raw.mkdir()
|
| 10 |
+
pdf = fake_raw / "report.pdf"
|
| 11 |
+
pdf.write_bytes(b"%PDF-1.4 fake")
|
| 12 |
+
|
| 13 |
+
monkeypatch.setattr("server.main.UPLOAD_DIR", fake_raw)
|
| 14 |
+
|
| 15 |
+
from server.main import app
|
| 16 |
+
client = TestClient(app)
|
| 17 |
+
resp = client.get("/api/files/report.pdf")
|
| 18 |
+
assert resp.status_code == 200
|
| 19 |
+
assert resp.content == b"%PDF-1.4 fake"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_serve_missing_file(tmp_path, monkeypatch):
|
| 23 |
+
"""Returns 404 for a filename that doesn't exist."""
|
| 24 |
+
fake_raw = tmp_path / "raw"
|
| 25 |
+
fake_raw.mkdir()
|
| 26 |
+
|
| 27 |
+
monkeypatch.setattr("server.main.UPLOAD_DIR", fake_raw)
|
| 28 |
+
|
| 29 |
+
from server.main import app
|
| 30 |
+
client = TestClient(app)
|
| 31 |
+
resp = client.get("/api/files/does_not_exist.pdf")
|
| 32 |
+
assert resp.status_code == 404
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_serve_blocks_path_traversal(tmp_path, monkeypatch):
|
| 36 |
+
"""Returns 400 for path traversal attempts."""
|
| 37 |
+
fake_raw = tmp_path / "raw"
|
| 38 |
+
fake_raw.mkdir()
|
| 39 |
+
|
| 40 |
+
monkeypatch.setattr("server.main.UPLOAD_DIR", fake_raw)
|
| 41 |
+
|
| 42 |
+
from server.main import app
|
| 43 |
+
client = TestClient(app)
|
| 44 |
+
resp = client.get("/api/files/..%2F..%2Fetc%2Fpasswd")
|
| 45 |
+
assert resp.status_code in (400, 404)
|