Add per-artifact public sharing

Any artifact can now be made publicly viewable via a share link, no login
required - opt-in per artifact, toggled from the feed UI next to Keep.

- Artifact.share_token: nullable, unique, high-entropy (secrets.token_urlsafe(32)).
  NULL = not shared; SQL treats each NULL as distinct so uniqueness still
  holds for the many not-shared rows.
- New public route GET /s/<token> (shared_artifact_view), completely
  unauthenticated, serving the same rendered/raw content as the owner
  route via a shared serve_artifact_file() helper.
- toggle_artifact_share: generates/clears the token: mirrors
  toggle_artifact_keep's pattern.
- Fixed a real gap this surfaced: embedded images in a shared artifact's
  content would otherwise break for an anonymous visitor, since asset_view
  was owner-only. It now also allows access when the asset's parent
  artifact is currently shared - unattached (orphaned) assets stay private
  regardless.
- serialize_artifact now reports is_shared/share_url so the API reflects
  sharing status (read-only for now; toggling stays UI-only).
- Feed UI: Share button (mirrors Keep's toggle interaction), a copyable
  share-link box that appears when shared, and a "shared" badge on the
  artifact row - distinct blue accent from Keep's green so both can be
  visible on the same row without confusion.

Verified live end-to-end (toggle on/off, public access, fake-token 404,
old-link-dies-on-unshare, embedded-asset access before/after sharing)
before writing the 12 automated tests in tests/views/test_sharing.py.
Also fixed two unrelated stale spots noticed in docs/api.md while adding
the Sharing section: the example response was missing is_kept/size_bytes,
and the raw-content table didn't list json/csv (added earlier this
session but never documented there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 16:00:39 +01:00
parent e137725cab
commit 91ea19d26b
10 changed files with 414 additions and 4 deletions

View file

@ -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),
),
]

View file

@ -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/<token> 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"]

View file

@ -8,7 +8,9 @@ urlpatterns = [
path("feed/older", views.feed_older, name="feed-older"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", 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"),

View file

@ -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,
}