benroshan Claude Sonnet 4.6 commited on
Commit
3a34cb6
·
1 Parent(s): 23ddd45

feat: add GET /api/files/{filename} route for citation PDF links

Browse files

Adds 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>

Files changed (2) hide show
  1. server/main.py +18 -1
  2. 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)