Skip to content

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

Open
TheodoreSpeaks wants to merge 9 commits into
stagingfrom
feat/public-files
Open

feat(files): public share links for workspace files#5130
TheodoreSpeaks wants to merge 9 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 10:11pm

Request Review

@cursor

cursor Bot commented Jun 18, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Introduces unauthenticated access to file bytes gated only by share tokens, plus new public endpoints and permission boundaries; misconfiguration or token leakage could expose workspace files outside membership.

Overview
Adds public file sharing so workspace members with write access can publish a file to an unguessable link at /f/{token}.

Backend: New public_share table and share manager (token upsert, batch lookup, token resolution). Authenticated GET/PUT on workspace file share routes with audit events; public metadata and content routes authorized only by active tokens, with per-IP rate limits and metadata that omits storage keys/workspace IDs. File listing is enriched with share state. Generated office docs on the public content path use prebuilt compiled artifacts via loadServableDocArtifact (no on-demand compile).

Frontend: Share modal and Share entry points in the files UI; React Query hooks for share state. Public page reuses FileViewer through a FileContentSource provider (token URL vs workspace serve URL), readOnly preview mode, and cache-busting on file updates. CSV “import as table” is suppressed on public views.

Reviewed by Cursor Bugbot for commit f2fe99e. 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 file sharing to the workspace, introducing a public_share table, unguessable per-file share tokens (/f/{token}), and a public viewer that reuses the in-app FileViewer component via a FileContentSource seam. The reviewer-requested fix threads a disableImport flag from PreviewPanel through CsvPreview into useCsvTruncationImport, ensuring the "Import as a table" toast is suppressed on the read-only public path where the synthetic token IDs would produce an unauthenticated import call.

  • New public_share table with polymorphic resource type/ID, an unguessable 21-char token (~126 bits entropy), ON DELETE SET NULL on createdBy so shares outlive their creator, and per-IP rate limiting on both public endpoints.
  • CSV disableImport fix: ReadOnlyTextPreview passes disableImport to PreviewPanelCsvPreviewuseCsvTruncationImport; large CSVs fall back to UnsupportedPreview since streamed CSV table preview is workspace-only.
  • Share modal uses lazy draftActive (seeded null until the user interacts) so stale initialShare props can never silently disable an active link.

Confidence Score: 5/5

The change is safe to merge — all previously raised issues have been resolved and no new defects were found.

All the concerns raised in the prior review round (draftActive stale-initialShare bug, createdBy CASCADE, download anchor reliability, deletedAt filter, rate limiting on the content route, and the CSV import toast firing on unauthenticated public paths) are addressed in this iteration. Token entropy is strong (~126 bits). The FileContentSource seam cleanly isolates the public URL substitution without touching existing workspace rendering paths. The migration is additive with no destructive changes.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx New share modal with lazy draftActive (null until user interacts) correctly prevents stale initialShare from triggering a silent isActive:false save on re-open; save/close/toast flow is clean.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts disableImport flag added as the final parameter (defaults false, non-breaking); useEffect dep array updated correctly; suppresses the 'Import as a table' truncation toast on read-only public paths.
apps/sim/lib/public-shares/share-manager.ts resolveActiveShareByToken now filters isNull(workspaceFiles.deletedAt) in SQL; upsertFileShare uses ON CONFLICT to keep token stable on re-enable; batch getSharesForResources avoids N+1 on file list.
apps/sim/app/api/files/public/[token]/content/route.ts Per-IP rate limiting enforced; resolves share by token (never by auth); delegates to loadServableDocArtifact for prebuilt artifacts; cache-control set to private, no-cache, must-revalidate; no storage key or workspace ID leaks to client.
packages/db/schema.ts New public_share table: createdBy uses ON DELETE SET NULL (correct), workspaceId uses ON DELETE CASCADE; unique index on (resourceType, resourceId) enforces one share per resource; token has its own unique index.
apps/sim/app/f/[token]/public-file-view.tsx Synthetic WorkspaceFileRecord uses token as key/id/workspaceId; FileContentSourceProvider swaps in the public URL; download anchor correctly appended/removed from DOM; no auth-gated data exposed to client.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx readOnly prop gates text-editable path: large CSVs fall back to UnsupportedPreview, others go through ReadOnlyTextPreview with disableImport; non-text paths (image, PDF, docx, etc.) are unaffected.
apps/sim/hooks/use-file-content-source.tsx Clean content-source seam; workspaceFileContentSource is the default context value; public viewer overrides it with a token-scoped URL; hooks updated to go through the source.
packages/db/migrations/0242_public_share.sql Additive CREATE TABLE migration; FK constraints match schema.ts (cascade on workspace, set null on user); four indexes created.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as Workspace User
    participant FE as Files UI
    participant ShareAPI as PUT /workspaces/.../share
    participant ShareMgr as ShareManager
    participant DB as public_share table
    participant PubPage as /f/[token] SSR
    participant PubAPI as GET /api/files/public/[token]/content
    participant RateLimit as Rate Limiter

    U->>FE: Open ShareModal, toggle Public, Save
    FE->>ShareAPI: PUT isActive:true
    ShareAPI->>ShareMgr: upsertFileShare()
    ShareMgr->>DB: INSERT ON CONFLICT UPDATE isActive
    DB-->>ShareMgr: row with stable token
    ShareMgr-->>ShareAPI: ShareRecord + URL
    ShareAPI-->>FE: 200 share
    FE->>U: Toast with Copy action

    U->>PubPage: GET /f/abc123
    PubPage->>ShareMgr: resolveActiveShareByToken(token)
    ShareMgr->>DB: JOIN workspaceFiles WHERE token AND isActive AND deletedAt IS NULL
    DB-->>ShareMgr: file metadata + provenance
    ShareMgr-->>PubPage: ResolvedShare
    PubPage->>U: Render PublicFileView

    U->>PubAPI: GET /api/files/public/token/content
    PubAPI->>RateLimit: enforcePublicFileRateLimit
    alt rate limit exceeded
        RateLimit-->>U: 429 Retry-After
    else allowed
        PubAPI->>ShareMgr: resolveActiveShareByToken
        ShareMgr-->>PubAPI: file record
        PubAPI->>PubAPI: loadServableDocArtifact
        PubAPI-->>U: bytes Cache-Control private no-cache
    end
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 Workspace User
    participant FE as Files UI
    participant ShareAPI as PUT /workspaces/.../share
    participant ShareMgr as ShareManager
    participant DB as public_share table
    participant PubPage as /f/[token] SSR
    participant PubAPI as GET /api/files/public/[token]/content
    participant RateLimit as Rate Limiter

    U->>FE: Open ShareModal, toggle Public, Save
    FE->>ShareAPI: PUT isActive:true
    ShareAPI->>ShareMgr: upsertFileShare()
    ShareMgr->>DB: INSERT ON CONFLICT UPDATE isActive
    DB-->>ShareMgr: row with stable token
    ShareMgr-->>ShareAPI: ShareRecord + URL
    ShareAPI-->>FE: 200 share
    FE->>U: Toast with Copy action

    U->>PubPage: GET /f/abc123
    PubPage->>ShareMgr: resolveActiveShareByToken(token)
    ShareMgr->>DB: JOIN workspaceFiles WHERE token AND isActive AND deletedAt IS NULL
    DB-->>ShareMgr: file metadata + provenance
    ShareMgr-->>PubPage: ResolvedShare
    PubPage->>U: Render PublicFileView

    U->>PubAPI: GET /api/files/public/token/content
    PubAPI->>RateLimit: enforcePublicFileRateLimit
    alt rate limit exceeded
        RateLimit-->>U: 429 Retry-After
    else allowed
        PubAPI->>ShareMgr: resolveActiveShareByToken
        ShareMgr-->>PubAPI: file record
        PubAPI->>PubAPI: loadServableDocArtifact
        PubAPI-->>U: bytes Cache-Control private no-cache
    end
Loading

Reviews (5): Last reviewed commit: "refactor(files): drive CSV preview impor..." | 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.

? await loadServableDocArtifact(file.workspaceId, raw, file.originalName)
: null
const buffer = artifact?.buffer ?? raw
const contentType = artifact?.contentType ?? file.contentType

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Public route serves doc source

Medium Severity

When loadServableDocArtifact finds no compiled artifact for a generated office/PDF file, the public content handler falls back to raw stored bytes while keeping the file’s binary contentType. Authenticated serve returns 409 until the artifact exists, so shared generated docs can download or preview as corrupt source instead of the compiled document.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2fe99e. Configure here.

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