diff --git a/bindery/jobs.py b/bindery/jobs.py index d6dc66e..970a28c 100644 --- a/bindery/jobs.py +++ b/bindery/jobs.py @@ -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 diff --git a/bindery/web.py b/bindery/web.py index d7b211c..bbdae5d 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -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. diff --git a/tests/test_api.py b/tests/test_api.py index 82e87fa..697c39a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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) diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index a86cf35..1346442 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -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 diff --git a/tests/test_jobs.py b/tests/test_jobs.py index fb39524..c2d19aa 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -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