From f6f004c6898de8bd7717636207b7411c1af52921 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 15:20:37 +0100 Subject: [PATCH] v0.3.2: add GET /api/artifacts/{id}/raw for content read-back Metadata (GET /api/artifacts/{id}) stays metadata-only; the full content now has its own endpoint so an agent can verify exactly what it posted. - New ArtifactRawView at /api/artifacts/{id}/raw: bearer-or-session auth, owner-scoped. Serves exact source text (markdown/html_full/json/csv), file bytes (image/pdf/binary), or 404 for render-only kinds (html_fragment/deck). Reuses raw_artifact_path/safe_artifact_path. - serialize_artifact exposes api_raw_url; /api/capabilities advertises the route. (The old /a/{id}?raw= viewer stays session-only for browsers.) - Docs + tests (exact content, owner-scoping, 403 unauth, 404 render-only). Suite: 136 passing; verified live. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++++++++++- artifacts/urls.py | 1 + artifacts/views.py | 33 ++++++++++++++++++++++++++++++ daybook_project/__init__.py | 2 +- docs/api.md | 28 ++++++++++++++------------ tests/api/test_artifacts.py | 40 +++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b5f60..26f8719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [0.3.2] — 2026-09-04 + +### Added +- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the + metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an + agent can read back and verify exactly what it posted: exact source text for + `markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`, + `404` for render-only `html_fragment`/`deck`. The metadata response now + includes `api_raw_url`, and `/api/capabilities` advertises the route. + ## [0.3.1] — 2026-09-04 ### Changed @@ -128,7 +138,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...HEAD +[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 diff --git a/artifacts/urls.py b/artifacts/urls.py index 1cbd657..008ceb5 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), + path("api/artifacts//raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), diff --git a/artifacts/views.py b/artifacts/views.py index 99f326b..d82eed9 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -303,6 +303,37 @@ class ArtifactDetailView(APIView): return Response(status=204) +class ArtifactRawView(APIView): + """The artifact's full content, for read-back / verification. Same auth as + the detail endpoint (bearer token or session), owner-scoped. + + GET /api/artifacts/{id}/raw + - markdown / html_full / json / csv -> exact posted source (text/plain) + - image / pdf / binary -> the stored file bytes + - html_fragment / deck -> 404 (render-only; no raw kept) + """ + + def get(self, request, artifact_id): + user = resolve_request_user(request) + if user is None: + return Response({"detail": "Authentication required (session or bearer token)."}, status=403) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + + raw_path = raw_artifact_path(artifact) + if raw_path and raw_path.exists(): + return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8") + + if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}: + path = safe_artifact_path(artifact.path) + if path.exists(): + content_type, _ = mimetypes.guess_type(path.name) + return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") + + return Response( + {"detail": "No raw content available for this kind (render-only)."}, status=404 + ) + + class CapabilitiesView(APIView): """Static capability info. Reachable by a logged-in session or a valid API token, so agents can check connectivity before posting. Exposes no user data.""" @@ -314,6 +345,7 @@ class CapabilitiesView(APIView): methods = { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/artifacts/{id}/raw": ["GET"], "/api/assets": ["POST"], "/api/capabilities": ["GET"], "/api/skill": ["GET"], @@ -1274,6 +1306,7 @@ def serialize_artifact(artifact, request): "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), + "api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])), "raw_available": raw_artifact_path(artifact) is not None, "source": artifact.source, "title": artifact.title, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 72aff5b..47770b1 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/docs/api.md b/docs/api.md index e804074..187270c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -465,26 +465,28 @@ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ ## Rendered And Raw Content -Rendered browser view: +**For agents (bearer token):** fetch the full content from the API — separate +from the metadata `GET /api/artifacts/{id}`: ```http -GET /a/{id} +GET /api/artifacts/{id}/raw ``` -Raw/source view: +Same auth as the detail endpoint (bearer token or session), owner-scoped, so an +agent can read back and verify exactly what it posted. The metadata response +includes `api_raw_url` pointing here. -```http -GET /a/{id}?raw=1 -``` +- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as + `text/plain`. +- `image` / `pdf` / `binary` → the stored file bytes (with its content type). +- `html_fragment` / `deck` → `404` (render-only; no raw source is kept). -Raw currently exists for: +Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line +endings. -| Kind | Raw content | -|---|---| -| `markdown` | Preserved `.md` source. | -| `html_full` | Stored HTML file served as text. | -| `json` | Preserved original JSON text. | -| `csv` | Preserved original CSV text. | +**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and +`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint +above for token access. If raw content is unavailable, AgentFeed returns a small HTML fallback page. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 34193b7..0c8797c 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -253,6 +253,46 @@ class ArtifactAPITestCase(APITestCase): response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) self.assertEqual(response.status_code, 403) + # -- raw content (GET {id}/raw) --------------------------------------- + + def _raw_body(self, response): + return b"".join(response.streaming_content).decode() + + def test_raw_returns_exact_content_with_bearer(self): + create = self.create_artifact(content="# Exact\n\nverify me") + artifact_id = create.data["id"] + # metadata detail advertises the raw endpoint + self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw")) + + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") + # Content round-trips faithfully; normalize newlines since the stored + # sidecar is written in the host's text mode (CRLF on Windows dev, LF on + # the Linux deploy). + self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me") + + def test_raw_requires_auth(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.get(reverse("artifact-raw-api", args=[artifact_id])) + self.assertEqual(response.status_code, 403) + + def test_raw_owner_scoped(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token) + ) + self.assertEqual(response.status_code, 404) + + def test_raw_not_available_for_render_only_kind(self): + artifact_id = self.create_artifact(kind="html_fragment", content="

hi

").data["id"] + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 404) + def test_patch_updates_title_and_tags(self): artifact_id = self.create_artifact().data["id"] response = self.client.patch(