mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
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:
@@ -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("", 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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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  then [f](pad-attachment:id-2)\n\n" +
|
||||
"a repeat \n\n" +
|
||||
"```\n\n```\n\n" +
|
||||
""
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user