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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user