feat(web,server): render embedded image attachments on share pages via a variants-only byte endpoint (BUG-2389 2b, TASK-2637)

Public share pages could not render the image attachments embedded in
shared content — BUG-2389 shipped honest "not available" placeholders and
deliberately deferred byte access to this task. Dave authorized VARIANTS
ONLY (originals and file downloads excluded).

Adds a public, token-scoped endpoint GET /api/v1/s/{token}/attachments/{id}
that serves rendered image variants. The invariant Dave ruled: every byte a
share viewer receives has passed through the decode -> re-encode variant
pipeline — container metadata (EXIF/GPS) dropped by construction, format
normalized per ThumbnailFormat, resolution bounded. An original is never
served, even for a small image whose variant is identity-dimensioned; the
re-encode is the privacy boundary, not the dimension gate.

Access classes:
- Plain links: token + revocation + expiry + item/collection anchoring.
- Protected links (password / require_auth): additionally require a
  short-lived HMAC signed ref, minted into the page-data response only
  after the second factor is satisfied — a bare token+id asset URL would
  leak through the same channels (mail, logs, history) the password exists
  to survive. Unconfigured secret -> no ref -> honest placeholder, never an
  unsigned URL.
- max_views gates page opens; assets ride the granted view (no re-charge).

To satisfy the invariant for images at or below a variant bound (which the
worker previously skipped, relying on the authenticated handler's original
fallback), the thumbnail worker now always derives thumb-md (identity-dims,
re-encoded; Resize passes through without upscaling), and share-resolve
lazily backfills legacy attachments. The byte endpoint and the
authenticated download path are otherwise unchanged.

Frontend: an optional urlBuilder threads through the markdown attachment
render helpers so the share page targets the token-scoped endpoint (with
the signed suffix for protected links) instead of the workspace path; every
other surface is unaffected.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
This commit is contained in:
xarmian
2026-08-18 15:30:41 +00:00
parent 482815aa58
commit d3c8234e23
14 changed files with 1488 additions and 41 deletions
@@ -45,10 +45,13 @@ var thumbnailSpecs = []struct {
// - Source format not supported by the configured processor (e.g.
// pure-Go on a WebP upload — the original survives, the user
// just sees the original at native resolution).
// - Source dimensions known and already smaller than the variant's
// target — pointless to encode an upscaled or same-size copy.
// - Variant already exists (idempotent reruns from a future
// "regenerate thumbnails" admin action).
// "regenerate thumbnails" admin action, and from the share-resolve
// lazy-derive path that backfills legacy attachments — TASK-2637).
//
// A source already within a variant's bounds is NOT skipped: Resize
// passes it through unresized (no upscale) and it is still re-encoded,
// which is the point on the share surface — see the loop below.
//
// Each successfully-derived variant becomes its own attachments row
// with parent_id = parentID and variant = "thumb-sm" / "thumb-md".
@@ -99,13 +102,19 @@ func (s *Server) deriveThumbnails(parentID string) {
outFormat := attachments.ThumbnailFormat(format)
for _, spec := range thumbnailSpecs {
// Skip when the source is already within the variant's bounds
// — the download handler's variant fallback path serves the
// original, which is what we'd produce anyway.
if parent.Width != nil && parent.Height != nil &&
*parent.Width <= spec.MaxLong && *parent.Height <= spec.MaxLong {
continue
}
// NB: we do NOT skip when the source is already within the variant's
// bounds. It would be pointless for the AUTHENTICATED download path
// (which falls back to the original), but the SHARE byte path
// (TASK-2637) serves variants ONLY and never an original — the
// invariant Dave ruled is that every byte a share viewer receives has
// passed through this decode→re-encode pipeline: metadata (EXIF/GPS)
// dropped by construction, format normalized per ThumbnailFormat,
// resolution bounded. A ≤bound source therefore still needs a real
// derived row. Resize passes the image through unresized in that case
// (no upscale — see pureGoProcessor.Resize), so the variant is
// identity-dimensioned but genuinely re-encoded and metadata-free; if
// thumb-sm and thumb-md come out byte-identical for a tiny source,
// content-addressed storage dedupes the blob (two rows, one blob).
if existing, err := s.store.GetAttachmentVariant(parent.WorkspaceID, parent.ID, spec.Variant); err == nil && existing != nil {
continue
}
@@ -168,12 +168,16 @@ func TestThumbnails_GeneratedOnJPEGUpload(t *testing.T) {
}
}
// TestThumbnails_SkippedForSmallSourceImage — when the parent's
// dimensions are already within a variant's bound, we don't emit a
// derived row (the download handler already falls back to original).
func TestThumbnails_SkippedForSmallSourceImage(t *testing.T) {
// TestThumbnails_DerivedForSmallSourceImage — TASK-2637 / fork-3 Option C
// REVERSED the old skip-when-≤bound behavior. A source already within a
// variant's bound now STILL derives a real row: Resize passes it through
// unresized (no upscale) and it is re-encoded, so the variant is
// identity-dimensioned but metadata-stripped and format-normalized. The
// share byte path serves variants ONLY and never an original, so a small
// image must have a genuine derived row to render at all.
func TestThumbnails_DerivedForSmallSourceImage(t *testing.T) {
srv, slug := testServerWithAttachments(t)
body := makeIntegrationPNG(t, 200, 150) // < both 256 and 1024 → both variants skipped
body := makeIntegrationPNG(t, 200, 150) // < both 256 and 1024, previously skipped
rr := doMultipartUpload(srv, slug, "small.png", body)
if rr.Code != http.StatusCreated {
@@ -186,8 +190,13 @@ func TestThumbnails_SkippedForSmallSourceImage(t *testing.T) {
srv.Stop()
for _, variant := range []string{models.AttachmentVariantThumbSm, models.AttachmentVariantThumbMd} {
if row, _ := variantRow(t, srv, resp.ID, variant); row != nil {
t.Errorf("variant %q should be skipped for small source, got row %s", variant, row.ID)
row, err := variantRow(t, srv, resp.ID, variant)
if err != nil || row == nil {
t.Fatalf("variant %q should be derived for a small source now, got row=%v err=%v", variant, row, err)
}
// PNG source stays PNG (alpha survives) — pin (c).
if row.MimeType != "image/png" {
t.Errorf("variant %q MimeType = %q, want image/png (PNG source stays PNG)", variant, row.MimeType)
}
}
}
@@ -0,0 +1,330 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/go-chi/chi/v5"
)
// Share-link asset serving (BUG-2389 2b / TASK-2637). This is the byte
// path that lets a public share page render the image ATTACHMENTS embedded
// in its shared content — deliberately VARIANTS ONLY (Dave-authorized:
// rendered thumbnails, never originals or file downloads). The companion
// minting side lives in handlers_share_links.go (mintShareAttachmentRefs).
//
// Two access classes, decided by two forks on the TASK-2637 trail:
//
// - PLAIN links (no password, no require_auth): token + revocation +
// expiry + item/collection anchoring gate the bytes. max_views is NOT
// re-checked — a page open is the billable view; the assets it embeds
// ride that one granted view (fork-1, Option 2).
//
// - PROTECTED links (password or require_auth): the password/auth is a
// SECOND FACTOR whose whole job is surviving URL leakage (share tokens
// travel through mail, logs, history, link previews). A bare token+id
// asset URL would leak through the identical channels, reopening the
// hole the second factor closes — so protected-link assets additionally
// require a short-lived HMAC signature minted into the page-data
// response only AFTER the factor was satisfied (fork-2, Option C; the
// "just anchor it" option B was rejected for exactly this reason).
//
// Every rejection is a generic 404 — no enumeration, no workspace-scope
// leak, byte-identical whether the token is unknown, expired, revoked, the
// attachment is foreign/soft-deleted/not-an-image, or a signature is
// missing/expired/for-a-different-attachment.
const (
// shareAssetSigDomain domain-separates the share-asset HMAC from every
// other use of the same deployment secret (notably the 6-digit claim
// codes in claim_codes.go, which sign a different payload shape). A
// signature minted here can never be mistaken for — or collide with —
// one minted anywhere else, even though both key off s.claimSecret.
shareAssetSigDomain = "share-asset-v1"
// shareAssetSigTTL is how long a minted asset signature stays valid.
// Minutes-scale: long enough for a page to render and a viewer to dwell
// on it, short enough that a leaked asset URL is stale almost
// immediately. The signature only gates PROTECTED links; plain links
// need no signature at all.
shareAssetSigTTL = 10 * time.Minute
// shareAssetVariant is the single variant this endpoint serves by
// default and the one mintShareAttachmentRefs gates on. Originals are
// out of scope by authorization; thumb-md is the rendered variant the
// share page embeds.
shareAssetVariant = models.AttachmentVariantThumbMd
)
// signShareAsset produces the hex HMAC binding a share link + attachment +
// expiry together. Returns "" when the secret is too short to sign with
// (an unconfigured self-host deployment) — the caller treats that as
// "cannot mint a protected ref" and degrades to the honest placeholder,
// never to an unsigned bare URL.
//
// The variant is deliberately NOT part of the signed payload: the signature
// authorizes access to THIS attachment's rendered variants under THIS link;
// the byte endpoint independently constrains which variant strings it will
// serve (thumb-sm / thumb-md, never original). Signing the variant would
// buy nothing and would force the mint side to pick the variant the client
// will later request.
func signShareAsset(secret []byte, linkID, attachmentID string, exp int64) string {
if len(secret) < 16 {
return ""
}
mac := hmac.New(sha256.New, secret)
writeLenPrefixed(mac, []byte(shareAssetSigDomain))
writeLenPrefixed(mac, []byte(linkID))
writeLenPrefixed(mac, []byte(attachmentID))
var expBytes [8]byte
binary.BigEndian.PutUint64(expBytes[:], uint64(exp))
mac.Write(expBytes[:])
return hex.EncodeToString(mac.Sum(nil))
}
// verifyShareAsset returns true iff sig is a currently-valid signature for
// (linkID, attachmentID, exp) — constant-time compared and not yet expired.
// Any malformed / empty input returns false without leaking which check
// failed.
func verifyShareAsset(secret []byte, linkID, attachmentID, expStr, sig string, now time.Time) bool {
if len(secret) < 16 || sig == "" || expStr == "" {
return false
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return false
}
if now.Unix() > exp {
return false
}
want := signShareAsset(secret, linkID, attachmentID, exp)
if want == "" {
return false
}
// hex-decoded, constant-time. Compare the decoded bytes rather than the
// hex strings so a differing hex case can't shortcut the compare.
got, err := hex.DecodeString(sig)
if err != nil {
return false
}
wantBytes, err := hex.DecodeString(want)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(got, wantBytes) == 1
}
// handleGetShareLinkAttachment serves a rendered image variant for an
// attachment embedded in a shared item/collection's content.
// GET /api/v1/s/{token}/attachments/{attachmentID}
func (s *Server) handleGetShareLinkAttachment(w http.ResponseWriter, r *http.Request) {
// no-store BEFORE any lookup or gate — every denial below is
// authorization-dependent and an unlabelled 404 is heuristically
// cacheable by URL (see handleGetAttachment for the full rationale).
// The share asset path keeps no-store even on success: a share link can
// be revoked or expire, and these thumbnails are small, so we make
// revocation bite immediately rather than trade it for a browser cache.
w.Header().Set("Cache-Control", "private, no-store")
if s.attachments == nil {
// Same generic 404 as every other rejection — a public caller must
// not learn whether storage is configured.
writeShareAttachmentNotFound(w)
return
}
token := chi.URLParam(r, "token")
attachmentID := chi.URLParam(r, "attachmentID")
if token == "" || attachmentID == "" {
writeShareAttachmentNotFound(w)
return
}
// 1. Resolve the link. Unknown / revoked (deleted → nil) → 404.
link, err := s.store.GetShareLinkByToken(token)
if err != nil {
writeInternalError(w, err)
return
}
if link == nil {
writeShareAttachmentNotFound(w)
return
}
// Expiry / max_views validity. NOTE: ValidateShareLink checks expiry;
// it does NOT decrement or re-enforce max_views (that lives in
// RecordShareLinkView, which we deliberately do NOT call here — fork-1
// Option 2: assets ride the page's granted view).
if err := s.store.ValidateShareLink(link); err != nil {
writeShareAttachmentNotFound(w)
return
}
// 2. Protected links (password OR require_auth) require a valid signed
// ref. Plain links need none.
if link.HasPassword || link.RequireAuth {
q := r.URL.Query()
if !verifyShareAsset(s.claimSecret, link.ID, attachmentID, q.Get("exp"), q.Get("sig"), time.Now()) {
writeShareAttachmentNotFound(w)
return
}
}
// 3. Anchor: the attachment must be a LIVE IMAGE that belongs to the
// shared target. Any miss → generic 404 (no enumeration).
att, err := s.store.GetAttachment(attachmentID)
if err != nil {
writeInternalError(w, err)
return
}
if !s.shareAttachmentAnchors(link, att) {
writeShareAttachmentNotFound(w)
return
}
// 4. Variants only. Resolve the requested (default thumb-md) variant,
// workspace-scoped. No original fallback — that is the whole point of
// "variants only". Missing variant row → 404.
variant := shareAssetVariant
if v := r.URL.Query().Get("variant"); v != "" {
if !isShareServableVariant(v) {
// Unknown or original — 404, not 400, to keep every rejection
// on this public path byte-identical and non-probing.
writeShareAttachmentNotFound(w)
return
}
variant = v
}
derived, err := s.store.GetAttachmentVariant(link.WorkspaceID, att.ID, variant)
if err != nil {
writeInternalError(w, err)
return
}
if derived == nil {
writeShareAttachmentNotFound(w)
return
}
// 5. Serve the variant bytes, fail-closed on Content-Type exactly like
// the authenticated handler. Variants are images, but we still route
// the MIME through the allowlist so a mislabelled row can never render
// as active same-origin content.
s.serveShareAttachmentBytes(w, r, derived)
}
// shareAttachmentAnchors reports whether att is a live image that belongs to
// this share link's target — an item-share serves only its own item's
// attachments; a collection-share serves attachments of any item IN the
// shared collection. Everything else (nil, soft-deleted, orphan, non-image,
// foreign workspace, foreign item/collection) is rejected. Fail-closed.
func (s *Server) shareAttachmentAnchors(link *models.ShareLink, att *models.Attachment) bool {
if att == nil || att.DeletedAt != nil || att.ItemID == nil {
return false
}
if att.WorkspaceID != link.WorkspaceID {
return false
}
// Image-only, via the allowlist (not a bare "image/" prefix) so only
// genuinely-servable image MIMEs pass — matches the fail-closed serve.
if entry, ok := attachments.LookupMIME(att.MimeType); !ok || entry.Category != attachments.CategoryImage {
return false
}
switch link.TargetType {
case "item":
return *att.ItemID == link.TargetID
case "collection":
item, err := s.store.GetItem(*att.ItemID)
if err != nil || item == nil {
return false
}
return item.CollectionID == link.TargetID
default:
return false
}
}
// serveShareAttachmentBytes streams a variant's bytes. Mirrors the byte tail
// of handleGetAttachment: fail-closed Content-Type off the allowlist,
// nosniff, ServeContent when the backend seeks. Content-Disposition is
// always inline here (variants are images the share page embeds) but the
// MIME is still validated so an unrecognized row serves as opaque octets.
func (s *Server) serveShareAttachmentBytes(w http.ResponseWriter, r *http.Request, att *models.Attachment) {
store, err := s.attachments.Resolve(att.StorageKey)
if err != nil {
writeInternalError(w, fmt.Errorf("resolve share attachment store for %s: %w", att.StorageKey, err))
return
}
body, err := store.Get(r.Context(), att.StorageKey)
if err != nil {
if errors.Is(err, attachments.ErrNotFound) {
slog.Warn("share attachments: blob missing for live variant row",
"attachment_id", att.ID, "storage_key", att.StorageKey)
writeShareAttachmentNotFound(w)
return
}
writeInternalError(w, fmt.Errorf("get share attachment blob: %w", err))
return
}
defer body.Close()
contentType := att.MimeType
if entry, ok := attachments.LookupMIME(att.MimeType); !ok || !entry.ServeInline() {
// Not an inline-safe allowlisted type: don't echo a type the browser
// might act on. Paired with nosniff + attachment disposition, inert.
contentType = "application/octet-stream"
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment")
} else {
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "inline")
}
if seeker, ok := body.(io.ReadSeeker); ok {
modtime := att.CreatedAt
if modtime.IsZero() {
modtime = time.Now()
}
http.ServeContent(w, r, att.Filename, modtime, seeker)
return
}
if size, sErr := store.Stat(r.Context(), att.StorageKey); sErr == nil && size >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
if r.Method == http.MethodHead {
return
}
if _, copyErr := io.Copy(w, body); copyErr != nil {
slog.Warn("share attachments: streaming copy failed",
"attachment_id", att.ID, "error", copyErr)
}
}
// isShareServableVariant gates the ?variant= query on this public path. It
// EXCLUDES original (unlike isKnownVariant, which includes it) — originals
// are out of the TASK-2637 authorization; only rendered thumbnails ship.
func isShareServableVariant(v string) bool {
switch v {
case models.AttachmentVariantThumbSm, models.AttachmentVariantThumbMd:
return true
}
return false
}
// writeShareAttachmentNotFound is the single 404 writer for this path, so
// every rejection reason produces a byte-identical response.
func writeShareAttachmentNotFound(w http.ResponseWriter) {
writeError(w, http.StatusNotFound, "not_found", "Not found")
}
@@ -0,0 +1,199 @@
package server
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"image"
"image/color"
"image/jpeg"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Real-derivation pins for TASK-2637 / fork-3 Option C. Dave's invariant:
// every byte a share viewer receives has passed through the decode→re-encode
// variant pipeline — metadata (EXIF/GPS) dropped by construction, format
// normalized per ThumbnailFormat, resolution bounded. These wire the REAL
// pure-Go image processor and drive the lazy-derive-at-resolve path, so they
// prove the pipeline runs on real bytes, not hand-seeded variant rows.
// exifMarker is the "Exif\0\0" APP1 identifier. Its presence in a JPEG's
// bytes means an EXIF block survived; its ABSENCE in the derived variant is
// the pin (b) assertion.
var exifMarker = []byte{0x45, 0x78, 0x69, 0x66, 0x00, 0x00}
// seedOriginalWithProcessor is newShareAssetFixture + the pure-Go image
// processor wired, so deriveThumbnails actually produces variants. Returns a
// fixture whose lazy-derive path is live.
func newDerivingShareFixture(t *testing.T) shareAssetFixture {
t.Helper()
f := newShareAssetFixture(t)
wireTestImageProcessor(f.srv)
if f.srv.imageProcessor == nil {
// libvips build wires no processor (its wireTestImageProcessor is a
// no-op); the real-derivation path can't run there.
t.Skip("no image processor on this build — derivation path not exercisable")
}
return f
}
// seedRawOriginal stores body under mime, anchored to itemID, WITHOUT any
// derived variant — the legacy shape the lazy-derive path backfills. Unlike
// putBlob it does not force image/png, so JPEG sources round-trip honestly.
func (f shareAssetFixture) seedRawOriginal(t *testing.T, itemID, mime string, body []byte) string {
t.Helper()
sum := sha256.Sum256(body)
hash := hex.EncodeToString(sum[:])
st, err := f.srv.attachments.Resolve(attachments.FSPrefix + ":" + hash)
if err != nil {
t.Fatalf("resolve store: %v", err)
}
key, err := st.Put(context.Background(), hash, mime, bytes.NewReader(body))
if err != nil {
t.Fatalf("store.Put: %v", err)
}
att := &models.Attachment{
WorkspaceID: f.wsID, ItemID: &itemID, StorageKey: key, ContentHash: hash,
MimeType: mime, SizeBytes: int64(len(body)), Filename: "src", UploadedBy: "system",
}
if err := f.srv.store.CreateAttachment(att); err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
return att.ID
}
// realPNGImage builds a valid PNG of the given size with an alpha gradient,
// so re-encoding as PNG (not JPEG) is observable and alpha survival matters.
func realPNGImage(t *testing.T, w, h int) []byte {
t.Helper()
return makeIntegrationPNG(t, w, h)
}
// jpegWithEXIF encodes a JPEG of the given size and splices an APP1 "Exif"
// segment in right after SOI, so the source demonstrably CARRIES EXIF. The
// decoder skips APP1; the re-encode drops it — which is the pin.
func jpegWithEXIF(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 100, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("jpeg encode: %v", err)
}
raw := buf.Bytes()
// A minimal APP1 Exif payload: "Exif\0\0" + a little filler standing in
// for a TIFF/GPS block. Enough that its marker bytes are searchable.
payload := append(append([]byte(nil), exifMarker...), bytes.Repeat([]byte{0x2a}, 32)...)
seg := make([]byte, 0, len(payload)+4)
seg = append(seg, 0xFF, 0xE1) // APP1 marker
segLen := len(payload) + 2
seg = append(seg, byte(segLen>>8), byte(segLen&0xff))
seg = append(seg, payload...)
// Splice after SOI (first two bytes 0xFFD8).
if len(raw) < 2 || raw[0] != 0xFF || raw[1] != 0xD8 {
t.Fatalf("unexpected JPEG start: % x", raw[:2])
}
out := make([]byte, 0, len(raw)+len(seg))
out = append(out, raw[:2]...)
out = append(out, seg...)
out = append(out, raw[2:]...)
if !bytes.Contains(out, exifMarker) {
t.Fatal("test bug: source JPEG does not contain the EXIF marker we injected")
}
return out
}
// Pin (a): a ≤1024px image (the live 64×64 case) renders on a share page via
// a REAL derived variant — lazy-derived at resolve, no original ever served.
func TestShareAssetDerive_SmallImageRendersViaVariant(t *testing.T) {
f := newDerivingShareFixture(t)
orig := f.seedRawOriginal(t, f.itemID, "image/png", realPNGImage(t, 64, 64))
f.setContent(t, f.itemID, imageRef(orig))
link := f.createLink(t, "item", f.itemID, nil)
// Resolve lazily derives the missing variant → the ref appears.
refs := f.resolvedRefs(t, link.Token, "")
if _, ok := refs[orig]; !ok {
t.Fatalf("small image minted no ref; lazy-derive did not run. refs=%v", refs)
}
// The byte endpoint serves the derived variant (not the original).
rr := f.getAsset(link.Token, orig, "variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("small-image asset: status %d, body %s", rr.Code, rr.Body.String())
}
if ct := rr.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png (PNG source stays PNG)", ct)
}
// A derived variant row now exists — confirm we served bytes, not empty.
if rr.Body.Len() == 0 {
t.Error("served an empty body")
}
}
// Pin (b): an EXIF-bearing source yields variant bytes with NO EXIF. Verify
// the BYTES, not intent.
func TestShareAssetDerive_StripsEXIF(t *testing.T) {
f := newDerivingShareFixture(t)
src := jpegWithEXIF(t, 1600, 1200) // >1024 so a genuine downscale happens too
orig := f.seedRawOriginal(t, f.itemID, "image/jpeg", src)
f.setContent(t, f.itemID, imageRef(orig))
link := f.createLink(t, "item", f.itemID, nil)
if _, ok := f.resolvedRefs(t, link.Token, "")[orig]; !ok {
t.Fatal("EXIF JPEG minted no ref")
}
rr := f.getAsset(link.Token, orig, "variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("asset status %d", rr.Code)
}
served := rr.Body.Bytes()
if bytes.Contains(served, exifMarker) {
t.Error("served variant STILL contains the EXIF marker — metadata not stripped by the pipeline")
}
// JPEG source normalizes to JPEG (ThumbnailFormat).
if ct := rr.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("Content-Type = %q, want image/jpeg (non-PNG → JPEG)", ct)
}
}
// Pin (c): format normalization — PNG source stays PNG (alpha survives),
// non-PNG (JPEG) normalizes to JPEG per ThumbnailFormat.
func TestShareAssetDerive_FormatNormalization(t *testing.T) {
cases := []struct {
name string
mime string
body func(t *testing.T) []byte
wantType string
}{
{"png-stays-png", "image/png", func(t *testing.T) []byte { return realPNGImage(t, 1500, 1000) }, "image/png"},
{"jpeg-stays-jpeg", "image/jpeg", func(t *testing.T) []byte { return jpegWithEXIF(t, 1500, 1000) }, "image/jpeg"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newDerivingShareFixture(t)
orig := f.seedRawOriginal(t, f.itemID, tc.mime, tc.body(t))
f.setContent(t, f.itemID, imageRef(orig))
link := f.createLink(t, "item", f.itemID, (*store.ShareLinkOptions)(nil))
if _, ok := f.resolvedRefs(t, link.Token, "")[orig]; !ok {
t.Fatal("no ref minted")
}
rr := f.getAsset(link.Token, orig, "variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("asset status %d", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != tc.wantType {
t.Errorf("Content-Type = %q, want %q", ct, tc.wantType)
}
})
}
}
@@ -0,0 +1,534 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Tests for the share-link asset byte path (BUG-2389 2b / TASK-2637):
// GET /api/v1/s/{token}/attachments/{id}, variants-only, plus the signed-ref
// gate for protected links. The 7 pins from the TASK-2637 design forks live
// here, each written so removing the guard it targets turns it red.
// --- signing unit tests -------------------------------------------------
func TestShareAssetSig_RoundTrip(t *testing.T) {
t.Parallel()
secret := bytes.Repeat([]byte{0x5a}, 32)
exp := time.Now().Add(time.Minute).Unix()
sig := signShareAsset(secret, "link-1", "att-1", exp)
if sig == "" {
t.Fatal("signShareAsset returned empty with a valid secret")
}
if !verifyShareAsset(secret, "link-1", "att-1", strconv.FormatInt(exp, 10), sig, time.Now()) {
t.Fatal("verifyShareAsset rejected a signature it just minted")
}
}
func TestShareAssetSig_ScopedToAttachment(t *testing.T) {
// fork-2 pin #4 (unit level): a signature minted for attachment X must not
// validate for attachment Y under the same link + expiry.
t.Parallel()
secret := bytes.Repeat([]byte{0x5a}, 32)
exp := time.Now().Add(time.Minute).Unix()
sigX := signShareAsset(secret, "link-1", "att-X", exp)
if verifyShareAsset(secret, "link-1", "att-Y", strconv.FormatInt(exp, 10), sigX, time.Now()) {
t.Fatal("signature for att-X validated for att-Y — scope binding broken")
}
}
func TestShareAssetSig_ScopedToLink(t *testing.T) {
// A signature minted under link A must not validate under link B (a token
// swap). Guards against a leaked sig being replayed on a different share.
t.Parallel()
secret := bytes.Repeat([]byte{0x5a}, 32)
exp := time.Now().Add(time.Minute).Unix()
sigA := signShareAsset(secret, "link-A", "att-1", exp)
if verifyShareAsset(secret, "link-B", "att-1", strconv.FormatInt(exp, 10), sigA, time.Now()) {
t.Fatal("signature for link-A validated under link-B — link binding broken")
}
}
func TestShareAssetSig_Expired(t *testing.T) {
// fork-2 pin #3 (unit level): a signature past its exp is rejected.
t.Parallel()
secret := bytes.Repeat([]byte{0x5a}, 32)
exp := time.Now().Add(-time.Second).Unix()
sig := signShareAsset(secret, "link-1", "att-1", exp)
if verifyShareAsset(secret, "link-1", "att-1", strconv.FormatInt(exp, 10), sig, time.Now()) {
t.Fatal("an expired signature validated")
}
}
func TestShareAssetSig_ExpTampered(t *testing.T) {
// Extending exp without re-signing must fail — exp is inside the MAC.
t.Parallel()
secret := bytes.Repeat([]byte{0x5a}, 32)
exp := time.Now().Add(time.Minute).Unix()
sig := signShareAsset(secret, "link-1", "att-1", exp)
future := strconv.FormatInt(exp+3600, 10)
if verifyShareAsset(secret, "link-1", "att-1", future, sig, time.Now()) {
t.Fatal("a signature validated against a tampered (extended) exp")
}
}
func TestShareAssetSig_UnconfiguredSecret(t *testing.T) {
// A too-short secret (self-host without an encryption key) cannot sign —
// signShareAsset returns "" so the mint side degrades to the placeholder,
// and verify never accepts.
t.Parallel()
short := []byte("tooshort")
exp := time.Now().Add(time.Minute).Unix()
if sig := signShareAsset(short, "link-1", "att-1", exp); sig != "" {
t.Fatalf("signShareAsset with a <16B secret returned %q, want empty", sig)
}
if verifyShareAsset(short, "link-1", "att-1", strconv.FormatInt(exp, 10), "deadbeef", time.Now()) {
t.Fatal("verifyShareAsset accepted with a <16B secret")
}
}
// --- HTTP fixture -------------------------------------------------------
// shareAssetFixture is a workspace with one collection + one item, plus a
// second item, wired for the anchoring matrix. The server has attachments
// and a claim secret so protected-link signing works.
type shareAssetFixture struct {
srv *Server
wsID string
slug string
collID string
itemID string
item2ID string
ownerID string
}
func newShareAssetFixture(t *testing.T) shareAssetFixture {
t.Helper()
srv := testServer(t)
dir := t.TempDir()
fs, err := attachments.NewFSStore(dir)
if err != nil {
t.Fatalf("NewFSStore: %v", err)
}
reg := attachments.NewRegistry()
reg.Register(attachments.FSPrefix, fs)
srv.SetAttachments(reg, 0)
srv.SetClaimSecret(bytes.Repeat([]byte{0x5a}, 32))
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "ShareAsset"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
col, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks", Schema: `{"fields":[]}`})
if err != nil {
t.Fatalf("CreateCollection: %v", err)
}
item, err := srv.store.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Shared", Fields: `{}`})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
item2, err := srv.store.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Other", Fields: `{}`})
if err != nil {
t.Fatalf("CreateItem2: %v", err)
}
owner, err := srv.store.CreateUser(models.UserCreate{Email: "owner@test.com", Name: "Owner", Password: "pw-owner"})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return shareAssetFixture{
srv: srv, wsID: ws.ID, slug: ws.Slug, collID: col.ID,
itemID: item.ID, item2ID: item2.ID, ownerID: owner.ID,
}
}
// seedImage creates an image original anchored to itemID plus its thumb-md
// variant, each with distinct bytes, and returns (originalID, thumbMdBytes).
func (f shareAssetFixture) seedImage(t *testing.T, itemID string, seed byte) (string, []byte) {
t.Helper()
origBody := distinctPNG(t, seed)
mdBody := distinctPNG(t, seed+1)
orig := putBlob(t, f.srv, &models.Attachment{
WorkspaceID: f.wsID, ItemID: &itemID, Filename: "orig.png",
}, origBody)
variant := models.AttachmentVariantThumbMd
putBlob(t, f.srv, &models.Attachment{
WorkspaceID: f.wsID, ItemID: &itemID, ParentID: &orig.ID,
Variant: &variant, Filename: "orig-thumb-md.png",
}, mdBody)
return orig.ID, mdBody
}
// setContent overwrites an item's markdown body.
func (f shareAssetFixture) setContent(t *testing.T, itemID, content string) {
t.Helper()
if _, err := f.srv.store.UpdateItem(itemID, models.ItemUpdate{Content: &content}); err != nil {
t.Fatalf("UpdateItem content: %v", err)
}
}
func imageRef(id string) string { return fmt.Sprintf("![pic](pad-attachment:%s)", id) }
// createLink mints a share link for the fixture's item or collection.
func (f shareAssetFixture) createLink(t *testing.T, targetType, targetID string, opts *store.ShareLinkOptions) *models.ShareLink {
t.Helper()
link, err := f.srv.store.CreateShareLink(f.wsID, targetType, targetID, "view", f.ownerID, opts)
if err != nil {
t.Fatalf("CreateShareLink: %v", err)
}
return link
}
// resolvedRefs resolves a share link (optionally with a password header) and
// returns its attachment_refs map.
func (f shareAssetFixture) resolvedRefs(t *testing.T, token, password string) map[string]shareAttachmentRef {
t.Helper()
headers := map[string]string{}
if password != "" {
headers["X-Share-Password"] = password
}
rr := doRequestWithHeaders(f.srv, "GET", "/api/v1/s/"+token, nil, headers)
if rr.Code != http.StatusOK {
t.Fatalf("resolve share link: status %d, body %s", rr.Code, rr.Body.String())
}
var resp struct {
Refs map[string]shareAttachmentRef `json:"attachment_refs"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse resolve payload: %v", err)
}
return resp.Refs
}
// getAsset issues GET on the asset endpoint. rawQuery is appended verbatim
// (already URL-formed, no leading '?').
func (f shareAssetFixture) getAsset(token, attID, rawQuery string) *httptest.ResponseRecorder {
path := "/api/v1/s/" + token + "/attachments/" + attID
if rawQuery != "" {
path += "?" + rawQuery
}
return doRequest(f.srv, "GET", path, nil)
}
// --- fork-1: plain links, max_views rides the page view -----------------
func TestShareAsset_PlainMultiImageUnderMaxViews1(t *testing.T) {
// fork-1 pin (a): a page under max_views=1 renders ALL its images. The
// single page open burns the one view; the asset fetches must NOT
// re-check or decrement it, or the 2nd (and every later) image 404s.
t.Parallel()
f := newShareAssetFixture(t)
a1, md1 := f.seedImage(t, f.itemID, 0x11)
a2, md2 := f.seedImage(t, f.itemID, 0x21)
f.setContent(t, f.itemID, imageRef(a1)+"\n\n"+imageRef(a2))
one := 1
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{MaxViews: &one})
// One page open — burns the single view.
refs := f.resolvedRefs(t, link.Token, "")
if _, ok := refs[a1]; !ok {
t.Fatalf("ref for a1 missing; refs=%v", refs)
}
if _, ok := refs[a2]; !ok {
t.Fatalf("ref for a2 missing; refs=%v", refs)
}
// Both assets must serve, AFTER the view budget is spent.
for _, tc := range []struct {
id string
want []byte
}{{a1, md1}, {a2, md2}} {
rr := f.getAsset(link.Token, tc.id, "variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("asset %s: status %d (assets must not consume max_views), body %s", tc.id, rr.Code, rr.Body.String())
}
if !bytes.Equal(rr.Body.Bytes(), tc.want) {
t.Errorf("asset %s: served wrong bytes (want the thumb-md variant, got %d bytes)", tc.id, rr.Body.Len())
}
}
}
func TestShareAsset_PlainServesThumbMdByDefault(t *testing.T) {
// fork-2 pin #5 baseline: a plain link's asset loads with no signature,
// and defaults to the thumb-md variant when no variant param is given.
t.Parallel()
f := newShareAssetFixture(t)
a1, md1 := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, nil)
refs := f.resolvedRefs(t, link.Token, "")
if ref, ok := refs[a1]; !ok || ref.Sig != "" {
t.Fatalf("plain link ref should exist with empty sig; got %+v ok=%v", ref, ok)
}
rr := f.getAsset(link.Token, a1, "") // no variant param → thumb-md default
if rr.Code != http.StatusOK {
t.Fatalf("plain asset (no query): status %d, body %s", rr.Code, rr.Body.String())
}
if !bytes.Equal(rr.Body.Bytes(), md1) {
t.Errorf("default served the wrong variant: got %d bytes, want thumb-md", rr.Body.Len())
}
if cc := rr.Header().Get("Cache-Control"); cc != "private, no-store" {
t.Errorf("Cache-Control = %q, want private, no-store", cc)
}
}
func TestShareAsset_ExpiredAndRevoked404(t *testing.T) {
// fork-1 pin (b): an expired OR revoked token 404s on the asset path even
// with a valid attachment id. Revocation is explicit (delete), not merely
// expiry.
t.Parallel()
t.Run("expired", func(t *testing.T) {
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
past := time.Now().Add(-time.Hour).Format(time.RFC3339)
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{ExpiresAt: &past})
rr := f.getAsset(link.Token, a1, "variant=thumb-md")
if rr.Code != http.StatusNotFound {
t.Fatalf("expired token asset: status %d, want 404", rr.Code)
}
})
t.Run("revoked", func(t *testing.T) {
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, nil)
// Valid before revocation.
if rr := f.getAsset(link.Token, a1, "variant=thumb-md"); rr.Code != http.StatusOK {
t.Fatalf("pre-revoke asset: status %d, want 200", rr.Code)
}
if err := f.srv.store.DeleteShareLink(link.ID, f.wsID); err != nil {
t.Fatalf("DeleteShareLink: %v", err)
}
if rr := f.getAsset(link.Token, a1, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("revoked token asset: status %d, want 404", rr.Code)
}
})
}
// --- fork-2: protected links require a signed ref -----------------------
func TestShareAsset_ProtectedRequiresSignature(t *testing.T) {
// fork-2 pin #1: a protected-link asset URL with the correct token + id
// but NO signature 404s. And pin #2: the signed ref the resolve response
// mints loads via a plain GET (what an <img src> issues).
t.Parallel()
f := newShareAssetFixture(t)
a1, md1 := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{Password: "hunter2"})
// Unsigned (bare token+id) → 404. This is the hole option B would reopen.
if rr := f.getAsset(link.Token, a1, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("protected asset without signature: status %d, want 404", rr.Code)
}
// The minted ref (obtained only AFTER satisfying the password) carries a
// signature; it loads via a plain GET.
refs := f.resolvedRefs(t, link.Token, "hunter2")
ref, ok := refs[a1]
if !ok || ref.Sig == "" {
t.Fatalf("protected link should mint a signed ref; got %+v ok=%v", ref, ok)
}
rr := f.getAsset(link.Token, a1, ref.Sig+"&variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("signed protected asset: status %d, body %s", rr.Code, rr.Body.String())
}
if !bytes.Equal(rr.Body.Bytes(), md1) {
t.Errorf("signed protected asset served wrong bytes: got %d, want thumb-md", rr.Body.Len())
}
}
func TestShareAsset_ProtectedRequireAuthAlsoGated(t *testing.T) {
// require_auth (not just password) is a protected link too: its asset path
// demands a signature. Without one → 404.
t.Parallel()
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{RequireAuth: true})
if rr := f.getAsset(link.Token, a1, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("require_auth asset without signature: status %d, want 404", rr.Code)
}
}
func TestShareAsset_ExpiredSignature404(t *testing.T) {
// fork-2 pin #3 (HTTP level): a signature past its exp 404s on the wire.
t.Parallel()
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{Password: "hunter2"})
pastExp := time.Now().Add(-time.Second).Unix()
sig := signShareAsset(f.srv.claimSecret, link.ID, a1, pastExp)
q := "exp=" + strconv.FormatInt(pastExp, 10) + "&sig=" + sig + "&variant=thumb-md"
if rr := f.getAsset(link.Token, a1, q); rr.Code != http.StatusNotFound {
t.Fatalf("expired-signature asset: status %d, want 404", rr.Code)
}
}
func TestShareAsset_SignatureForXDoesNotFetchY(t *testing.T) {
// fork-2 pin #4 (HTTP level): a signature minted for attachment X must not
// authorize attachment Y — even though both are anchored images under the
// same protected link.
t.Parallel()
f := newShareAssetFixture(t)
aX, _ := f.seedImage(t, f.itemID, 0x11)
aY, _ := f.seedImage(t, f.itemID, 0x21)
f.setContent(t, f.itemID, imageRef(aX)+"\n\n"+imageRef(aY))
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{Password: "hunter2"})
refs := f.resolvedRefs(t, link.Token, "hunter2")
sigX := refs[aX].Sig
if sigX == "" {
t.Fatal("no signature minted for aX")
}
// Present X's signature against Y's id.
if rr := f.getAsset(link.Token, aY, sigX+"&variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("aX's signature fetched aY: status %d, want 404", rr.Code)
}
// Sanity: X's signature does fetch X.
if rr := f.getAsset(link.Token, aX, sigX+"&variant=thumb-md"); rr.Code != http.StatusOK {
t.Fatalf("aX's signature failed to fetch aX: status %d", rr.Code)
}
}
// --- anchoring & variants-only ------------------------------------------
func TestShareAsset_ItemShareRejectsForeignItemAttachment(t *testing.T) {
// An item share serves only its own item's attachments. An attachment
// anchored to a DIFFERENT item (even in the same workspace) 404s — this is
// what stops an item share from leaking a sibling item's bytes.
t.Parallel()
f := newShareAssetFixture(t)
foreign, _ := f.seedImage(t, f.item2ID, 0x31) // anchored to item2
link := f.createLink(t, "item", f.itemID, nil)
if rr := f.getAsset(link.Token, foreign, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("item share served a foreign item's attachment: status %d, want 404", rr.Code)
}
}
func TestShareAsset_CollectionShareServesItemsInCollection(t *testing.T) {
// A collection share serves attachments of any item IN the collection —
// including item2, which an item share of item1 would reject.
t.Parallel()
f := newShareAssetFixture(t)
a2, md2 := f.seedImage(t, f.item2ID, 0x31)
f.setContent(t, f.item2ID, imageRef(a2))
link := f.createLink(t, "collection", f.collID, nil)
refs := f.resolvedRefs(t, link.Token, "")
if _, ok := refs[a2]; !ok {
t.Fatalf("collection share did not mint a ref for an in-collection item's attachment; refs=%v", refs)
}
rr := f.getAsset(link.Token, a2, "variant=thumb-md")
if rr.Code != http.StatusOK {
t.Fatalf("collection share asset: status %d, body %s", rr.Code, rr.Body.String())
}
if !bytes.Equal(rr.Body.Bytes(), md2) {
t.Errorf("collection share served wrong bytes")
}
}
func TestShareAsset_SoftDeletedAttachment404(t *testing.T) {
t.Parallel()
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, nil)
if err := f.srv.store.SoftDeleteAttachment(a1); err != nil {
t.Fatalf("SoftDeleteAttachment: %v", err)
}
if rr := f.getAsset(link.Token, a1, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("soft-deleted attachment: status %d, want 404", rr.Code)
}
}
func TestShareAsset_OriginalVariantExcluded(t *testing.T) {
// variant=original is out of scope (originals never ship). It 404s rather
// than 400 to keep every rejection on this public path non-probing.
t.Parallel()
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, nil)
if rr := f.getAsset(link.Token, a1, "variant=original"); rr.Code != http.StatusNotFound {
t.Fatalf("variant=original: status %d, want 404 (originals out of scope)", rr.Code)
}
}
func TestShareAsset_NoVariantRow404(t *testing.T) {
// An image with no variant AND no way to derive one (this fixture wires no
// image processor) 404s — never an original fallback. Under Option C a
// variant is normally lazy-derived at resolve (see the derive_test file),
// but when derivation is unavailable or fails the invariant still holds:
// the page shows the placeholder, and the byte endpoint never serves the
// original.
t.Parallel()
f := newShareAssetFixture(t)
// Seed an original only, no variant.
body := distinctPNG(t, 0x41)
orig := putBlob(t, f.srv, &models.Attachment{
WorkspaceID: f.wsID, ItemID: &f.itemID, Filename: "novariant.png",
}, body)
f.setContent(t, f.itemID, imageRef(orig.ID))
link := f.createLink(t, "item", f.itemID, nil)
// Not minted (no variant).
refs := f.resolvedRefs(t, link.Token, "")
if _, ok := refs[orig.ID]; ok {
t.Errorf("minted a ref for an attachment with no thumb-md variant")
}
// And the byte endpoint 404s rather than serving the original.
if rr := f.getAsset(link.Token, orig.ID, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("no-variant asset: status %d, want 404 (no original fallback)", rr.Code)
}
}
func TestShareAsset_UnknownTokenAndAttachment404(t *testing.T) {
t.Parallel()
f := newShareAssetFixture(t)
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, nil)
if rr := f.getAsset("nope-not-a-token", a1, "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("unknown token: status %d, want 404", rr.Code)
}
if rr := f.getAsset(link.Token, "00000000-0000-0000-0000-000000000000", "variant=thumb-md"); rr.Code != http.StatusNotFound {
t.Fatalf("unknown attachment: status %d, want 404", rr.Code)
}
}
// --- mint-side: unconfigured secret degrades protected links ------------
func TestShareAsset_ProtectedWithoutSecretOmitsRef(t *testing.T) {
// fork-2 fallback boundary: when the deployment secret is unset, a
// protected link cannot mint a signed ref, so the ref is OMITTED entirely
// (the page shows the #1135 placeholder) — never an unsigned bare URL.
t.Parallel()
f := newShareAssetFixture(t)
f.srv.SetClaimSecret(nil) // unconfigured
a1, _ := f.seedImage(t, f.itemID, 0x11)
f.setContent(t, f.itemID, imageRef(a1))
link := f.createLink(t, "item", f.itemID, &store.ShareLinkOptions{Password: "hunter2"})
refs := f.resolvedRefs(t, link.Token, "hunter2")
if _, ok := refs[a1]; ok {
t.Errorf("protected link minted a ref with no secret configured; must omit and degrade to placeholder")
}
}
+130 -4
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/server/render"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/go-chi/chi/v5"
)
@@ -527,14 +528,21 @@ func (s *Server) handleResolveShareLink(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusNotFound, "not_found", "Not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
resp := map[string]interface{}{
"type": "item",
"item": publicShareItemDTO(item),
"permission": "view",
"share_link": map[string]interface{}{
"target_type": link.TargetType,
},
})
}
// Renderable image-attachment refs embedded in the item's content
// (BUG-2389 2b / TASK-2637). Omitted entirely when nothing is
// renderable, so the page falls back to the honest placeholder.
if refs := s.mintShareAttachmentRefs(link, item.Content); refs != nil {
resp["attachment_refs"] = refs
}
writeJSON(w, http.StatusOK, resp)
case "collection":
coll, err := s.store.GetCollection(link.TargetID)
@@ -622,7 +630,7 @@ func (s *Server) handleResolveShareLink(w http.ResponseWriter, r *http.Request)
}
publicCollection["views"] = publicViews
writeJSON(w, http.StatusOK, map[string]interface{}{
collResp := map[string]interface{}{
"type": "collection",
"collection": publicCollection,
"items": publicItems,
@@ -630,13 +638,131 @@ func (s *Server) handleResolveShareLink(w http.ResponseWriter, r *http.Request)
"share_link": map[string]interface{}{
"target_type": link.TargetType,
},
})
}
// Renderable image-attachment refs across every shared item's content
// (BUG-2389 2b / TASK-2637). Anchoring for a collection share accepts
// any item IN the collection, enforced per-attachment in
// shareAttachmentAnchors.
contents := make([]string, 0, len(items))
for _, it := range items {
contents = append(contents, it.Content)
}
if refs := s.mintShareAttachmentRefs(link, contents...); refs != nil {
collResp["attachment_refs"] = refs
}
writeJSON(w, http.StatusOK, collResp)
default:
writeError(w, http.StatusNotFound, "not_found", "Not found")
}
}
// shareAttachmentRef is the per-attachment entry in a share payload's
// `attachment_refs` map (BUG-2389 2b / TASK-2637). Its PRESENCE (keyed by
// attachment UUID) tells the public share page an embedded reference is
// renderable: it anchors to the shared target, is a live image, and has the
// rendered variant the byte endpoint serves. The frontend builds the
// `<img src>` from the key + Sig; unreferenced/unanchored UUIDs are simply
// absent, and the page renders the honest "not available" placeholder.
type shareAttachmentRef struct {
// Sig is the signed query fragment ("exp=<unix>&sig=<hex>") a PROTECTED
// link's asset URL must carry; empty for PLAIN links, which need no
// signature. The frontend merges it with the variant param.
Sig string `json:"sig,omitempty"`
// MimeType / Filename / Width / Height are the minimal metadata the
// client resolver needs to render the embed (image gate, alt fallback,
// layout reservation). Width/Height are the VARIANT's dimensions — that
// is the image actually served — so the reserved box matches the bytes.
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
}
// mintShareAttachmentRefs builds the `attachment_refs` map for a resolved
// share payload. It scans the shared content bodies for pad-attachment
// references and, for each one that ANCHORS to this link's target, is a
// LIVE IMAGE, and HAS the rendered variant the endpoint serves, emits a ref.
// For PROTECTED links each ref carries a short-lived HMAC signature (fork-2
// Option C); for PLAIN links Sig stays empty (fork-1). Returns nil when
// nothing is renderable, so the payload omits the key entirely.
//
// Anchoring reuses shareAttachmentAnchors — the SAME predicate the byte
// endpoint enforces — so the mint side can never advertise a ref the serve
// side would reject, and vice-versa.
func (s *Server) mintShareAttachmentRefs(link *models.ShareLink, contents ...string) map[string]shareAttachmentRef {
seen := make(map[string]struct{})
var uuids []string
for _, content := range contents {
for _, id := range render.ExtractAttachmentRefs(content) {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uuids = append(uuids, id)
}
}
if len(uuids) == 0 {
return nil
}
protected := link.HasPassword || link.RequireAuth
var exp int64
var expStr string
if protected {
exp = time.Now().Add(shareAssetSigTTL).Unix()
expStr = strconv.FormatInt(exp, 10)
}
refs := make(map[string]shareAttachmentRef)
for _, id := range uuids {
att, err := s.store.GetAttachment(id)
if err != nil || !s.shareAttachmentAnchors(link, att) {
continue
}
variant, err := s.store.GetAttachmentVariant(link.WorkspaceID, att.ID, shareAssetVariant)
if err == nil && variant == nil {
// Legacy attachment with no derived variant yet (uploaded before
// thumb-md was always-derived, or a small source under the old
// skip rule). Derive it NOW — synchronously, so this first resolve
// renders the image rather than a placeholder. deriveThumbnails is
// idempotent and bounded; new uploads already have the row and pay
// nothing here (TASK-2637 / fork-3 Option C: a share viewer must
// never receive an original, so we derive rather than fall back).
s.deriveThumbnails(att.ID)
variant, err = s.store.GetAttachmentVariant(link.WorkspaceID, att.ID, shareAssetVariant)
}
if err != nil || variant == nil {
// Still nothing (derivation unsupported/failed, e.g. a format the
// processor can't decode) — don't advertise it; the page shows the
// honest placeholder. Never an original.
continue
}
ref := shareAttachmentRef{
MimeType: att.MimeType,
Filename: att.Filename,
Width: variant.Width,
Height: variant.Height,
}
if protected {
sig := signShareAsset(s.claimSecret, link.ID, att.ID, exp)
if sig == "" {
// Secret unconfigured (self-host without an encryption key):
// cannot mint a protected ref. Omit it — the page degrades to
// the #1135 placeholder, NEVER an unsigned bare URL (fork-2
// fallback boundary).
continue
}
ref.Sig = "exp=" + expStr + "&sig=" + sig
}
refs[att.ID] = ref
}
if len(refs) == 0 {
return nil
}
return refs
}
// handleShareLinkViews returns view history for a share link.
// GET /workspaces/{ws}/share-links/{linkID}/views
func (s *Server) handleShareLinkViews(w http.ResponseWriter, r *http.Request) {
+31
View File
@@ -318,6 +318,37 @@ func ResolveAttachmentReferences(markdown, workspaceSlug string, resolve Attachm
return b.String()
}
// ExtractAttachmentRefs returns the unique attachment UUIDs referenced in
// `markdown` via `pad-attachment:UUID` image/link syntax, in first-seen
// order. References inside fenced code blocks are skipped — same rule as
// ResolveAttachmentReferences, so a docs page that DEMONSTRATES the syntax
// doesn't cause a live attachment to be treated as embedded.
//
// This is the read-side companion the share-link minter uses to decide
// which attachments a public page will actually render, so it can mint a
// signed/anchored ref for exactly those and nothing more (TASK-2637).
func ExtractAttachmentRefs(markdown string) []string {
var out []string
seen := make(map[string]struct{})
for _, c := range splitCodeFences(markdown) {
if c.fenced {
continue
}
for _, m := range markdownAttachmentRefRE.FindAllStringSubmatch(c.text, -1) {
uuid := strings.TrimSpace(m[3])
if uuid == "" {
continue
}
if _, ok := seen[uuid]; ok {
continue
}
seen[uuid] = struct{}{}
out = append(out, uuid)
}
}
return out
}
// codeChunk is a single span of input — either inside a fenced code
// block (fenced=true) or outside one. ResolveAttachmentReferences
// skips substitution inside fenced spans so docs that demonstrate the
@@ -483,3 +483,31 @@ func TestSplitCodeFences_NestedAndUnclosed(t *testing.T) {
t.Errorf("second chunk wrong: %#v", chunks[1])
}
}
func TestExtractAttachmentRefs(t *testing.T) {
md := "intro ![a](pad-attachment:id-1) then [f](pad-attachment:id-2)\n\n" +
"a repeat ![again](pad-attachment:id-1)\n\n" +
"```\n![fenced](pad-attachment:id-FENCED)\n```\n\n" +
"![tail](pad-attachment:id-3)"
got := ExtractAttachmentRefs(md)
want := []string{"id-1", "id-2", "id-3"} // deduped, first-seen order, fenced skipped
if len(got) != len(want) {
t.Fatalf("ExtractAttachmentRefs = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("ExtractAttachmentRefs[%d] = %q, want %q (full: %v)", i, got[i], want[i], got)
}
}
for _, id := range got {
if id == "id-FENCED" {
t.Error("a fenced-code reference leaked into the extracted refs")
}
}
}
func TestExtractAttachmentRefs_None(t *testing.T) {
if got := ExtractAttachmentRefs("no attachments here"); got != nil {
t.Errorf("expected nil for content with no refs, got %v", got)
}
}
+6
View File
@@ -1417,6 +1417,12 @@ func (s *Server) setupRouter() {
// Share link resolution (outside workspace scope, no auth required)
r.Get("/s/{token}", s.handleResolveShareLink)
// Share-link asset bytes (BUG-2389 2b / TASK-2637): rendered image
// VARIANTS for attachments embedded in the shared content. Same
// public/no-auth group; protected links gate on a short-lived
// signed ref minted by handleResolveShareLink. Originals and
// file downloads are out of scope by authorization.
r.Get("/s/{token}/attachments/{attachmentID}", s.handleGetShareLinkAttachment)
// Claim-code redemption (PLAN-1519 / TASK-1521 / IDEA-1517 §4).
// POST /api/v1/oauth/claim with body {workspace, code} grants
+35 -9
View File
@@ -38,6 +38,20 @@ export interface AttachmentMeta {
*/
export type AttachmentResolver = (uuid: string) => AttachmentMeta | null | undefined;
/**
* Builds the byte URL for an attachment reference, overriding the default
* workspace-scoped path (`attachmentDownloadUrl`). Public share pages supply
* one that points at the token-scoped `/api/v1/s/{token}/attachments/{id}`
* endpoint and carries the short-lived signature for protected links
* (BUG-2389 2b / TASK-2637). When absent, callers fall back to
* `attachmentDownloadUrl(workspaceSlug, …)`, so every existing surface is
* unaffected.
*/
export type AttachmentUrlBuilder = (
uuid: string,
variant?: 'thumb-sm' | 'thumb-md' | 'original'
) => string;
/** URL scheme used in markdown content. Mirrored on the Go side. */
const ATTACHMENT_URL_PREFIX = 'pad-attachment:';
@@ -126,9 +140,12 @@ export function renderAttachmentImage(
meta: AttachmentMeta,
alt: string,
workspaceSlug: string,
variant: 'thumb-sm' | 'thumb-md' = 'thumb-md'
variant: 'thumb-sm' | 'thumb-md' = 'thumb-md',
urlBuilder?: AttachmentUrlBuilder
): string {
const src = attachmentDownloadUrl(workspaceSlug, meta.id, variant);
const src = urlBuilder
? urlBuilder(meta.id, variant)
: attachmentDownloadUrl(workspaceSlug, meta.id, variant);
const altText = alt && alt.trim() !== '' ? alt : meta.filename;
const sizeAttrs =
meta.width && meta.height && meta.width > 0 && meta.height > 0
@@ -145,9 +162,10 @@ export function renderAttachmentImage(
export function renderAttachmentChip(
meta: AttachmentMeta,
displayText: string | undefined,
workspaceSlug: string
workspaceSlug: string,
urlBuilder?: AttachmentUrlBuilder
): string {
const href = attachmentDownloadUrl(workspaceSlug, meta.id);
const href = urlBuilder ? urlBuilder(meta.id) : attachmentDownloadUrl(workspaceSlug, meta.id);
const label = displayText && displayText.trim() !== '' ? displayText : meta.filename;
const size = formatAttachmentSize(meta.size_bytes);
const sizeSpan = size ? ` <span class="file-chip-size">· ${escapeHtml(size)}</span>` : '';
@@ -193,14 +211,21 @@ export function resolveAttachmentImage(
workspaceSlug: string,
resolver: AttachmentResolver,
variant: 'thumb-sm' | 'thumb-md' = 'thumb-md',
missing: (uuid: string, alt: string) => string = renderAttachmentMissing
missing: (uuid: string, alt: string) => string = renderAttachmentMissing,
urlBuilder?: AttachmentUrlBuilder
): string {
const uuid = parseAttachmentHref(href);
if (uuid === null) return '';
const meta = resolver(uuid);
if (!meta) return missing(uuid, alt);
if (isImageMime(meta.mime_type)) return renderAttachmentImage(meta, alt, workspaceSlug, variant);
return renderAttachmentChip(meta, alt && alt.trim() !== '' ? alt : meta.filename, workspaceSlug);
if (isImageMime(meta.mime_type))
return renderAttachmentImage(meta, alt, workspaceSlug, variant, urlBuilder);
return renderAttachmentChip(
meta,
alt && alt.trim() !== '' ? alt : meta.filename,
workspaceSlug,
urlBuilder
);
}
/**
@@ -214,11 +239,12 @@ export function resolveAttachmentLink(
text: string,
workspaceSlug: string,
resolver: AttachmentResolver,
missing: (uuid: string, alt: string) => string = renderAttachmentMissing
missing: (uuid: string, alt: string) => string = renderAttachmentMissing,
urlBuilder?: AttachmentUrlBuilder
): string {
const uuid = parseAttachmentHref(href);
if (uuid === null) return '';
const meta = resolver(uuid);
if (!meta) return missing(uuid, text);
return renderAttachmentChip(meta, text, workspaceSlug);
return renderAttachmentChip(meta, text, workspaceSlug, urlBuilder);
}
+15
View File
@@ -136,6 +136,21 @@ export interface SharePayload {
};
collection?: PublicShareCollection;
items?: PublicShareItem[];
// Renderable image-attachment refs embedded in the shared content
// (BUG-2389 2b / TASK-2637). Keyed by attachment UUID; presence = the
// server minted a ref (anchored image with a served variant). `sig` is
// the signed "exp=..&sig=.." fragment a protected link's asset URL must
// carry — absent for plain links. Omitted entirely when nothing is
// renderable.
attachment_refs?: Record<string, ShareAttachmentRef>;
}
export interface ShareAttachmentRef {
sig?: string;
mime_type: string;
filename: string;
width?: number | null;
height?: number | null;
}
// ─── Grants ──────────────────────────────────────────────────────────────────
@@ -128,3 +128,77 @@ describe('share-page attachment rendering (BUG-2389)', () => {
expect(html).toContain('<img src="pad-attachment:');
});
});
// TASK-2637 (BUG-2389 2b) — the share page now supplies a urlBuilder that
// targets the token-scoped byte endpoint (with the signed suffix for
// protected links) instead of the workspace path. These pin the frontend
// seam: the override reaches the <img src>, carries the requested variant,
// and merges a signed fragment robustly.
describe('share-page attachment urlBuilder (TASK-2637)', () => {
const meta: AttachmentMeta = {
id: UUID,
filename: 'diagram.png',
mime_type: 'image/png',
size_bytes: 1234
} as AttachmentMeta;
it('routes the <img src> through urlBuilder, not the workspace path', () => {
const html = renderMarkedWithAttachments(IMG_MD, {
resolver: () => meta,
workspaceSlug: 'ws-1',
urlBuilder: (uuid) => `/api/v1/s/tok/attachments/${uuid}`
});
expect(html).toContain(`<img src="/api/v1/s/tok/attachments/${UUID}"`);
// The default workspace URL must NOT appear — the override won.
expect(html).not.toContain('/api/v1/workspaces/ws-1/attachments/');
});
it('passes the image variant to urlBuilder', () => {
let seen: string | undefined = 'UNCALLED';
renderMarkedWithAttachments(IMG_MD, {
resolver: () => meta,
workspaceSlug: '',
imageVariant: 'thumb-md',
urlBuilder: (uuid, variant) => {
seen = variant;
return `/x/${uuid}`;
}
});
expect(seen).toBe('thumb-md');
});
it('merges a signed fragment with the variant param (protected-link shape)', () => {
// Mirrors the share page's URLSearchParams merge: sig fragment + variant.
const sig = 'exp=123&sig=abc';
const build = (uuid: string, variant?: string) => {
const params = new URLSearchParams();
if (variant) params.set('variant', variant);
if (sig) for (const [k, v] of new URLSearchParams(sig)) params.set(k, v);
const qs = params.toString();
return `/api/v1/s/tok/attachments/${uuid}${qs ? `?${qs}` : ''}`;
};
const html = renderMarkedWithAttachments(IMG_MD, {
resolver: () => meta,
workspaceSlug: '',
imageVariant: 'thumb-md',
urlBuilder: build
});
expect(html).toContain(`/api/v1/s/tok/attachments/${UUID}?`);
expect(html).toContain('variant=thumb-md');
expect(html).toContain('exp=123');
expect(html).toContain('sig=abc');
});
it('a uuid absent from the refs map still renders the honest placeholder', () => {
// The share page resolver returns null for an unanchored/unrenderable
// id; that must fall to the unavailable placeholder, never a broken img.
const html = renderMarkedWithAttachments(IMG_MD, {
resolver: () => null, // not in refs
workspaceSlug: '',
missing: renderAttachmentUnavailable,
urlBuilder: (uuid) => `/api/v1/s/tok/attachments/${uuid}`
});
expect(html).toContain('attachment-unavailable');
expect(html).not.toContain('/api/v1/s/tok/attachments/');
});
});
+13 -3
View File
@@ -4,6 +4,7 @@ import type { Item } from '$lib/types';
import { itemUrlId } from '$lib/types';
import {
type AttachmentResolver,
type AttachmentUrlBuilder,
isAttachmentHref,
resolveAttachmentImage,
resolveAttachmentLink
@@ -43,6 +44,11 @@ type AttachmentRenderContext = {
// would be false there; the attachment exists, the surface just has
// no byte access yet (BUG-2389).
missing?: (uuid: string, alt: string) => string;
// Optional byte-URL override. Share pages pass one that targets the
// token-scoped /api/v1/s/{token}/attachments/{id} endpoint (with the
// signed suffix for protected links); without it, URLs are built from
// workspaceSlug via attachmentDownloadUrl (BUG-2389 2b / TASK-2637).
urlBuilder?: AttachmentUrlBuilder;
};
let currentAttachmentCtx: AttachmentRenderContext | null = null;
@@ -78,7 +84,8 @@ renderer.link = function (this: Renderer, { href, title, text: rawText, tokens }
rawText,
currentAttachmentCtx.workspaceSlug,
currentAttachmentCtx.resolver,
currentAttachmentCtx.missing
currentAttachmentCtx.missing,
currentAttachmentCtx.urlBuilder
);
}
const text = this.parser.parseInline(tokens);
@@ -109,7 +116,8 @@ renderer.image = function (this: Renderer, { href, title, text }: Tokens.Image)
currentAttachmentCtx.workspaceSlug,
currentAttachmentCtx.resolver,
currentAttachmentCtx.imageVariant,
currentAttachmentCtx.missing
currentAttachmentCtx.missing,
currentAttachmentCtx.urlBuilder
);
}
// Default image rendering. Mirrors marked's stdout behavior so
@@ -422,6 +430,7 @@ export function renderMarkedWithAttachments(
workspaceSlug: string;
imageVariant?: 'thumb-sm' | 'thumb-md';
missing?: (uuid: string, alt: string) => string;
urlBuilder?: AttachmentUrlBuilder;
}
): string {
// Save/restore rather than clear-to-null so a nested render (a resolver
@@ -434,7 +443,8 @@ export function renderMarkedWithAttachments(
resolver: ctx.resolver,
workspaceSlug: ctx.workspaceSlug,
imageVariant: ctx.imageVariant ?? 'thumb-md',
missing: ctx.missing
missing: ctx.missing,
urlBuilder: ctx.urlBuilder
};
try {
return marked(content) as string;
+58 -8
View File
@@ -14,7 +14,13 @@
type PublicCollection,
type PublicItem
} from '$lib/components/share/shareView';
import type { PublicShareCollection, PublicShareItem, PublicShareView } from '$lib/types';
import type {
PublicShareCollection,
PublicShareItem,
PublicShareView,
ShareAttachmentRef,
SharePayload
} from '$lib/types';
let token = $derived(page.params.token ?? '');
@@ -278,6 +284,22 @@
syncUrl();
}
// Renderable image-attachment refs from the share payload (BUG-2389 2b /
// TASK-2637). Keyed by attachment UUID; PRESENCE means the server minted a
// ref (the attachment anchors to this share, is a live image, and has the
// served variant). `sig` is the signed "exp=..&sig=.." fragment a protected
// link's asset URL must carry — empty/absent for plain links. Shape lives
// in $lib/types (ShareAttachmentRef) alongside SharePayload.
let attachmentRefs = $state<Record<string, ShareAttachmentRef>>({});
// Capture attachment_refs from a resolved payload. Called from both load
// paths (onMount and the password submit) so the map is populated BEFORE
// itemData/collectionData, keeping the render derivations in sync.
function applyAttachmentRefs(data: SharePayload | null | undefined) {
const refs = data?.attachment_refs;
attachmentRefs = refs && typeof refs === 'object' ? refs : {};
}
// Single sanitized markdown pipeline for the whole page: marked → DOMPurify.
// Used by both the single-ITEM share view (`renderedContent`) and the
// collection inline-expand (`renderItemContent`). Keeping ONE {@html} source
@@ -285,14 +307,40 @@
function sanitizeMarkdown(content: string): string {
if (!content) return '';
try {
// Attachment-aware render (BUG-2389): `pad-attachment:` refs no
// longer fall through to broken <img>/dead links. Share viewers
// have no byte access until the token-scoped read path ships, so
// the resolver yields nothing and every reference renders as the
// HONEST "not available on shared pages" placeholder — never the
// "missing or deleted" one, which would be false here.
// Attachment-aware render (BUG-2389 → 2b / TASK-2637). Image
// attachments the server minted a ref for (anchored to this share,
// live, with a rendered variant) resolve to the token-scoped byte
// endpoint; protected links carry the signed suffix from the ref.
// Anything absent from the refs map — unanchored, non-image, no
// variant, or a protected link whose secret is unconfigured —
// resolves to null and renders the HONEST "not available on shared
// pages" placeholder, never the "missing or deleted" one.
const raw = renderMarkedWithAttachments(content, {
resolver: () => null,
resolver: (uuid) => {
const ref = attachmentRefs[uuid];
if (!ref) return null;
return {
id: uuid,
mime_type: ref.mime_type,
filename: ref.filename,
size_bytes: 0,
width: ref.width ?? null,
height: ref.height ?? null
};
},
urlBuilder: (uuid, variant) => {
const ref = attachmentRefs[uuid];
const params = new URLSearchParams();
if (variant) params.set('variant', variant);
// Merge the server's signed fragment (protected links) so the
// signature travels alongside the variant. URLSearchParams
// joining is robust to either part being empty.
if (ref?.sig) {
for (const [k, v] of new URLSearchParams(ref.sig)) params.set(k, v);
}
const qs = params.toString();
return `/api/v1/s/${encodeURIComponent(token)}/attachments/${encodeURIComponent(uuid)}${qs ? `?${qs}` : ''}`;
},
workspaceSlug: '',
missing: renderAttachmentUnavailable
});
@@ -355,6 +403,7 @@
return;
}
applyAttachmentRefs(data);
if (data.type === 'item') {
shareType = 'item';
let fields: Record<string, any> = {};
@@ -423,6 +472,7 @@
}
// Re-process the data exactly like onMount does
requirePassword = false;
applyAttachmentRefs(data);
if (data.type === 'item') {
shareType = 'item';
let fields: Record<string, any> = {};