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

@ -5,7 +5,7 @@
<span class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% for artifact in group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %}"
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
@ -16,6 +16,8 @@
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
@ -24,6 +26,7 @@
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% 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 %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}