Files
xarmian 734b53f33c fix(attachments): a HEIC embed is decided by what can be served and painted, not by an image/ prefix (BUG-2964)
A pure-Go build derives no HEIC thumbnail, and the byte endpoint SILENTLY serves
the original when the requested variant is missing — so an editor that chose
<img> on a MIME PREFIX handed the browser HEIC bytes, which Chrome and Firefox
render as the broken-image icon.

The server now says what it did: X-Pad-Attachment-Variant names what the BYTES
are (a fallback still reports `original`), and X-Pad-Attachment-Derived names
which variants EXIST, answered only on the no-variant path so the hot image path
pays nothing. `none` is a sentinel rather than an empty value, because ABSENCE
has to keep meaning "server predates this fix".

The rule, in both renderers: embed as <img> iff THE VARIANT THIS RENDER WILL
REQUEST exists, OR the browser paints the original. Not a prefix (the bug); not
availability alone (the same build derives no AVIF thumbnail, and browsers
decode AVIF). The second disjunct is a new fourth predicate beside display.ts's
three, NOT canOpenInViewer — that one excludes image/svg+xml for active-content
reasons, and an SVG inside an <img> runs no script, so reusing it would have
flipped every existing SVG embed to a chip.

Verified on a REAL HEIF against a pure-Go build, with a PNG positive control on
the same instance: HEIF reads `none` and answers ?variant=thumb-md with 200 +
Content-Type image/heif — the defect itself; PNG reads `thumb-sm,thumb-md` and
answers with the actual thumbnail. Three counterfactuals and a negative control
on the new TS/Go lock-step test, which replaces a lock-step that had been
asserted in a comment only.

Codex CLEAN after 3 rounds; rounds 1 and 2 each found a real defect in the fix
(a per-variant fact collapsed to a boolean, and an async fact cached as durable).

The share-link 404 is deliberately unchanged — that path serves variants only
because the variant pipeline is the privacy boundary, and serving an original to
an anonymous viewer would trade a broken image for an EXIF/GPS leak. The
limitation is documented per surface beside the capabilities endpoint.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 19:40:37 -04:00

544 lines
20 KiB
Go

// Package render implements server-side rendering helpers shared between
// API endpoints that emit pre-rendered HTML (a future shared-item view,
// markdown export pipelines, etc.) and the editor's preview path.
//
// Today the only consumer is the attachment reference resolver — image
// and file-chip rendering for `pad-attachment:UUID` markdown references.
// The TS-side companion lives at `web/src/lib/markdown/attachments.ts`;
// keep the two implementations in lock-step so server-rendered and
// client-rendered output match for the same input. See DOC-865 for the
// architecture.
package render
import (
"fmt"
"html"
"net/url"
"regexp"
"strings"
)
// AttachmentMeta is the minimal metadata needed to render a `pad-attachment:UUID`
// reference. Trimmed from the full models.Attachment row so callers can pass
// a thin DTO without dragging the whole package graph in.
type AttachmentMeta struct {
ID string
MimeType string
Filename string
SizeBytes int64
Width *int
Height *int
// DerivedVariants names the derived (thumbnail) variants the server holds
// for this attachment (BUG-2964), e.g. ["thumb-sm","thumb-md"].
//
// PER-VARIANT, NOT A BOOLEAN, and codex round 1 is why: derivation writes
// each variant independently, so "some thumbnail exists" does not mean the
// one this render will REQUEST exists. If only thumb-sm were present and the
// render asked for thumb-md, a boolean would say "image", the endpoint would
// fall back to the undecodable original, and the broken image would be back
// with an extra layer of machinery in front of it.
//
// DerivedKnown separates "the server said none" from "nobody looked" without
// a nil-versus-empty-slice subtlety: false means UNKNOWN and falls back to
// the pre-BUG-2964 MIME-prefix behaviour. The TS mirror is
// `AttachmentMeta.derived_variants?: string[] | undefined`, where undefined
// is the same third state.
DerivedVariants []string
DerivedKnown bool
}
// AttachmentResolver looks up an attachment by UUID. Returns nil for
// missing/deleted attachments — the resolver renders a "missing"
// placeholder in that case so the document doesn't show a broken-image
// icon.
type AttachmentResolver func(uuid string) *AttachmentMeta
// attachmentRefPrefix is the URL scheme item content uses to reference
// attachments (mirrored on the TS side as ATTACHMENT_URL_PREFIX). Stored
// verbatim — never resolved to a backend URL — so a backend migration can
// rewrite storage_keys without touching item content. See DOC-865.
const attachmentRefPrefix = "pad-attachment:"
// IsAttachmentHref reports whether href is a `pad-attachment:UUID` reference.
func IsAttachmentHref(href string) bool {
return strings.HasPrefix(href, attachmentRefPrefix)
}
// ParseAttachmentHref returns the UUID portion of a `pad-attachment:UUID`
// reference, or "" if href is not a pad-attachment reference / has an
// empty UUID after trimming whitespace.
func ParseAttachmentHref(href string) string {
if !IsAttachmentHref(href) {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(href, attachmentRefPrefix))
}
// AttachmentDownloadURL builds the canonical attachment download URL.
// Pass an empty variant for the original; otherwise "thumb-sm" / "thumb-md"
// (the server falls back to the original if no derived row exists).
func AttachmentDownloadURL(workspaceSlug, attachmentID, variant string) string {
base := "/api/v1/workspaces/" + url.PathEscape(workspaceSlug) + "/attachments/" + url.PathEscape(attachmentID)
if variant == "" {
return base
}
return base + "?variant=" + url.QueryEscape(variant)
}
// IsImageMime reports whether the MIME type is LABELLED as an image.
// Mirrors `image/*` from the server-side allowlist (image/png, image/jpeg,
// image/gif, image/webp, image/avif, image/heic, image/heif).
//
// No longer the embed decision on its own — see ShouldEmbedAsImage.
func IsImageMime(mime string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(mime)), "image/")
}
// imgPaintableMimes are the types a BROWSER paints inside an <img> tag
// (BUG-2964). Byte-for-byte mirror of IMG_PAINTABLE_MIMES in
// web/src/lib/markdown/attachments.ts; a test asserts the two agree.
//
// HEIC/HEIF are absent deliberately: Chrome and Firefox decode neither, so an
// <img> pointed at HEIC bytes shows the broken-image icon. AVIF IS present —
// browsers decode it — which is why AVIF stays an image even on a build that
// derives no thumbnail for it. image/svg+xml IS present: an SVG in an <img>
// runs no script, and it has been embedded that way in existing documents
// since attachments shipped.
// imageRenderVariant is the variant this renderer puts in an <img> src. Named
// once so the embed DECISION and the URL it produces cannot disagree about
// which variant is being asked for — the exact disagreement codex round 1 found
// between a boolean availability flag and a specific request.
const imageRenderVariant = "thumb-md"
var imgPaintableMimes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/avif": true,
"image/svg+xml": true,
}
// ShouldEmbedAsImage decides <img> vs file chip (BUG-2964).
//
// THE RULE IS A DISJUNCTION: embed as <img> iff THE VARIANT THIS RENDER WILL
// REQUEST exists (the server can serve decodable bytes) OR the browser paints
// the original (no derivative needed).
//
// `wantVariant` is the variant the caller is about to put in the URL, and it
// must be the same one — asking about a variant you will not request answers a
// question nobody has (codex round 1). An empty wantVariant means the render
// points at the original, so only the paintable half can carry it.
//
// Not a MIME prefix test, because a pure-Go build derives no HEIC thumbnail and
// the byte endpoint falls back to the ORIGINAL when the variant row is missing —
// so image/heic produced an <img> full of bytes the browser cannot decode. Not
// variant availability alone either, because that same build derives no AVIF
// thumbnail and every current browser decodes AVIF.
//
// DerivedKnown false means unknown and falls back to the prefix test.
func ShouldEmbedAsImage(meta *AttachmentMeta, wantVariant string) bool {
if meta == nil || !IsImageMime(meta.MimeType) {
return false
}
if !meta.DerivedKnown {
return true
}
if wantVariant != "" {
for _, v := range meta.DerivedVariants {
if v == wantVariant {
return true
}
}
}
mime := strings.ToLower(strings.TrimSpace(meta.MimeType))
if i := strings.IndexByte(mime, ';'); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
return imgPaintableMimes[mime]
}
// FormatAttachmentSize returns a human-readable byte count ("832 B",
// "1.2 MB", "5 GB"). Negative sizes return "" — that's a corrupt-row
// signal, not something to print to the user.
func FormatAttachmentSize(bytes int64) string {
if bytes < 0 {
return ""
}
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
units := []string{"KB", "MB", "GB", "TB"}
n := float64(bytes) / 1024
i := 0
for n >= 1024 && i < len(units)-1 {
n /= 1024
i++
}
if n < 10 {
return fmt.Sprintf("%.1f %s", n, units[i])
}
return fmt.Sprintf("%.0f %s", n, units[i])
}
// RenderAttachmentImage builds an inline `<img>` element for an image
// attachment. Width/height attributes are emitted when both are known and
// positive so the browser can reserve layout space before the bytes
// arrive — avoiding the reflow jank that would otherwise hit on every
// render cycle.
//
// The `data-attachment-id` attribute is the editor's hook for click-to-
// zoom, rotate, and crop interactions.
//
// All user-controlled strings are HTML-escaped before interpolation;
// callers can safely pipe the result into a larger HTML document.
func RenderAttachmentImage(meta *AttachmentMeta, alt, workspaceSlug string) string {
if meta == nil {
return ""
}
src := AttachmentDownloadURL(workspaceSlug, meta.ID, imageRenderVariant)
altText := alt
if strings.TrimSpace(altText) == "" {
altText = meta.Filename
}
sizeAttrs := ""
if meta.Width != nil && meta.Height != nil && *meta.Width > 0 && *meta.Height > 0 {
sizeAttrs = fmt.Sprintf(` width="%d" height="%d"`, *meta.Width, *meta.Height)
}
return fmt.Sprintf(`<img src="%s" data-attachment-id="%s" alt="%s"%s>`,
html.EscapeString(src),
html.EscapeString(meta.ID),
html.EscapeString(altText),
sizeAttrs,
)
}
// RenderAttachmentChip builds a Notion-style file-chip anchor for a
// non-image attachment. `target=_blank` + `rel=noopener noreferrer`
// matches the existing external-link defaults, and the `download`
// attribute lets browsers save the file with its canonical filename.
//
// displayText overrides the chip label when non-empty; otherwise the
// attachment's filename is used.
func RenderAttachmentChip(meta *AttachmentMeta, displayText, workspaceSlug string) string {
if meta == nil {
return ""
}
href := AttachmentDownloadURL(workspaceSlug, meta.ID, "")
label := displayText
if strings.TrimSpace(label) == "" {
label = meta.Filename
}
size := FormatAttachmentSize(meta.SizeBytes)
sizeSpan := ""
if size != "" {
sizeSpan = ` <span class="file-chip-size">· ` + html.EscapeString(size) + `</span>`
}
return fmt.Sprintf(
`<a class="file-chip" href="%s" data-attachment-id="%s" download="%s" target="_blank" rel="noopener noreferrer"><span class="file-chip-icon" aria-hidden="true">📄</span><span class="file-chip-name">%s</span>%s</a>`,
html.EscapeString(href),
html.EscapeString(meta.ID),
html.EscapeString(meta.Filename),
html.EscapeString(label),
sizeSpan,
)
}
// RenderAttachmentMissing builds a placeholder span for a missing/deleted
// attachment. Keeps the document layout intact and tells the user *why*
// there's no image — a broken-image icon would suggest a transient network
// failure, but this is a permanent metadata state (the row is soft-deleted
// or the UUID was never valid).
func RenderAttachmentMissing(uuid, alt string) string {
safeAlt := alt
if strings.TrimSpace(safeAlt) == "" {
safeAlt = "Missing attachment"
}
return fmt.Sprintf(
`<span class="attachment-missing" data-attachment-id="%s" title="This attachment is missing or has been deleted">📎 %s</span>`,
html.EscapeString(uuid),
html.EscapeString(safeAlt),
)
}
// ResolveAttachmentImage resolves a `![alt](pad-attachment:UUID)` image
// reference into rendered HTML. Falls back to a file chip when the
// attachment exists but isn't an image MIME — the markdown author asked
// for an embed, but a PDF or zip can't sensibly inline.
func ResolveAttachmentImage(href, alt, workspaceSlug string, resolve AttachmentResolver) string {
uuid := ParseAttachmentHref(href)
if uuid == "" {
return ""
}
meta := resolve(uuid)
if meta == nil {
return RenderAttachmentMissing(uuid, alt)
}
if ShouldEmbedAsImage(meta, imageRenderVariant) {
return RenderAttachmentImage(meta, alt, workspaceSlug)
}
chipText := alt
if strings.TrimSpace(chipText) == "" {
chipText = meta.Filename
}
return RenderAttachmentChip(meta, chipText, workspaceSlug)
}
// ResolveAttachmentLink resolves a `[text](pad-attachment:UUID)` link
// reference into rendered HTML. Always renders as a file chip — link
// syntax indicates the user wants a downloadable reference, not an
// inline embed, even when the underlying MIME is an image.
func ResolveAttachmentLink(href, text, workspaceSlug string, resolve AttachmentResolver) string {
uuid := ParseAttachmentHref(href)
if uuid == "" {
return ""
}
meta := resolve(uuid)
if meta == nil {
return RenderAttachmentMissing(uuid, text)
}
return RenderAttachmentChip(meta, text, workspaceSlug)
}
// markdownAttachmentRefRE matches both image and link forms of a
// pad-attachment reference in source markdown:
//
// ![alt](pad-attachment:UUID)
// [text](pad-attachment:UUID)
//
// The leading `!?` captures whether the form is image or link. The
// alt/text capture accepts CommonMark escape sequences (`\]`, `\\`, etc.)
// so labels containing literal brackets — e.g. `[Q1 \] report](pad-…)` —
// match the same way marked does on the TS side; without this, the two
// renderers disagree on edge-case labels and the lock-step contract
// breaks. Captured escapes are unwound in unescapeMarkdownText before the
// label reaches the render helpers. The UUID capture rejects whitespace
// and `)` so it stops at the closing paren. Markdown's optional " title"
// suffix on a link destination is handled — anything between the UUID
// and `)` after a space matches the title group we currently ignore
// (our HTML output doesn't surface the title attribute today).
//
// References inside fenced code blocks must be skipped — that's done
// by ResolveAttachmentReferences's caller via splitCodeFences.
var markdownAttachmentRefRE = regexp.MustCompile(
`(!?)\[((?:\\.|[^\]\\])*)\]\(pad-attachment:([^\s)]+)(?:\s+"[^"]*")?\)`,
)
// unescapeMarkdownText reverses CommonMark escape sequences in a captured
// label. Marked's tokenizer drops the backslash and emits the literal
// character, so the regex-based Go path needs to mirror that behavior
// before passing the label to the render helpers — otherwise a label
// like `Q1 \] report` would render with the backslash visible.
//
// We only unescape backslash + ASCII punctuation that can appear inside
// a label match: `\\`, `\]`, `\[`, `\(`, `\)`, `\!`. Other escapes
// pass through unchanged — matches CommonMark §6.1 closely enough for
// the labels Pad's editor produces.
func unescapeMarkdownText(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case '\\', ']', '[', '(', ')', '!':
b.WriteByte(s[i+1])
i++
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// ResolveAttachmentReferences scans `markdown` for `pad-attachment:UUID`
// references in standard markdown image/link syntax and replaces each
// with rendered HTML using the supplied resolver:
//
// - `![alt](pad-attachment:UUID)` → <img> for image MIMEs, file chip otherwise
// - `[text](pad-attachment:UUID)` → file chip
// - resolver returns nil → missing-attachment placeholder span
//
// References inside fenced code blocks (```…```) are left untouched so
// documentation about the reference syntax renders verbatim. References
// inside inline code (single backticks) are NOT skipped — that's a known
// limitation; the editor doesn't generate references inside code spans,
// and stripping them robustly would require a full markdown tokenizer.
//
// The output is mixed markdown + HTML. Downstream callers can pass it
// through a CommonMark-compatible markdown→HTML stage, which renders
// HTML blocks and inline HTML transparently.
func ResolveAttachmentReferences(markdown, workspaceSlug string, resolve AttachmentResolver) string {
if resolve == nil {
return markdown
}
chunks := splitCodeFences(markdown)
var b strings.Builder
b.Grow(len(markdown))
for _, c := range chunks {
if c.fenced {
b.WriteString(c.text)
continue
}
b.WriteString(markdownAttachmentRefRE.ReplaceAllStringFunc(c.text, func(match string) string {
sub := markdownAttachmentRefRE.FindStringSubmatch(match)
if sub == nil {
return match
}
isImage := sub[1] == "!"
altOrText := unescapeMarkdownText(sub[2])
uuid := strings.TrimSpace(sub[3])
href := attachmentRefPrefix + uuid
if isImage {
return ResolveAttachmentImage(href, altOrText, workspaceSlug, resolve)
}
return ResolveAttachmentLink(href, altOrText, workspaceSlug, resolve)
}))
}
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
// reference syntax render literally.
type codeChunk struct {
text string
fenced bool
}
// splitCodeFences partitions markdown source into alternating fenced /
// non-fenced spans. The fence detector recognizes the CommonMark form:
// at least three backticks or tildes at the start of a line, optional
// info string, terminated by a fence of the same character at column 0
// (or end of input). Indented code blocks are not skipped — they're
// rare in our content and the spec for indented code is already fragile.
func splitCodeFences(s string) []codeChunk {
var chunks []codeChunk
lines := strings.SplitAfter(s, "\n")
var cur strings.Builder
inFence := false
var fenceChar byte
var fenceLen int
flush := func(fenced bool) {
if cur.Len() == 0 {
return
}
chunks = append(chunks, codeChunk{text: cur.String(), fenced: fenced})
cur.Reset()
}
for _, line := range lines {
if !inFence {
if ch, n, ok := openingFence(line); ok {
// flush the non-fenced span we were accumulating
flush(false)
inFence = true
fenceChar = ch
fenceLen = n
cur.WriteString(line)
continue
}
cur.WriteString(line)
continue
}
// inside a fence
cur.WriteString(line)
if isClosingFence(line, fenceChar, fenceLen) {
flush(true)
inFence = false
}
}
if inFence {
flush(true)
} else {
flush(false)
}
return chunks
}
// openingFence checks if line starts a CommonMark fenced code block.
// Returns the fence character (' ` ' or '~'), the length, and ok.
func openingFence(line string) (byte, int, bool) {
trimmed := strings.TrimLeft(line, " ")
// CommonMark allows up to 3 spaces of indent before the fence.
if len(line)-len(trimmed) > 3 {
return 0, 0, false
}
if len(trimmed) < 3 {
return 0, 0, false
}
ch := trimmed[0]
if ch != '`' && ch != '~' {
return 0, 0, false
}
n := 0
for n < len(trimmed) && trimmed[n] == ch {
n++
}
if n < 3 {
return 0, 0, false
}
// For backtick fences, the info string MUST NOT contain a backtick
// (CommonMark §4.5). We don't bother validating beyond fence length
// here; isClosingFence enforces the matching rules on the close.
return ch, n, true
}
// isClosingFence checks if line closes a fenced code block opened with
// `fenceLen` of `fenceChar`. The closing fence must be at least as long
// as the opening fence, and must consist only of the fence character
// (after optional indentation).
func isClosingFence(line string, fenceChar byte, fenceLen int) bool {
trimmed := strings.TrimLeft(line, " ")
if len(line)-len(trimmed) > 3 {
return false
}
// Strip the trailing newline if present.
trimmed = strings.TrimRight(trimmed, "\n")
trimmed = strings.TrimRight(trimmed, "\r")
if len(trimmed) < fenceLen {
return false
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] != fenceChar {
// closing fence may be followed only by whitespace
return strings.TrimSpace(trimmed[i:]) == "" && i >= fenceLen
}
}
return len(trimmed) >= fenceLen
}