Skip to content

feat(files): public share links for workspace files#5130

Merged
TheodoreSpeaks merged 11 commits into
stagingfrom
feat/public-files
Jun 19, 2026
Merged

feat(files): public share links for workspace files#5130
TheodoreSpeaks merged 11 commits into
stagingfrom
feat/public-files

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Add a per-file Share toggle (context menu, viewer toolbar, breadcrumb) that publishes a file to an unguessable public URL /f/{token}; default private, write/admin only
  • New polymorphic public_share table (resourceType/resourceId, token, isActive) — shaped for future folder sharing + password/email gating (reserved columns)
  • Public page /f/{token} reuses the in-app FileViewer via a content-source seam + readOnly mode, so images/PDF/markdown/code/docx/pptx/xlsx all render (generated docs load their prebuilt compiled artifact, never compile on the public path)
  • Share modal follows the standard save→close convention with a success toast that surfaces + copies the link; reopening shows the live link
  • Header shows provenance (workspace · shared-by) and Sim branding; links are noindex

Type of Change

  • New feature

Testing

Tested manually. bun run lint, check:api-validation:strict, check:migrations origin/staging (backward-compatible — additive CREATE TABLE), and route tests (share auth gates + public 404/no-leak) all pass.

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)

@vercel

vercel Bot commented Jun 18, 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 18, 2026 11:44pm

Request Review

@cursor

cursor Bot commented Jun 18, 2026

Copy link
Copy Markdown

PR Summary

High Risk
New unauthenticated data egress path (token-only auth) with S3-backed content and enterprise policy gates; misconfiguration or token leakage would expose workspace files outside membership.

Overview
Adds public file sharing: workspace members with write access can turn on an unguessable link (/f/{token}) per file via a new Share modal and menu entries; the files list now includes each file’s share state.

Backend: new public_share table and share manager; authenticated GET/PUT on /api/workspaces/.../share (audit events, org policy disablePublicFileSharing blocks enabling but not disabling); unauthenticated metadata and byte routes keyed only by active tokens, with per-IP rate limits and metadata that omits storage keys/workspace IDs. Public content uses resolveServableDoc for generated office docs (409 if not compiled yet) and no-cache responses.

Public UI: /f/[token] page (noindex) reuses FileViewer in readOnly mode behind a FileContentSource provider so previews hit the token content URL instead of the auth serve route.

Viewer plumbing: binary/text fetch hooks and image preview go through the content-source seam; read-only previews skip CSV “import as table” and treat oversized CSVs as download-only on the public path.

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

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx Outdated
# Conflicts:
#	apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
#	apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts
#	packages/db/migrations/meta/0241_snapshot.json
#	packages/db/migrations/meta/_journal.json
#	scripts/check-api-validation-contracts.ts
@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds public share links for workspace files: a new public_share table (polymorphic, one row per resource), a /f/{token} public page, authenticated share-management endpoints, and a FileContentSource context seam that lets the existing FileViewer serve both workspace and public content without duplication.

  • DB & API: Additive public_share migration with correct FK semantics (created_by SET NULL, workspace_id CASCADE); share state is batch-fetched alongside the files list to avoid N+1 queries; public metadata and content endpoints are rate-limited per-IP and return 404 without leaking file identity.
  • Public viewer: SSR validates the token server-side; the client reuses FileViewer via the content-source seam; readOnly mode gates CSV import toasts and the text editor; generated docs serve their prebuilt compiled artifact — never re-compile on the public path.
  • Access control: Enabling a share is gated by org permission-group policy (disablePublicFileSharing); disabling is always allowed; audit events recorded for both directions.

Confidence Score: 5/5

Safe to merge — the feature is well-isolated, public endpoints are rate-limited and return consistent 404s without leaking workspace data, and all issues raised in prior review threads have been addressed.

All previously flagged issues (stale draftActive, download anchor, soft-delete filter, content-endpoint rate limiting, CSV import toast on public page, createdBy cascade) have been resolved. The two remaining observations are both low-likelihood: the token-unique conflict path in upsertFileShare is practically unreachable with 126-bit nanoid tokens, and the close-animation skip in the share modal is cosmetic.

No files require special attention. The share-manager.ts upsert and share-modal.tsx unmount pattern are the only areas worth a second glance, but neither poses a correctness risk in production.

Important Files Changed

Filename Overview
apps/sim/lib/public-shares/share-manager.ts New module managing public share lifecycle: upsert, lookup, and batch-fetch. Token is 126-bit nanoid; resolveActiveShareByToken correctly filters soft-deleted files. Minor gap: onConflictDoUpdate only covers the resource-unique constraint, leaving a theoretical token-unique violation unhandled.
apps/sim/app/api/files/public/[token]/content/route.ts Public file-bytes endpoint; rate-limited per-IP, auth solely by active token, returns 404 for unknown/inactive/deleted shares. Correctly delegates compiled-artifact resolution to resolveServableDoc and applies no-cache headers.
apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts GET/PUT endpoints for managing share state; correctly gates enabling behind write permission + org policy, always allows disabling. Audit events recorded for both directions.
apps/sim/app/f/[token]/public-file-view.tsx Client component for the public page; reuses FileViewer via content-source seam. Synthetic WorkspaceFileRecord correctly uses token@version as the cache key. Download anchor is appended to document.body before click.
apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx Share modal with draftActive seeded as null (addresses the stale-initialShare issue). Policy-gated switch. Modal is mounted/unmounted by null-toggling shareModal in parent rather than toggling open, which skips the close animation.
apps/sim/hooks/use-file-content-source.tsx New context seam for file content URLs; decouples renderers from the workspace serve URL. Default implementation is backward-compatible; public override ignores key/opts and returns fixed token URL.
packages/db/schema.ts Adds public_share table with correct FK semantics: workspace_id CASCADE, created_by SET NULL. Token and resource-unique indexes present.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx Adds readOnly prop; large CSVs fall through to UnsupportedPreview in read-only mode; new ReadOnlyTextPreview handles text/markdown/code. Binary viewers work via content-source seam.
apps/sim/lib/public-shares/rate-limit.ts Per-IP token-bucket rate limits for metadata (120 req/min) and content (60 req/min) public endpoints. Fails open on storage errors, matching existing precedent.
apps/sim/lib/copilot/tools/server/files/doc-compile.ts Adds resolveServableDoc — distinguishes already-binary files from generated-source docs; loads compiled artifact or returns unavailable (never compiles on the public path).

Reviews (9): Last reviewed commit: "feat(files): gate public sharing behind ..." | Re-trigger Greptile

Comment thread apps/sim/app/f/[token]/public-file-view.tsx
Comment thread apps/sim/lib/public-shares/share-manager.ts
@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds public share links for workspace files — write/admin members toggle sharing per-file via a new Share modal (context menu, toolbar, breadcrumb), which publishes the file to an unguessable /f/{token} URL backed by a new polymorphic public_share table. The public page reuses FileViewer through a FileContentSource seam that swaps the auth-gated serve URL for a token-scoped endpoint, keeping all renderer components unchanged.

  • New public_share table with a stable token-per-resource design (disable/re-enable preserves the URL), polymorphic on resourceType for future folder sharing, and reserved columns for password/email gating.
  • Public API routes (GET /api/files/public/[token] and /content) are unauthenticated and gate solely on an active share token; generated office docs serve their prebuilt compiled artifact, never triggering E2B.
  • FileContentSourceProvider context seam cleanly replaces hardcoded workspace serve URLs in ImagePreview, useWorkspaceFileContent, and useWorkspaceFileBinary so both in-app and public viewers share the same render code.

Confidence Score: 3/5

Two concrete defects should be addressed before merge: the share modal can silently overwrite a concurrent share state on Save, and the ON DELETE CASCADE on created_by will break all public share links when a user account is removed.

The share modal initializes draftActive from potentially stale list-cache data; when the fresh server fetch resolves with a different isActive value, Save becomes active without any user action — clicking it would overwrite the actual server state with the stale value. Separately, the created_by foreign key uses ON DELETE CASCADE, meaning any user deletion silently removes all shares they ever created across all workspaces, which is likely to surprise both admins and external link holders.

apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx (isDirty logic) and packages/db/schema.ts (created_by cascade behavior).

Security Review

  • Unauthenticated bandwidth amplification (apps/sim/app/api/files/public/[token]/content/route.ts): The public content endpoint streams full file bytes with no rate limiting or signed-URL expiry. A valid token alone is sufficient to sustain arbitrary-throughput requests against backing storage.
  • Token entropy is adequate: generateShortId() uses crypto.getRandomValues() with a 64-character URL-safe alphabet at default length 21, yielding ~126 bits of entropy — sufficient to make tokens unguessable.
  • No injection, credential leakage, or auth-bypass issues were found in the share-gating logic.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx Share modal — draftActive is initialized from stale initialShare and compared against freshly-loaded share, so isDirty can flip true without user input, enabling an accidental silent write.
packages/db/schema.ts New public_share table is well-indexed and polymorphic; the createdBy ON DELETE CASCADE will silently break existing share links when the creating user is removed, which is likely unintentional.
apps/sim/app/api/files/public/[token]/content/route.ts Unauthenticated byte-serving route; share validity is properly checked; loadServableDocArtifact serves prebuilt artifacts without compiling; no rate limiting is applied.
apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts Auth and permission gating (401/403 checks) are correct; write-gated PUT and read-gated GET match the spec; audit events recorded on both enable and disable.
apps/sim/lib/public-shares/share-manager.ts Core share CRUD — clean upsert with stable token semantics; resolveActiveShareByToken correctly gates on isActive + deletedAt; generateShortId uses crypto.getRandomValues so entropy is adequate.
apps/sim/app/f/[token]/public-file-view.tsx FileContentSourceProvider seam correctly replaces workspace serve URLs with the token-scoped public endpoint; synthetic WorkspaceFileRecord is cleanly built; header includes noindex robots and provenance.
apps/sim/hooks/use-file-content-source.tsx New context seam cleanly abstracts the URL construction; workspaceFileContentSource is the unchanged default so existing callers are unaffected.
apps/sim/hooks/queries/workspace-files.ts fetchWorkspaceFileContent and fetchWorkspaceFileBinary now accept a pre-built URL from the content source, removing hardcoded workspace paths; backwards-compatible change.
apps/sim/app/workspace/[workspaceId]/files/files.tsx ShareModal is wired into the context menu and toolbar correctly; shareModal is rendered in each conditional branch (selectedFile view and main list view) separately, not duplicated simultaneously.
apps/sim/lib/copilot/tools/server/files/doc-compile.ts loadServableDocArtifact cleanly detects pre-compiled binaries via magic bytes and never triggers E2B compilation on the public path.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User (Writer)
    participant FM as FileViewer / ShareModal
    participant SA as PUT /workspaces/[id]/files/[fileId]/share
    participant SM as ShareManager (DB)
    participant PU as Public Visitor
    participant PA as GET /files/public/[token]
    participant PC as GET /files/public/[token]/content

    U->>FM: Toggle share ON → Save
    FM->>SA: "PUT { isActive: true }"
    SA->>SM: upsertFileShare()
    SM-->>SA: "{ token, url, isActive: true }"
    SA-->>FM: "200 { share }"
    FM-->>U: Toast with link (copy action)

    PU->>PA: "GET /api/files/public/{token}"
    PA->>SM: resolveActiveShareByToken(token)
    SM-->>PA: "{ file, workspaceName, ownerName }"
    PA-->>PU: "{ name, type, size, workspaceName, ownerName }"

    PU->>PC: "GET /api/files/public/{token}/content"
    PC->>SM: resolveActiveShareByToken(token)
    SM-->>PC: "{ file }"
    PC-->>PU: file bytes (or compiled artifact)
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 U as User (Writer)
    participant FM as FileViewer / ShareModal
    participant SA as PUT /workspaces/[id]/files/[fileId]/share
    participant SM as ShareManager (DB)
    participant PU as Public Visitor
    participant PA as GET /files/public/[token]
    participant PC as GET /files/public/[token]/content

    U->>FM: Toggle share ON → Save
    FM->>SA: "PUT { isActive: true }"
    SA->>SM: upsertFileShare()
    SM-->>SA: "{ token, url, isActive: true }"
    SA-->>FM: "200 { share }"
    FM-->>U: Toast with link (copy action)

    PU->>PA: "GET /api/files/public/{token}"
    PA->>SM: resolveActiveShareByToken(token)
    SM-->>PA: "{ file, workspaceName, ownerName }"
    PA-->>PU: "{ name, type, size, workspaceName, ownerName }"

    PU->>PC: "GET /api/files/public/{token}/content"
    PC->>SM: resolveActiveShareByToken(token)
    SM-->>PC: "{ file }"
    PC-->>PU: file bytes (or compiled artifact)
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile

Comment thread packages/db/schema.ts Outdated
Comment thread apps/sim/app/api/files/public/[token]/content/route.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Fixed the share-modal init bug Greptile flagged: draftActive is now null until the user toggles, and the switch/Save derive from the authoritative useFileShare value (falling back to the row snapshot only for flicker-free first paint) — so a Save can no longer silently flip sharing the wrong way when the fetched state differs from the initial prop.

@greptile review

Comment thread apps/sim/app/api/files/public/[token]/content/route.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Thanks for the reviews — addressed all findings:

Fixed in this PR

  • Share-modal draft desync (Bugbot High / Greptile P1 ×2): draftActive is now null until the user toggles; the switch + Save derive from the authoritative useFileShare value (row snapshot only for flicker-free first paint), so Save can't silently flip sharing. (057994ff)
  • No rate limiting on public content (Greptile P2 security): added per-IP token-bucket limiting to both public endpoints — 120/min metadata, 60/min content (S3 egress) — returning 429 + Retry-After. (f8831aa4)
  • Public content cached 1 year (Bugbot High): the content response now sets Cache-Control: private, no-cache, must-revalidate, so unshare/edit/delete take effect immediately instead of serving stale bytes. (672a2679)
  • Large CSV OOM on public page (Bugbot Medium): CsvTablePreview's streamed fallback is workspace-only, so on the read-only public path a large CSV (>5MB) is now download-only instead of loading the whole file into the browser. (672a2679)
  • created_by CASCADE nukes shares on user deletion (Greptile P1): changed to ON DELETE SET NULL so a share + its link outlive the creator (the file still belongs to the workspace). (672a2679)
  • Soft-deleted filter in app code (Greptile P2): moved deletedAt IS NULL into the resolveActiveShareByToken SQL WHERE. (672a2679)
  • Anchor download not appended to DOM (Greptile P2): append/remove around .click() for Firefox reliability. (672a2679)

Acknowledged, not changing

  • Bugbot's "unauthenticated token-gated access" overview is the intended design — defended by 126-bit unguessable tokens, uniform no-leak 404s, write/admin-gated creation, and now per-IP rate limiting. Distributed (multi-IP) abuse remains an edge/WAF concern, noted for follow-up.

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Fixed the public-CSV import toast (Greptile P1): threaded a disableImport flag through PreviewPanelCsvPreviewuseCsvTruncationImport. The read-only public viewer sets it, so a truncated shared CSV still shows its first-N-row table but no longer surfaces the 'Import as a table' action (which would have fired an unauthenticated import with the synthetic token IDs). In-app behavior is unchanged (flag defaults off).

@greptile review

Comment thread apps/sim/app/f/[token]/public-file-view.tsx
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Addressed the synthetic-record staleness: the page now threads the file's real updatedAt (epoch ms) as a version into PublicFileView, and the synthetic record folds it into both key (${token}@${version}) and updatedAt. Those feed the React Query keys for the text (useWorkspaceFileContent) and binary (useWorkspaceFileBinary) hooks, so an edit — including a same-size one — busts the cache and the viewer refetches fresh content. Combined with the earlier no-cache, must-revalidate on the content response (HTTP layer) and the server-rendered (force-dynamic) metadata, cold loads and refetches now always reflect current bytes/text.

@greptile review

@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 f2fe99e. Configure here.

Comment thread apps/sim/app/api/files/public/[token]/content/route.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Two Bugbot items:

  1. "Public viewer stale after edits" (public-file-view.tsx) — already resolved in f2fe99e3 (re-review lagged a commit): the synthetic record now folds the file's real updatedAt into key + updatedAt, so the text/binary React Query keys bust on edit, and the content response is no-cache, must-revalidate.

  2. "Public route serves doc source" — fixed in 30f2c7d9. resolveServableDoc now returns a discriminated result: passthrough (uploaded binary / non-doc), artifact (prebuilt compiled binary), or unavailable (generated doc whose compiled artifact isn't built yet). On unavailable the content route returns 409 ('still being prepared') instead of streaming raw source under the binary content type — matching the authenticated serve route's behavior, so shared generated docs can never download/preview as corrupt source.

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks merged commit f0b3550 into staging Jun 19, 2026
16 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the feat/public-files branch June 19, 2026 01:26
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