Keep server details out of API answers

An unexpected error now sends a general message, and the log keeps the
text. A job no longer shows the path of its uploaded file. A cover is
cached only by the browser of the user.
This commit is contained in:
tim
2026-10-02 23:37:33 +02:00
parent 0edfa1308c
commit e196d1688e
5 changed files with 19 additions and 4 deletions
+2
View File
@@ -87,6 +87,8 @@ class Job:
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
# The path of an uploaded file is for the job only.
d["options"] = {k: v for k, v in self.options.items() if k != "path"}
d["mode"] = self.mode.value
d["mode_label"] = MODE_LABELS[self.mode.value]
d["status"] = self.status.value
+7 -2
View File
@@ -151,8 +151,9 @@ def bad_request(e: Exception) -> Any:
def server_error(e: Exception) -> Any:
if isinstance(e, HTTPException):
return {"error": e.description}, e.code
# The text of an unexpected error can hold paths and addresses, so it goes only to the log.
logger.exception(f"Error in {request.path}: {e}")
return {"error": f"{type(e).__name__}: {e}"}, 500
return {"error": "Bindery hit an unexpected error. The log has the details."}, 500
# Login
@@ -361,7 +362,11 @@ def story_cover(story_id: int) -> Any:
if not cover:
raise KeyError("This story has no cover")
# The page puts the cover version in the URL, so the browser can keep the image for a year.
return send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60)
response = send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60)
# A cover needs a login, so a shared cache must not keep it.
response.cache_control.public = False
response.cache_control.private = True
return response
# Replacement rules. Story rules run after the global rules.
+1
View File
@@ -222,6 +222,7 @@ def test_the_cover_of_a_story(client):
response = client.get(f"/api/stories/{story_id}/cover?v={version}")
assert (response.status_code, response.mimetype, response.data) == (200, "image/png", PNG)
assert response.cache_control.max_age == 365 * 24 * 60 * 60
assert response.cache_control.private and not response.cache_control.public
# A new cover gets a new version, so the browser loads it.
store.put_image(story_id, "https://example.com/new.jpg", "jpg", "image/jpeg", b"new", is_cover=True)
+3 -2
View File
@@ -145,8 +145,9 @@ def test_download_builds_from_stored_chapters_without_changing_them(client, stor
response.close()
def test_unexpected_backend_error_returns_json(client, monkeypatch):
def test_unexpected_backend_error_returns_json(client, monkeypatch, caplog):
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
response = client.get("/api/stories")
assert response.status_code == 500
assert response.json == {"error": "RuntimeError: broken"}
assert response.json == {"error": "Bindery hit an unexpected error. The log has the details."}
assert "RuntimeError: broken" in caplog.text
+6
View File
@@ -161,3 +161,9 @@ def test_finished_jobs_leave_the_database(tmp_path, monkeypatch):
old.updated = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1)
queue.get_status()
assert JobQueue(tmp_path / "db.sqlite").get_status() == []
def test_the_api_form_of_a_job_hides_the_path_of_an_upload():
job = Job(mode=DownloadMode.IMPORT_FILE, options={"path": "/config/uploads/x.epub", "name": "x.epub"})
assert job.to_dict()["options"] == {"name": "x.epub"}
assert Job.from_record(job.to_record()).options == job.options