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 <noreply@anthropic.com>
This commit is contained in:
parent
2b901f7844
commit
f6f004c689
6 changed files with 102 additions and 15 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -9,6 +9,16 @@ former name, "Daybook".)
|
||||||
|
|
||||||
_Nothing yet._
|
_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
|
## [0.3.1] — 2026-09-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
@ -128,7 +138,8 @@ First tagged release.
|
||||||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
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.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.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
|
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ urlpatterns = [
|
||||||
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
||||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
|
path("api/artifacts/<str:artifact_id>/raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"),
|
||||||
path("settings/", views.settings_home, name="settings-home"),
|
path("settings/", views.settings_home, name="settings-home"),
|
||||||
path("settings/account/", views.settings_account, name="settings-account"),
|
path("settings/account/", views.settings_account, name="settings-account"),
|
||||||
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
|
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,37 @@ class ArtifactDetailView(APIView):
|
||||||
return Response(status=204)
|
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):
|
class CapabilitiesView(APIView):
|
||||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
"""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."""
|
so agents can check connectivity before posting. Exposes no user data."""
|
||||||
|
|
@ -314,6 +345,7 @@ class CapabilitiesView(APIView):
|
||||||
methods = {
|
methods = {
|
||||||
"/api/artifacts": ["GET", "POST"],
|
"/api/artifacts": ["GET", "POST"],
|
||||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||||
|
"/api/artifacts/{id}/raw": ["GET"],
|
||||||
"/api/assets": ["POST"],
|
"/api/assets": ["POST"],
|
||||||
"/api/capabilities": ["GET"],
|
"/api/capabilities": ["GET"],
|
||||||
"/api/skill": ["GET"],
|
"/api/skill": ["GET"],
|
||||||
|
|
@ -1274,6 +1306,7 @@ def serialize_artifact(artifact, request):
|
||||||
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||||
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", 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"),
|
"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,
|
"raw_available": raw_artifact_path(artifact) is not None,
|
||||||
"source": artifact.source,
|
"source": artifact.source,
|
||||||
"title": artifact.title,
|
"title": artifact.title,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Single source of truth for the app version. Bump this, commit, then tag the
|
# 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
|
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||||
# GET /api/capabilities ("version").
|
# GET /api/capabilities ("version").
|
||||||
__version__ = "0.3.1"
|
__version__ = "0.3.2"
|
||||||
|
|
|
||||||
28
docs/api.md
28
docs/api.md
|
|
@ -465,26 +465,28 @@ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
||||||
|
|
||||||
## Rendered And Raw Content
|
## 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
|
```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
|
- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as
|
||||||
GET /a/{id}?raw=1
|
`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 |
|
**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
|
||||||
| `markdown` | Preserved `.md` source. |
|
above for token access.
|
||||||
| `html_full` | Stored HTML file served as text. |
|
|
||||||
| `json` | Preserved original JSON text. |
|
|
||||||
| `csv` | Preserved original CSV text. |
|
|
||||||
|
|
||||||
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
|
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,46 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
self.assertEqual(response.status_code, 403)
|
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="<p>hi</p>").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):
|
def test_patch_updates_title_and_tags(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue