Skip to content

feat(file): workspace-scoped inline images + public-share cascade#5203

Merged
TheodoreSpeaks merged 3 commits into
stagingfrom
fix/file-share-cascade
Jun 25, 2026
Merged

feat(file): workspace-scoped inline images + public-share cascade#5203
TheodoreSpeaks merged 3 commits into
stagingfrom
fix/file-share-cascade

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Embedded markdown images now resolve only within the document's workspace — a cross-workspace embed 404s and does not render, in-app and on shares (enforced via the existing getWorkspaceFile scope check; cross-workspace embeds previously rendered for dual-workspace members)
  • Public file shares now cascade to the images the shared doc embeds, so a logged-out viewer sees them instead of broken icons. The public route serves an embed only when it is referenced-by-doc + same-workspace + passes a magic-byte image sniff (raster only; SVG excluded)
  • New routes: /api/workspaces/[id]/files/inline (in-app, workspace-scoped) and /api/files/public/[token]/inline (public cascade); embeds rewrite through them via the FileContentSource seam
  • One shared isomorphic parser owns the embed grammar for both the frontend renderer and the server doc scan, so the set the client links and the set the server authorizes can't drift
  • Fixed: wf_ file ids were 400ing on the view/export routes (the schema required .uuid(), but workspace files use wf_<shortId>)
  • Scope is markdown image embeds only — PDFs/office docs are self-contained and HTML/SVG render as inert text, so nothing else needs cascading

Type of Change

  • Bug fix
  • New feature

Testing

  • Unit + route tests for both new routes (same-workspace serve, cross-workspace 404, not-referenced 404, non-image/SVG 404, 401, unknown token), the shared embed parser, the workspace-scope resolver, and the magic-byte sniffer
  • bun run lint, bun run check:api-validation:strict, and tsc --noEmit all pass
  • Staging data exists for manual e2e: workspace a618… doc wf_Ytm… embeds image wf_YwDXi8…

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Embedded markdown images now resolve only within the document's workspace,
and public file shares cascade to the images the shared document embeds.

- New /api/workspaces/[id]/files/inline (in-app, workspace-scoped) and
  /api/files/public/[token]/inline (public cascade) routes; the public one
  serves an embed only when it is referenced-by-doc, same-workspace, and
  passes a magic-byte image sniff
- Embed srcs (serve-key and view-id forms) rewrite through one scoped inline
  route; one shared isomorphic parser owns the embed grammar for both the
  frontend renderer and the server doc scan
- Accept wf_ file ids on the view/export routes (were 400ing on .uuid())
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 25, 2026 12:06am

Request Review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor

cursor Bot commented Jun 24, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New file-serving and public-share endpoints define the security boundary (doc reference, workspace scope, content sniffing); mistakes could leak workspace files or serve unsafe inline content.

Overview
Adds workspace-scoped inline image serving for markdown embeds and extends public file shares so embedded images load for anonymous viewers instead of broken icons.

Embedded src values (serve URLs, view URLs, in-app /workspace/.../files/... paths) are rewritten through a FileContentSource seam: in-app requests hit /api/workspaces/{id}/files/inline, public shares hit /api/files/public/{token}/inline. Resolution is limited to files in the document’s workspace; cross-workspace references 404. The public cascade only serves images referenced in the shared doc, after share auth, and only when bytes pass a magic-byte raster sniff (SVG/HTML spoofing is rejected).

A shared isomorphic embed parser (embedded-image-ref) keeps client rewrites, server authorization, and markdown export bundling aligned. Export now rewrites both view-URL and workspace-path embed spellings. wf_ workspace file IDs are accepted on view/export contracts (replacing UUID-only validation that caused 400s). The rich markdown editor gains an /Image slash command (upload picker) and renders images via resolveImageSrc instead of a local display rewrite.

Reviewed by Cursor Bugbot for commit a007410. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e14499f. Configure here.

Comment thread apps/sim/app/api/files/export/[id]/route.ts
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds workspace-scoped inline image serving for markdown embeds and cascades public file shares to their embedded images, so logged-out viewers see images instead of broken icons. It also fixes a schema bug where wf_<shortId> file IDs were 400ing on the view and export routes.

  • Two new routes/api/workspaces/[id]/files/inline (session-gated, workspace-scoped) and /api/files/public/[token]/inline (public cascade) — each enforcing three sequential security gates: referenced-by-doc, same-workspace scope via resolveWorkspaceInlineImage, and a magic-byte content-type sniff on the public path to block SVG/HTML injection.
  • Shared isomorphic parser (embedded-image-ref.ts) owns the embed-URL grammar for both the frontend renderer (resolveImageSrc) and server-side doc scan (extractEmbeddedFileRefs), preventing client/server grammar drift; the combined reference cap (50 keys + ids total) addresses a previously noted per-type double-counting bug.
  • FileContentSource seam extended with resolveImageSrc so each rendering context (in-app workspace view, public share view) routes embedded image src attributes through its scoped inline route without any changes to the image component itself.

Confidence Score: 5/5

Safe to merge — the three-gate security model is correctly layered, all gates fail closed with 404/401, and the shared parser eliminates client/server drift risk.

The new inline routes are well-structured: the in-app route gates on session + workspace membership before resolving any file, and the public route correctly chains rate-limit → token resolution → deployment auth → referenced-by-doc → same-workspace → magic-byte sniff, with every gate failure returning a non-leaking response. The shared isomorphic parser ensures the reference set the server authorizes exactly matches what the client renders. The export route fix (wf_ IDs, in-app-path embed rewriting) is targeted and correct. Previous reviewer feedback on the per-type cap bug has been addressed in the final commit. Test coverage is thorough across all gates.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/lib/uploads/utils/embedded-image-ref.ts New isomorphic parser owning the embed grammar for both frontend and server; correctly caps combined key+id references at 50, handles s3/blob prefixes and both URL forms, and is pure/side-effect-free.
apps/sim/app/api/files/public/[token]/inline/route.ts New public cascade route enforcing three sequential gates (referenced-by-doc, same-workspace, magic-byte sniff); all gate failures return 404 to avoid info leakage.
apps/sim/app/api/workspaces/[id]/files/inline/route.ts New in-app inline route that gates on session + workspace membership before resolving; cross-workspace refs return 404 rather than 403 to avoid existence probing.
apps/sim/lib/uploads/server/inline-image.ts Single workspace-scope resolver shared by both inline routes; double-checks workspaceId on key-based lookups.
apps/sim/app/api/files/serve-inline-image.ts Shared serve tail; sniff=true path rejects non-raster content preventing XSS via spoofed content-types on public shares.
apps/sim/hooks/use-file-content-source.tsx Adds resolveImageSrc to FileContentSource seam with workspace- and token-scoped factories.
apps/sim/lib/api/contracts/primitives.ts Adds workspaceFileIdSchema and inlineFileRefQuerySchema with Zod refine enforcing exactly one of key or fileId.
apps/sim/lib/api/contracts/storage-transfer.ts Fixes fileViewParamsSchema and fileExportParamsSchema to accept wf_ IDs.

Reviews (4): Last reviewed commit: "fix(file): export rewrites all embed for..." | Re-trigger Greptile

Comment thread apps/sim/app/api/files/public/[token]/inline/route.ts
Comment thread apps/sim/lib/uploads/utils/embedded-image-ref.ts
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces workspace-scoped inline image serving for embedded markdown images and a public-share cascade that allows logged-out viewers of a shared document to see its embedded images. Two new API routes are added, a shared isomorphic parser owns the embed grammar for both frontend and server, and the wf_-prefixed file-ID validation bug on the view/export routes is fixed.

  • New routes: /api/workspaces/[id]/files/inline (in-app, workspace-scoped; returns 404 for cross-workspace refs) and /api/files/public/[token]/inline (public cascade; gates on referenced-by-doc + same-workspace + magic-byte image sniff).
  • Shared grammar: extractEmbeddedFileRef / extractEmbeddedFileRefs in lib/uploads/utils/embedded-image-ref.ts is the single isomorphic parser consumed by the TipTap image renderer, the server-side referenced-by-doc gate, and the export bundler — drift between client and server authorization sets is no longer possible.
  • Schema fix: fileViewParamsSchema and fileExportParamsSchema switched from .uuid() to workspaceFileIdSchema ([A-Za-z0-9_-]+), fixing silent 400s on all wf_<shortId> files.

Confidence Score: 4/5

The three-gate public cascade route is correctly structured, the workspace-scoped in-app route properly gates on session + membership, and the schema fix unblocks wf_-format file IDs without regressions.

The core security logic is sound across all changed routes, the isomorphic parser removes grammar drift between client and server, and the validation fix is straightforward. All three findings are non-blocking quality observations with no correctness or data-integrity impact on the changed paths.

apps/sim/app/api/files/public/[token]/inline/route.ts (per-request document download) and apps/sim/lib/uploads/utils/embedded-image-ref.ts (per-type vs. global image cap)

Important Files Changed

Filename Overview
apps/sim/app/api/files/public/[token]/inline/route.ts New public cascade route with three security gates (referenced-by-doc, same-workspace, magic-byte sniff); logic is sound, minor performance note on per-request document download
apps/sim/app/api/workspaces/[id]/files/inline/route.ts New workspace-scoped inline route; auth check before any storage access, 404 on permission failure to prevent existence probing, clean implementation
apps/sim/app/api/files/serve-inline-image.ts Shared serving tail; intentional no-cache policy documented, sniff toggle cleanly separates trusted (in-app) and untrusted (public) audiences
apps/sim/lib/uploads/utils/embedded-image-ref.ts Isomorphic parser for embed grammar; handles serve, view, and in-app path forms; URL constructor used for normalization; per-type 50-image cap
apps/sim/lib/uploads/server/inline-image.ts Workspace-scope gate shared by both routes; correctly returns null for cross-workspace, deleted, and non-workspace references
apps/sim/lib/uploads/utils/validation.ts Magic-byte sniffer for PNG, JPEG, GIF, WebP; SVG explicitly excluded; boundary checks on all format branches
apps/sim/hooks/use-file-content-source.tsx FileContentSource seam extended with resolveImageSrc; factory functions for workspace and public sources; default fallback passes src through unchanged (safe but omits in-app path rewrite for out-of-context renders)
apps/sim/lib/api/contracts/primitives.ts New workspaceFileIdSchema and inlineFileRefQuerySchema; exactly-one-of refine is correct; fixes the silent UUID-only validation bug
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx resolveDisplaySrc removed; ResizableImageView now consumes context source; resolveImageSrc correctly routes through workspace/token-scoped inline routes
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx FileViewer wrapper now owns the FileContentSourceProvider; contentSource prop threads public or workspace source to all descendants cleanly
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts Tests cover serve-URL and view-URL rewriting through resolveImageSrc, but the in-app workspace-path case (/workspace/{id}/files/{fileId}) previously tested via resolveDisplaySrc is no longer covered

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant PublicInlineRoute as /api/files/public/[token]/inline
    participant WorkspaceInlineRoute as /api/workspaces/[id]/files/inline
    participant ShareManager
    participant Storage
    participant DB

    Note over Browser,DB: Logged-out viewer — public share cascade
    Browser->>PublicInlineRoute: "GET ?fileId=wf_abc (or ?key=…)"
    PublicInlineRoute->>ShareManager: resolveActiveShareByToken(token)
    ShareManager-->>PublicInlineRoute: "{share, file: doc}"
    PublicInlineRoute->>PublicInlineRoute: validateDeploymentAuth()
    PublicInlineRoute->>Storage: downloadFile(doc.key) [referenced-by-doc gate]
    Storage-->>PublicInlineRoute: doc bytes
    PublicInlineRoute->>PublicInlineRoute: extractEmbeddedImageIds/Keys(docText).includes(ref)
    alt not referenced
        PublicInlineRoute-->>Browser: 404
    end
    PublicInlineRoute->>DB: resolveWorkspaceInlineImage(doc.workspaceId, ref) [same-workspace gate]
    DB-->>PublicInlineRoute: "{key, contentType, filename} or null"
    alt cross-workspace or deleted
        PublicInlineRoute-->>Browser: 404
    end
    PublicInlineRoute->>Storage: "downloadFile(image.key) [content-truth gate: sniff=true]"
    Storage-->>PublicInlineRoute: image bytes
    PublicInlineRoute->>PublicInlineRoute: sniffImageContentType(bytes) — rejects SVG/HTML
    PublicInlineRoute-->>Browser: 200 image/png (or jpeg/gif/webp)

    Note over Browser,DB: Authenticated viewer — workspace-scoped inline
    Browser->>WorkspaceInlineRoute: "GET /api/workspaces/wsId/files/inline?fileId=wf_abc"
    WorkspaceInlineRoute->>WorkspaceInlineRoute: getSession() + getUserEntityPermissions()
    alt no session or not a member
        WorkspaceInlineRoute-->>Browser: 401 / 404
    end
    WorkspaceInlineRoute->>DB: resolveWorkspaceInlineImage(workspaceId, ref)
    DB-->>WorkspaceInlineRoute: "{key, contentType, filename} or null"
    WorkspaceInlineRoute->>Storage: "downloadFile(image.key) [sniff=false, stored type]"
    Storage-->>WorkspaceInlineRoute: image bytes
    WorkspaceInlineRoute-->>Browser: 200 image/…
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant PublicInlineRoute as /api/files/public/[token]/inline
    participant WorkspaceInlineRoute as /api/workspaces/[id]/files/inline
    participant ShareManager
    participant Storage
    participant DB

    Note over Browser,DB: Logged-out viewer — public share cascade
    Browser->>PublicInlineRoute: "GET ?fileId=wf_abc (or ?key=…)"
    PublicInlineRoute->>ShareManager: resolveActiveShareByToken(token)
    ShareManager-->>PublicInlineRoute: "{share, file: doc}"
    PublicInlineRoute->>PublicInlineRoute: validateDeploymentAuth()
    PublicInlineRoute->>Storage: downloadFile(doc.key) [referenced-by-doc gate]
    Storage-->>PublicInlineRoute: doc bytes
    PublicInlineRoute->>PublicInlineRoute: extractEmbeddedImageIds/Keys(docText).includes(ref)
    alt not referenced
        PublicInlineRoute-->>Browser: 404
    end
    PublicInlineRoute->>DB: resolveWorkspaceInlineImage(doc.workspaceId, ref) [same-workspace gate]
    DB-->>PublicInlineRoute: "{key, contentType, filename} or null"
    alt cross-workspace or deleted
        PublicInlineRoute-->>Browser: 404
    end
    PublicInlineRoute->>Storage: "downloadFile(image.key) [content-truth gate: sniff=true]"
    Storage-->>PublicInlineRoute: image bytes
    PublicInlineRoute->>PublicInlineRoute: sniffImageContentType(bytes) — rejects SVG/HTML
    PublicInlineRoute-->>Browser: 200 image/png (or jpeg/gif/webp)

    Note over Browser,DB: Authenticated viewer — workspace-scoped inline
    Browser->>WorkspaceInlineRoute: "GET /api/workspaces/wsId/files/inline?fileId=wf_abc"
    WorkspaceInlineRoute->>WorkspaceInlineRoute: getSession() + getUserEntityPermissions()
    alt no session or not a member
        WorkspaceInlineRoute-->>Browser: 401 / 404
    end
    WorkspaceInlineRoute->>DB: resolveWorkspaceInlineImage(workspaceId, ref)
    DB-->>WorkspaceInlineRoute: "{key, contentType, filename} or null"
    WorkspaceInlineRoute->>Storage: "downloadFile(image.key) [sniff=false, stored type]"
    Storage-->>WorkspaceInlineRoute: image bytes
    WorkspaceInlineRoute-->>Browser: 200 image/…
Loading

Comments Outside Diff (1)

  1. apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts, line 1-41 (link)

    P2 Missing test for in-app workspace path rewrite

    The old resolveDisplaySrc test explicitly covered the /workspace/{id}/files/{fileId}/api/files/view/{fileId} rewrite, which was the original cross-workspace leak path. The replacement tests exercise serve-URL and view-URL rewrites through resolveImageSrc, but the in-app path form is not tested at this level. A document saved with a /workspace/W1/files/wf_abc embed src relies on extractEmbeddedFileRef parsing that path and resolveImageSrc routing it through the workspace inline route — that end-to-end path is only tested at the unit level in embedded-image-ref.test.ts, not at the resolveImageSrc integration level that the old test covered.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (2): Last reviewed commit: "feat(file): workspace-scoped inline imag..." | Re-trigger Greptile

Comment thread apps/sim/app/api/files/public/[token]/inline/route.ts
Comment thread apps/sim/lib/uploads/utils/embedded-image-ref.ts
- New /Image slash command uploads an image via a file picker and inserts
  it at the caret (same upload+insert path as paste/drop)
- Inserted src is the workspace serve URL, so it renders in-app and
  cascades to public shares like any other embed
- Per-editor handler wired through slash-command storage (the extension
  set is a shared singleton); only active when the editor is editable
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Addresses PR review:
- Markdown export now rewrites the in-app `/workspace/<ws>/files/<id>`
  embed form too (not just `/api/files/view/<id>`), so a bundled asset
  never leaves a broken link in an offline export (Bugbot)
- extractEmbeddedFileRefs bounds total references (keys + ids) to 50
  combined rather than 50 each, matching MAX_EMBEDDED_IMAGES intent
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks merged commit cff7a49 into staging Jun 25, 2026
16 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix/file-share-cascade branch June 25, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant