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
|
|
@ -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 }}&{% 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-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 %}
|
||||
|
|
|
|||
|
|
@ -68,11 +68,16 @@
|
|||
</nav>
|
||||
<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="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>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
<div class="details-panel" hidden data-details-panel></div>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue