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:
parent
e137725cab
commit
91ea19d26b
10 changed files with 414 additions and 4 deletions
18
artifacts/migrations/0013_artifact_share_token.py
Normal file
18
artifacts/migrations/0013_artifact_share_token.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue