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
|
|
@ -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.
|
- 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.
|
- `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.
|
- 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.
|
- 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.
|
- `TagColor` (label color overrides) stays global/shared across all users.
|
||||||
- Use random 8-character artifact IDs.
|
- Use random 8-character artifact IDs.
|
||||||
|
|
|
||||||
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)
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
is_kept = models.BooleanField(default=False)
|
is_kept = models.BooleanField(default=False)
|
||||||
size_bytes = models.BigIntegerField(default=0)
|
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
|
@property
|
||||||
def is_unread(self):
|
def is_unread(self):
|
||||||
return self.read_at is None
|
return self.read_at is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_shared(self):
|
||||||
|
return bool(self.share_token)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ urlpatterns = [
|
||||||
path("feed/older", views.feed_older, name="feed-older"),
|
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>/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>/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("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("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||||
|
|
|
||||||
|
|
@ -205,10 +205,18 @@ class AssetUploadView(APIView):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@xframe_options_sameorigin
|
@xframe_options_sameorigin
|
||||||
def asset_view(request, asset_id):
|
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)
|
path = safe_artifact_path(asset.path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise Http404("Asset file not found")
|
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})
|
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
|
@login_required
|
||||||
def feed_older(request):
|
def feed_older(request):
|
||||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
"""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
|
@xframe_options_sameorigin
|
||||||
def artifact_view(request, artifact_id):
|
def artifact_view(request, artifact_id):
|
||||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
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":
|
if request.GET.get("raw") == "1":
|
||||||
raw_path = raw_artifact_path(artifact)
|
raw_path = raw_artifact_path(artifact)
|
||||||
if raw_path and raw_path.exists():
|
if raw_path and raw_path.exists():
|
||||||
|
|
@ -1005,6 +1040,12 @@ def serialize_artifact(artifact, request):
|
||||||
"metadata": artifact.metadata,
|
"metadata": artifact.metadata,
|
||||||
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
|
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
|
||||||
"is_kept": artifact.is_kept,
|
"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,
|
"size_bytes": artifact.size_bytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
docs/api.md
32
docs/api.md
|
|
@ -196,7 +196,11 @@ Response shape:
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.5",
|
||||||
"run_id": "brief-001"
|
"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. |
|
| `markdown` | Preserved `.md` source. |
|
||||||
| `html_full` | Stored HTML file served as text. |
|
| `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.
|
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
|
## Capabilities
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,24 @@ h1 {
|
||||||
display: none;
|
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 {
|
.artifact-source {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
@ -888,6 +906,45 @@ kbd {
|
||||||
color: #28583a;
|
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 {
|
.details-panel {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
border-bottom: 1px solid var(--grid-line);
|
border-bottom: 1px solid var(--grid-line);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% for artifact in group.artifacts %}
|
{% 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 }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||||
data-artifact-id="{{ artifact.id }}"
|
data-artifact-id="{{ artifact.id }}"
|
||||||
data-title="{{ artifact.title }}"
|
data-title="{{ artifact.title }}"
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
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-tags="{{ artifact.tags|join:', ' }}"
|
||||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||||
|
|
@ -24,6 +26,7 @@
|
||||||
<span class="artifact-title">{{ artifact.title }}</span>
|
<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="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="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 %}
|
{% if artifact.label_chips %}
|
||||||
<span class="tag-chips">
|
<span class="tag-chips">
|
||||||
{% for label in artifact.label_chips %}
|
{% for label in artifact.label_chips %}
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,16 @@
|
||||||
</nav>
|
</nav>
|
||||||
<div class="artifact-action-links">
|
<div class="artifact-action-links">
|
||||||
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
|
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
|
||||||
|
<button type="button" class="share-button {% if selected_artifact.is_shared %}shared{% endif %}" data-share-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_shared %}🔗 Shared{% else %}Share{% endif %}</button>
|
||||||
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
|
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
|
||||||
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
|
||||||
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
|
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
|
||||||
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
|
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="share-link-box" data-share-link-box {% if not selected_artifact.is_shared %}hidden{% endif %}>
|
||||||
|
<input type="text" readonly data-share-link-input value="{% if selected_artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' selected_artifact.share_token %}{% endif %}">
|
||||||
|
<button type="button" data-copy-url="{% if selected_artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' selected_artifact.share_token %}{% endif %}">Copy</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="details-panel" hidden data-details-panel></div>
|
<div class="details-panel" hidden data-details-panel></div>
|
||||||
|
|
@ -235,6 +240,12 @@
|
||||||
syncKeepButton(row.dataset.kept === "1");
|
syncKeepButton(row.dataset.kept === "1");
|
||||||
const keepButton = actions.querySelector("[data-keep-button]");
|
const keepButton = actions.querySelector("[data-keep-button]");
|
||||||
if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId;
|
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;
|
actions.querySelector("[data-details-toggle]").disabled = false;
|
||||||
renderDetails(row);
|
renderDetails(row);
|
||||||
};
|
};
|
||||||
|
|
@ -247,6 +258,21 @@
|
||||||
keepButton.textContent = isKept ? "✓ Kept" : "Keep";
|
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 renderDetails = (row) => {
|
||||||
const panel = document.querySelector("[data-details-panel]");
|
const panel = document.querySelector("[data-details-panel]");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
@ -292,6 +318,13 @@
|
||||||
keepButton.classList.remove("kept");
|
keepButton.classList.remove("kept");
|
||||||
keepButton.textContent = "Keep";
|
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;
|
actions.querySelector("[data-details-toggle]").disabled = true;
|
||||||
const panel = document.querySelector("[data-details-panel]");
|
const panel = document.querySelector("[data-details-panel]");
|
||||||
if (panel) panel.hidden = true;
|
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) => {
|
previewPane.addEventListener("click", (event) => {
|
||||||
const copyButton = event.target.closest("[data-copy-url]");
|
const copyButton = event.target.closest("[data-copy-url]");
|
||||||
if (copyButton) {
|
if (copyButton) {
|
||||||
|
|
@ -538,6 +595,13 @@
|
||||||
return;
|
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]");
|
const detailsToggle = event.target.closest("[data-details-toggle]");
|
||||||
if (detailsToggle && !detailsToggle.disabled) {
|
if (detailsToggle && !detailsToggle.disabled) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
186
tests/views/test_sharing.py
Normal file
186
tests/views/test_sharing.py
Normal file
|
|
@ -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("<h1>Shared content</h1>", 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"])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue