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

@ -575,6 +575,24 @@ h1 {
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 {
align-items: center;
color: var(--muted);
@ -888,6 +906,45 @@ kbd {
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 {
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);