diff --git a/CLAUDE.md b/CLAUDE.md index 0203418..f612d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ The core product principle is: agents publish content, not files. The server own - Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. - `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. - Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. - Use random 8-character artifact IDs. diff --git a/artifacts/migrations/0013_artifact_share_token.py b/artifacts/migrations/0013_artifact_share_token.py new file mode 100644 index 0000000..6996be7 --- /dev/null +++ b/artifacts/migrations/0013_artifact_share_token.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-09-02 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0012_alter_artifact_kind'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='share_token', + field=models.CharField(blank=True, default=None, max_length=64, null=True, unique=True), + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index d0752fb..446454f 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -54,11 +54,19 @@ class Artifact(models.Model): read_at = models.DateTimeField(null=True, blank=True) is_kept = models.BooleanField(default=False) size_bytes = models.BigIntegerField(default=0) + # NULL = not shared. Many rows share NULL (SQL treats each NULL as + # distinct, so `unique=True` still holds); a real value must be unique + # since it's the sole credential for the unauthenticated /s/ route. + share_token = models.CharField(max_length=64, null=True, blank=True, unique=True, default=None) @property def is_unread(self): return self.read_at is None + @property + def is_shared(self): + return bool(self.share_token) + class Meta: ordering = ["-created_at"] diff --git a/artifacts/urls.py b/artifacts/urls.py index 875c73d..12a717c 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -8,7 +8,9 @@ urlpatterns = [ path("feed/older", views.feed_older, name="feed-older"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), + path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/", views.artifact_view, name="artifact"), + path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), diff --git a/artifacts/views.py b/artifacts/views.py index 0db2e82..72aa35b 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -205,10 +205,18 @@ class AssetUploadView(APIView): ) -@login_required @xframe_options_sameorigin def asset_view(request, asset_id): - asset = get_object_or_404(Asset, pk=asset_id, owner=request.user) + asset = get_object_or_404(Asset, pk=asset_id) + is_owner = request.user.is_authenticated and asset.owner_id == request.user.id + # Assets are only reachable via a URL embedded in their parent artifact's + # content, so if that artifact is currently shared, its assets are + # effectively public too - otherwise a shared artifact's inline images + # would break for anyone who isn't logged in as the owner. + is_via_shared_artifact = asset.artifact_id is not None and bool(asset.artifact.share_token) + if not (is_owner or is_via_shared_artifact): + raise Http404("Asset file not found") + path = safe_artifact_path(asset.path) if not path.exists(): raise Http404("Asset file not found") @@ -447,6 +455,23 @@ def toggle_artifact_keep(request, artifact_id): return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept}) +@login_required +@require_POST +def toggle_artifact_share(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + if artifact.share_token: + artifact.share_token = None + else: + artifact.share_token = secrets.token_urlsafe(32) + artifact.save(update_fields=["share_token"]) + share_url = ( + request.build_absolute_uri(reverse("artifact-shared", args=[artifact.share_token])) + if artifact.share_token + else None + ) + return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url}) + + @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" @@ -490,6 +515,16 @@ def feed_older(request): @xframe_options_sameorigin def artifact_view(request, artifact_id): artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + return serve_artifact_file(request, artifact) + + +@xframe_options_sameorigin +def shared_artifact_view(request, share_token): + artifact = get_object_or_404(Artifact, share_token=share_token) + return serve_artifact_file(request, artifact) + + +def serve_artifact_file(request, artifact): if request.GET.get("raw") == "1": raw_path = raw_artifact_path(artifact) if raw_path and raw_path.exists(): @@ -1005,6 +1040,12 @@ def serialize_artifact(artifact, request): "metadata": artifact.metadata, "read_at": artifact.read_at.isoformat() if artifact.read_at else None, "is_kept": artifact.is_kept, + "is_shared": artifact.is_shared, + "share_url": ( + request.build_absolute_uri(reverse("artifact-shared", args=[artifact.share_token])) + if artifact.share_token + else None + ), "size_bytes": artifact.size_bytes, } diff --git a/docs/api.md b/docs/api.md index d555472..57d5d21 100644 --- a/docs/api.md +++ b/docs/api.md @@ -196,7 +196,11 @@ Response shape: "model": "gpt-5.5", "run_id": "brief-001" }, - "read_at": null + "read_at": null, + "is_kept": false, + "is_shared": false, + "share_url": null, + "size_bytes": 702 } ``` @@ -429,9 +433,35 @@ Raw currently exists for: |---|---| | `markdown` | Preserved `.md` source. | | `html_full` | Stored HTML file served as text. | +| `json` | Preserved original JSON text. | +| `csv` | Preserved original CSV text. | If raw content is unavailable, Daybook returns a small HTML fallback page. +## Sharing + +Any artifact can be made publicly viewable — no login or token required — +via a per-artifact share link. This is toggled from the feed UI (a "Share" +button next to "Keep"), not through the ingest API; there's no `PATCH` +field to set it programmatically yet. + +```http +GET /s/{share_token} +``` + +- `share_token` is a high-entropy random string (unrelated to the artifact's + own `id`), generated when sharing is turned on and cleared when turned + off — turning sharing off immediately invalidates the old link. +- The public route serves the same rendered content as `GET /a/{id}` + (including `?raw=1`), but with no authentication check at all — anyone + with the link can view it. +- Images embedded in a shared artifact's content are also served + unauthenticated while that artifact is shared, so inline images don't + break for a visitor without a Daybook account. +- `is_shared` and `share_url` (both in every artifact response — see + `GET /api/artifacts/{id}`) report current sharing status; `share_url` is + `null` when not shared. + ## Capabilities Endpoint: diff --git a/static/daybook.css b/static/daybook.css index 42885b5..50d4b2a 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -575,6 +575,24 @@ h1 { display: none; } +.shared-badge { + align-items: center; + background: #eaf2fb; + border-radius: 4px; + color: #1d4d85; + display: inline-flex; + font-size: 0.66rem; + font-weight: 700; + justify-content: center; + line-height: 1; + padding: 0.14rem 0.3rem; + white-space: nowrap; +} + +.shared-badge[hidden] { + display: none; +} + .artifact-source { align-items: center; color: var(--muted); @@ -888,6 +906,45 @@ kbd { color: #28583a; } +.share-button.shared { + background: #eaf2fb; + border-color: #9cc0ed; + color: #1d4d85; +} + +.share-link-box { + align-items: center; + display: flex; + gap: 0.35rem; + padding: 0 0.9rem 0.6rem; +} + +.share-link-box[hidden] { + display: none; +} + +.share-link-box input { + background: var(--surface-alt); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--muted); + flex: 1 1 auto; + font: inherit; + font-size: 0.76rem; + padding: 0.35rem 0.55rem; +} + +.share-link-box button { + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.76rem; + padding: 0.35rem 0.65rem; +} + .details-panel { background: var(--surface-alt); border-bottom: 1px solid var(--grid-line); diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 2bc6182..111fb86 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -5,7 +5,7 @@ {{ group.artifacts|length }} {% for artifact in group.artifacts %} - @@ -24,6 +26,7 @@ {{ artifact.title }} {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} K + 🔗 {% if artifact.label_chips %} {% for label in artifact.label_chips %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 447fec0..1e5521d 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -68,11 +68,16 @@ + @@ -235,6 +240,12 @@ syncKeepButton(row.dataset.kept === "1"); const keepButton = actions.querySelector("[data-keep-button]"); if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId; + syncShareButton(row.dataset.shared === "1", row.dataset.shareUrl || ""); + const shareButton = actions.querySelector("[data-share-button]"); + if (shareButton) { + shareButton.disabled = false; + shareButton.dataset.artifactId = row.dataset.artifactId; + } actions.querySelector("[data-details-toggle]").disabled = false; renderDetails(row); }; @@ -247,6 +258,21 @@ keepButton.textContent = isKept ? "✓ Kept" : "Keep"; }; + const syncShareButton = (isShared, shareUrl) => { + const shareButton = document.querySelector("[data-share-button]"); + if (shareButton) { + shareButton.classList.toggle("shared", isShared); + shareButton.textContent = isShared ? "🔗 Shared" : "Share"; + } + const box = document.querySelector("[data-share-link-box]"); + if (!box) return; + box.hidden = !isShared; + const input = box.querySelector("[data-share-link-input]"); + if (input) input.value = isShared ? shareUrl : ""; + const copyButton = box.querySelector("[data-copy-url]"); + if (copyButton) copyButton.dataset.copyUrl = isShared ? shareUrl : ""; + }; + const renderDetails = (row) => { const panel = document.querySelector("[data-details-panel]"); if (!panel) return; @@ -292,6 +318,13 @@ keepButton.classList.remove("kept"); keepButton.textContent = "Keep"; } + const shareButton = actions.querySelector("[data-share-button]"); + if (shareButton) { + shareButton.disabled = true; + shareButton.classList.remove("shared"); + shareButton.textContent = "Share"; + } + syncShareButton(false, ""); actions.querySelector("[data-details-toggle]").disabled = true; const panel = document.querySelector("[data-details-panel]"); if (panel) panel.hidden = true; @@ -523,6 +556,30 @@ } }; + const toggleShare = async (button) => { + const artifactId = button.dataset.artifactId; + if (!artifactId) return; + button.disabled = true; + try { + const response = await fetch(`/a/${artifactId}/share`, { + method: "POST", + headers: {"X-CSRFToken": csrfToken}, + }); + const data = await response.json(); + syncShareButton(data.is_shared, data.share_url || ""); + const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`); + if (row) { + row.dataset.shared = data.is_shared ? "1" : "0"; + row.dataset.shareUrl = data.share_url || ""; + row.classList.toggle("shared", data.is_shared); + const badge = row.querySelector("[data-shared-badge]"); + if (badge) badge.hidden = !data.is_shared; + } + } finally { + button.disabled = false; + } + }; + previewPane.addEventListener("click", (event) => { const copyButton = event.target.closest("[data-copy-url]"); if (copyButton) { @@ -538,6 +595,13 @@ return; } + const shareButton = event.target.closest("[data-share-button]"); + if (shareButton && !shareButton.disabled) { + event.preventDefault(); + toggleShare(shareButton); + return; + } + const detailsToggle = event.target.closest("[data-details-toggle]"); if (detailsToggle && !detailsToggle.disabled) { event.preventDefault(); diff --git a/tests/views/test_sharing.py b/tests/views/test_sharing.py new file mode 100644 index 0000000..da7771e --- /dev/null +++ b/tests/views/test_sharing.py @@ -0,0 +1,186 @@ +import shutil +import tempfile +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token + +from artifacts.models import Artifact, Asset + +User = get_user_model() + + +class SharingTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-sharing-")) + cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root) + cls.settings_override.enable() + + @classmethod + def tearDownClass(cls): + cls.settings_override.disable() + shutil.rmtree(cls.artifacts_root, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + self.owner = User.objects.create_user(username="owner", password="pw") + self.other = User.objects.create_user(username="other", password="pw") + self.artifact = Artifact.objects.create( + id="shrtest1", + owner=self.owner, + source="share-test", + title="Share Test", + kind=Artifact.MARKDOWN, + day=timezone.localdate(), + path="share-test/shrtest1.html", + original_kind=Artifact.MARKDOWN, + size_bytes=10, + ) + target = self.artifacts_root / "share-test" + target.mkdir(parents=True, exist_ok=True) + (target / "shrtest1.html").write_text("

Shared content

", encoding="utf-8") + + def toggle_share(self, client): + return client.post(reverse("artifact-toggle-share", args=[self.artifact.id])) + + # -- toggling --------------------------------------------------------- + + def test_toggle_share_requires_login(self): + response = self.toggle_share(self.client) + self.assertEqual(response.status_code, 302) + + def test_non_owner_cannot_toggle_share(self): + self.client.force_login(self.other) + response = self.toggle_share(self.client) + self.assertEqual(response.status_code, 404) + + def test_toggle_share_on_creates_token_and_url(self): + self.client.force_login(self.owner) + response = self.toggle_share(self.client) + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertTrue(body["is_shared"]) + self.assertIsNotNone(body["share_url"]) + + self.artifact.refresh_from_db() + self.assertTrue(self.artifact.share_token) + self.assertIn(self.artifact.share_token, body["share_url"]) + + def test_toggle_share_off_clears_token(self): + self.client.force_login(self.owner) + self.toggle_share(self.client) + response = self.toggle_share(self.client) + body = response.json() + self.assertFalse(body["is_shared"]) + self.assertIsNone(body["share_url"]) + + self.artifact.refresh_from_db() + self.assertIsNone(self.artifact.share_token) + + # -- public access ------------------------------------------------------ + + def test_shared_artifact_viewable_without_login(self): + self.client.force_login(self.owner) + self.toggle_share(self.client) + self.artifact.refresh_from_db() + + anon = self.client_class() + response = anon.get(reverse("artifact-shared", args=[self.artifact.share_token])) + self.assertEqual(response.status_code, 200) + + def test_unshared_artifact_share_route_404s(self): + anon = self.client_class() + response = anon.get(reverse("artifact-shared", args=["not-a-real-token"])) + self.assertEqual(response.status_code, 404) + + def test_unsharing_invalidates_old_link(self): + self.client.force_login(self.owner) + first = self.toggle_share(self.client).json() + self.toggle_share(self.client) # turn back off + + anon = self.client_class() + response = anon.get(reverse("artifact-shared", args=[first["share_url"].rsplit("/", 1)[-1]])) + self.assertEqual(response.status_code, 404) + + def test_owner_route_still_requires_login_even_when_shared(self): + self.client.force_login(self.owner) + self.toggle_share(self.client) + + anon = self.client_class() + response = anon.get(reverse("artifact", args=[self.artifact.id])) + self.assertEqual(response.status_code, 302) # redirected to login, not served + + # -- embedded assets ------------------------------------------------------ + + def test_embedded_asset_not_public_when_artifact_unshared(self): + asset = Asset.objects.create( + id="asseta1b2c3", + owner=self.owner, + artifact=self.artifact, + path="share-test/pic.png", + content_type="image/png", + size_bytes=5, + ) + (self.artifacts_root / "share-test" / "pic.png").write_bytes(b"\x89PNG") + + anon = self.client_class() + response = anon.get(reverse("asset", args=[asset.id])) + self.assertEqual(response.status_code, 404) + + def test_embedded_asset_public_when_artifact_shared(self): + asset = Asset.objects.create( + id="asseta1b2c3", + owner=self.owner, + artifact=self.artifact, + path="share-test/pic.png", + content_type="image/png", + size_bytes=5, + ) + (self.artifacts_root / "share-test" / "pic.png").write_bytes(b"\x89PNG") + + self.client.force_login(self.owner) + self.toggle_share(self.client) + + anon = self.client_class() + response = anon.get(reverse("asset", args=[asset.id])) + self.assertEqual(response.status_code, 200) + + def test_unattached_asset_stays_private_regardless(self): + """An asset with no parent artifact (orphaned upload) is never public, + even if some unrelated artifact happens to be shared.""" + asset = Asset.objects.create( + id="orphanasset", + owner=self.owner, + artifact=None, + path="share-test/orphan.png", + content_type="image/png", + size_bytes=5, + ) + (self.artifacts_root / "share-test" / "orphan.png").write_bytes(b"\x89PNG") + + self.client.force_login(self.owner) + self.toggle_share(self.client) # shares self.artifact, unrelated to this asset + + anon = self.client_class() + response = anon.get(reverse("asset", args=[asset.id])) + self.assertEqual(response.status_code, 404) + + # -- API surface -------------------------------------------------------- + + def test_api_detail_reports_share_status(self): + token = Token.objects.create(user=self.owner) + self.client.force_login(self.owner) + self.toggle_share(self.client) + self.artifact.refresh_from_db() + + response = self.client.get( + reverse("artifact-detail-api", args=[self.artifact.id]), + HTTP_AUTHORIZATION=f"Bearer {token.key}", + ) + self.assertTrue(response.data["is_shared"]) + self.assertIn(self.artifact.share_token, response.data["share_url"])