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

* feat(files): public share links for workspace files

* improvement(files): drop reserved public_share columns until used; sync audit mock

* fix(files): share modal tracks authoritative saved state until toggled

* feat(files): per-IP rate limit on public share endpoints

* fix(files): address PR review — public CSV OOM, content cache, share FK, soft-delete filter, download anchor

* fix(files): disable CSV import action in read-only preview (public share)

* refactor(files): drive CSV preview import affordance off readOnly, not disableImport

* fix(files): version public viewer caches by file updatedAt so edits aren't stale

* fix(files): 409 (not corrupt source) when a shared generated doc has no compiled artifact

* feat(files): gate public sharing behind an access-control permission
This commit is contained in:
Theodore Li
2026-06-18 21:26:49 -04:00
committed by GitHub
parent 267e49c69c
commit f0b3550729
38 changed files with 18222 additions and 28 deletions
@@ -0,0 +1,80 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('PublicFileContentAPI')
/**
* GET /api/files/public/[token]/content
* Public, unauthenticated bytes for a shared file. Authorized solely by an active
* share token — never by workspace membership. 404 for unknown/inactive/deleted
* shares. Disposition (inline vs attachment) is resolved from the file type by
* {@link createFileResponse}; the public page's Download button uses `<a download>`.
*
* Generated office docs are stored as source; {@link resolveServableDoc} swaps in
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
* pass through untouched. A generated doc whose compiled artifact isn't built yet
* returns 409 rather than serving raw source under a binary content type.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
try {
const limited = await enforcePublicFileRateLimit(request, 'content')
if (limited) return limited
const parsed = await parseRequest(getPublicFileContentContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
throw new FileNotFoundError('Not found')
}
const { file } = resolved
const raw = await downloadFile({ key: file.key, context: 'workspace' })
const servable = file.workspaceId
? await resolveServableDoc(file.workspaceId, raw, file.originalName)
: ({ kind: 'passthrough' } as const)
if (servable.kind === 'unavailable') {
logger.info('Public shared doc not yet compiled', { token, key: file.key })
return NextResponse.json(
{ error: 'This document is still being prepared. Please try again shortly.' },
{ status: 409 }
)
}
const buffer = servable.kind === 'artifact' ? servable.buffer : raw
const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType
logger.info('Public shared file served', { token, key: file.key, size: buffer.length })
// Revalidate every request: a shared file can be unshared, edited, or deleted,
// so the fixed token URL must never serve stale bytes from a long-lived cache.
return createFileResponse({
buffer,
contentType,
filename: file.originalName,
cacheControl: 'private, no-cache, must-revalidate',
})
} catch (error) {
logger.error('Error serving public shared file:', error)
if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
}
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
}
}
)
@@ -0,0 +1,75 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockResolveActiveShareByToken, mockEnforceRateLimit } = vi.hoisted(() => ({
mockResolveActiveShareByToken: vi.fn(),
mockEnforceRateLimit: vi.fn(),
}))
vi.mock('@/lib/public-shares/share-manager', () => ({
resolveActiveShareByToken: mockResolveActiveShareByToken,
}))
vi.mock('@/lib/public-shares/rate-limit', () => ({
enforcePublicFileRateLimit: mockEnforceRateLimit,
}))
import { NextResponse } from 'next/server'
import { GET } from '@/app/api/files/public/[token]/route'
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)
describe('GET /api/files/public/[token]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
})
it('returns 429 when the per-IP rate limit is exceeded', async () => {
mockEnforceRateLimit.mockResolvedValueOnce(
NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 })
)
const res = await GET(request(), params())
expect(res.status).toBe(429)
expect(mockResolveActiveShareByToken).not.toHaveBeenCalled()
})
it('returns 404 for an unknown or inactive token', async () => {
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
const res = await GET(request(), params())
expect(res.status).toBe(404)
})
it('returns public-safe metadata (name/type/size + provenance) without leaking the key or workspace id', async () => {
mockResolveActiveShareByToken.mockResolvedValueOnce({
share: { id: 'sh_1', token: 'tok_1' },
file: {
id: 'wf_1',
key: 'workspace/ws/secret-key.pdf',
workspaceId: 'ws-secret',
originalName: 'report.pdf',
contentType: 'application/pdf',
size: 2048,
},
workspaceName: 'Acme Workspace',
ownerName: 'Jane Doe',
})
const res = await GET(request(), params())
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
token: 'tok_1',
name: 'report.pdf',
type: 'application/pdf',
size: 2048,
workspaceName: 'Acme Workspace',
ownerName: 'Jane Doe',
})
expect(JSON.stringify(body)).not.toContain('secret-key')
expect(JSON.stringify(body)).not.toContain('ws-secret')
})
})
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicFileContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
export const dynamic = 'force-dynamic'
const logger = createLogger('PublicFileMetadataAPI')
/**
* GET /api/files/public/[token]
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
* inactive, or deleted shares — the existence of a file is never leaked.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
try {
const limited = await enforcePublicFileRateLimit(request, 'metadata')
if (limited) return limited
const parsed = await parseRequest(getPublicFileContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const { file, workspaceName, ownerName } = resolved
return NextResponse.json({
token,
name: file.originalName,
type: file.contentType,
size: file.size,
workspaceName,
ownerName,
})
} catch (error) {
logger.error('Error fetching public file metadata:', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch file') },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
vi.hoisted(() => ({
mockGetWorkspaceFile: vi.fn(),
mockGetShareForResource: vi.fn(),
mockUpsertFileShare: vi.fn(),
mockValidateSharing: vi.fn(),
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
getWorkspaceFile: mockGetWorkspaceFile,
}))
vi.mock('@/lib/public-shares/share-manager', () => ({
getShareForResource: mockGetShareForResource,
upsertFileShare: mockUpsertFileShare,
}))
vi.mock('@/ee/access-control/utils/permission-check', () => {
class PublicFileSharingNotAllowedError extends Error {
constructor() {
super('Public file sharing is not allowed based on your permission group settings')
this.name = 'PublicFileSharingNotAllowedError'
}
}
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
})
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@sim/audit', () => auditMock)
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
const FILE_ID = 'wf_abc'
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
const putRequest = (body: unknown) =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const getRequest = () =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
const SHARE = {
id: 'sh_1',
token: 'tok_1',
url: 'https://sim.ai/f/tok_1',
isActive: true,
resourceType: 'file' as const,
resourceId: FILE_ID,
}
describe('share route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
mockGetShareForResource.mockResolvedValue(SHARE)
mockUpsertFileShare.mockResolvedValue(SHARE)
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(401)
})
it('returns 403 when the caller has no workspace access', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(403)
})
it('returns the share for a member', async () => {
const res = await GET(getRequest(), params())
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ share: SHARE })
})
})
describe('PUT', () => {
it('returns 403 for a read-only member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(403)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('returns 404 when the file is not in the workspace', async () => {
mockGetWorkspaceFile.mockResolvedValueOnce(null)
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(404)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('enables the share for a writer', async () => {
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(200)
expect(mockUpsertFileShare).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
isActive: true,
})
expect(await res.json()).toEqual({ share: SHARE })
})
it('returns 403 when org access-control disables public sharing (enable)', async () => {
const { PublicFileSharingNotAllowedError } = await import(
'@/ee/access-control/utils/permission-check'
)
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(403)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})
it('allows disabling a share even when policy disallows enabling', async () => {
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
const res = await PUT(putRequest({ isActive: false }), params())
expect(res.status).toBe(200)
expect(mockValidateSharing).not.toHaveBeenCalled()
expect(mockUpsertFileShare).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
isActive: false,
})
})
it('rejects a missing isActive body', async () => {
const res = await PUT(putRequest({}), params())
expect(res.status).toBe(400)
})
})
})
@@ -0,0 +1,146 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getShareForResource, upsertFileShare } from '@/lib/public-shares/share-manager'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
PublicFileSharingNotAllowedError,
validatePublicFileSharing,
} from '@/ee/access-control/utils/permission-check'
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkspaceFileShareAPI')
/**
* GET /api/workspaces/[id]/files/[fileId]/share
* Fetch the public share state for a file (requires workspace membership).
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getFileShareContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission === null) {
logger.warn(
`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const file = await getWorkspaceFile(workspaceId, fileId)
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const share = await getShareForResource('file', fileId)
return NextResponse.json({ share })
} catch (error) {
logger.error(`[${requestId}] Error fetching file share:`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch share') },
{
status: 500,
}
)
}
}
)
/**
* PUT /api/workspaces/[id]/files/[fileId]/share
* Enable or disable the public share for a file (requires write permission).
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(upsertFileShareContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { isActive } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
logger.warn(
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const file = await getWorkspaceFile(workspaceId, fileId)
if (!file) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// Enabling a public link is gated by the org's access-control policy; disabling
// is always allowed so users can still un-share after the policy is turned on.
if (isActive) {
try {
await validatePublicFileSharing(session.user.id, workspaceId)
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
return NextResponse.json({ error: error.message }, { status: 403 })
}
throw error
}
}
const share = await upsertFileShare({
workspaceId,
fileId,
userId: session.user.id,
isActive,
})
logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: file.name,
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
request,
})
return NextResponse.json({ share })
} catch (error) {
logger.error(`[${requestId}] Error updating file share:`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update share') },
{
status: 500,
}
)
}
}
)
@@ -11,6 +11,7 @@ import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSharesForResources } from '@/lib/public-shares/share-manager'
import {
FileConflictError,
listWorkspaceFiles,
@@ -68,11 +69,20 @@ export const GET = withRouteHandler(
const files = await listWorkspaceFiles(workspaceId, { scope })
const shares = await getSharesForResources(
'file',
files.map((file) => file.id)
)
const filesWithShares = files.map((file) => ({
...file,
share: shares.get(file.id) ?? null,
}))
logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`)
return NextResponse.json({
success: true,
files,
files: filesWithShares,
})
} catch (error) {
logger.error(`[${requestId}] Error listing workspace files:`, error)
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { PublicFileView } from '@/app/f/[token]/public-file-view'
export const dynamic = 'force-dynamic'
/** Shared links must never be indexed by search engines. */
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
interface PublicFilePageProps {
params: Promise<{ token: string }>
}
export default async function PublicFilePage({ params }: PublicFilePageProps) {
const { token } = await params
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
notFound()
}
const { file, workspaceName, ownerName } = resolved
return (
<PublicFileView
token={token}
name={file.originalName}
type={file.contentType}
size={file.size}
version={file.updatedAt.getTime()}
workspaceName={workspaceName}
ownerName={ownerName}
/>
)
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
import { useMemo } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Chip } from '@/components/emcn'
import { Download } from '@/components/emcn/icons'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useBrandConfig } from '@/ee/whitelabeling'
import { type FileContentSource, FileContentSourceProvider } from '@/hooks/use-file-content-source'
interface PublicFileViewProps {
token: string
name: string
type: string
size: number
/** Content version (the file's `updatedAt`, epoch ms) — busts the viewer's caches when the file changes. */
version: number
workspaceName: string | null
ownerName: string | null
}
export function PublicFileView({
token,
name,
type,
size,
version,
workspaceName,
ownerName,
}: PublicFileViewProps) {
const contentUrl = `/api/files/public/${token}/content`
const brand = useBrandConfig()
const provenance = [workspaceName, ownerName ? `Shared by ${ownerName}` : null]
.filter(Boolean)
.join(' · ')
// The public viewer reuses the in-app FileViewer; the content source seam swaps
// the auth-gated workspace serve URL for the token-scoped public endpoint, and a
// synthetic record carries the metadata the renderers/query keys need. `key` and
// `updatedAt` fold in the content version so the React Query caches (keyed on the
// storage key + `updatedAt`) refetch when the shared file changes — even when its
// size is unchanged.
const source = useMemo<FileContentSource>(() => ({ buildUrl: () => contentUrl }), [contentUrl])
const file = useMemo<WorkspaceFileRecord>(
() => ({
id: token,
workspaceId: token,
name,
key: `${token}@${version}`,
path: contentUrl,
size,
type,
uploadedBy: '',
folderId: null,
uploadedAt: new Date(version),
updatedAt: new Date(version),
}),
[token, name, type, size, version, contentUrl]
)
return (
<div className='flex min-h-screen flex-col bg-[var(--bg)]'>
<header className='sticky top-0 z-10 flex items-center justify-between gap-4 border-[var(--border)] border-b bg-[var(--bg)] px-4 py-3'>
<div className='flex min-w-0 items-center gap-3'>
{!brand.logoUrl && (
<>
<Link
href='https://sim.ai'
target='_blank'
rel='noopener noreferrer'
aria-label='Sim home'
className='shrink-0'
>
<Image
src='/logo/wordmark-dark.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto dark:hidden'
priority
/>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='hidden h-[22px] w-auto dark:block'
priority
/>
</Link>
<div className='h-5 w-px shrink-0 bg-[var(--border)]' />
</>
)}
<div className='flex min-w-0 flex-col'>
<span className='truncate font-medium text-[14px] text-[var(--text-body)]'>{name}</span>
{provenance ? (
<span className='truncate text-[12px] text-[var(--text-muted)]'>{provenance}</span>
) : null}
</div>
</div>
<Chip
variant='primary'
leftIcon={Download}
onClick={() => {
const anchor = document.createElement('a')
anchor.href = contentUrl
anchor.download = name
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}}
>
Download
</Chip>
</header>
<main className='flex min-h-0 flex-1 flex-col'>
<FileContentSourceProvider value={source}>
<FileViewer file={file} workspaceId={token} canEdit={false} readOnly />
</FileContentSourceProvider>
</main>
</div>
)
}
@@ -15,7 +15,7 @@ import {
FolderInput,
Pencil,
} from '@/components/emcn'
import { Download, Trash } from '@/components/emcn/icons'
import { Download, Link, Trash } from '@/components/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
@@ -28,6 +28,7 @@ interface FileRowContextMenuProps {
onRename: () => void
onDelete: () => void
onMove?: (optionValue: string) => void
onShare?: () => void
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
@@ -42,6 +43,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
onRename,
onDelete,
onMove,
onShare,
moveOptions,
canEdit,
selectedCount,
@@ -85,6 +87,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
Rename
</DropdownMenuItem>
)}
{!isMultiSelect && onShare && (
<DropdownMenuItem onSelect={onShare}>
<Link />
Share
</DropdownMenuItem>
)}
{onMove && moveOptions && moveOptions.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@@ -19,7 +19,8 @@ export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>
export function useCsvTruncationImport(
workspaceId: string,
file: CsvImportFileDescriptor,
truncated: boolean
truncated: boolean,
readOnly = false
) {
const router = useRouter()
const importFile = useImportFileAsTable()
@@ -58,11 +59,11 @@ export function useCsvTruncationImport(
// Surface the cap as a warning toast with an import action, once per file.
const notifiedKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!truncated || notifiedKeyRef.current === file.key) return
if (readOnly || !truncated || notifiedKeyRef.current === file.key) return
notifiedKeyRef.current = file.key
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
description: 'Import this file as a table to view all of its rows.',
action: { label: 'Import as a table', onClick: importAsTable },
})
}, [truncated, file.key, importAsTable])
}, [readOnly, truncated, file.key, importAsTable])
}
@@ -6,7 +6,7 @@ import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
import { resolveFileCategory } from './file-category'
import type { StreamingMode } from './text-editor-state'
import { useDocPreviewBinary } from './use-doc-preview-binary'
@@ -18,7 +18,7 @@ import { DocxPreview } from './docx-preview'
import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
import { PptxPreview } from './pptx-preview'
import { resolvePreviewType } from './preview-panel'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
@@ -72,6 +72,12 @@ interface FileViewerProps {
file: WorkspaceFileRecord
workspaceId: string
canEdit: boolean
/**
* Render a read-only preview with no editing affordances. Text files render
* through {@link PreviewPanel} (or a plain `<pre>`) instead of the editable
* {@link TextEditor}. Used by the public share page.
*/
readOnly?: boolean
previewMode?: PreviewMode
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
@@ -87,6 +93,7 @@ export function FileViewer({
file,
workspaceId,
canEdit,
readOnly = false,
previewMode,
autoFocus,
onDirtyChange,
@@ -100,6 +107,15 @@ export function FileViewer({
const category = resolveFileCategory(file.type, file.name)
if (category === 'text-editable') {
if (readOnly) {
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
// read-only public path a large CSV is download-only.
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
if (isCsvStreamOnly(file)) {
@@ -155,6 +171,53 @@ export function FileViewer({
return <UnsupportedPreview file={file} />
}
/**
* Read-only text/markdown/code preview. Renders rich types (markdown, csv, svg,
* mermaid, html) through {@link PreviewPanel} and plain text/code in a `<pre>`.
* Fetches content through the active content source, so it works for both
* workspace files and public share links.
*/
const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const {
data: content,
isLoading,
error,
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
const resolvedError = resolvePreviewError((error as Error | null) ?? null, null)
if (resolvedError) return <PreviewError label='file' error={resolvedError} />
if (isLoading || content == null) return <PreviewLoadingFrame className='h-full' tone='surface' />
if (resolvePreviewType(file.type, file.name)) {
return (
<div className='h-full min-h-0 w-full overflow-auto'>
<PreviewPanel
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileKey={file.key}
readOnly
/>
</div>
)
}
return (
<div className='h-full min-h-0 w-full overflow-auto bg-[var(--surface-1)] p-4'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-body)]'>
{content}
</pre>
</div>
)
})
const IframePreview = memo(function IframePreview({
file,
workspaceId,
@@ -2,10 +2,12 @@
import { memo } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { ZoomablePreview } from './zoomable-preview'
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const source = useFileContentSource()
const serveUrl = source.buildUrl(file.key)
return (
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
@@ -1,4 +1,4 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
@@ -83,6 +83,12 @@ interface PreviewPanelProps {
isStreaming?: boolean
disableAutoScroll?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
/**
* Read-only surface (e.g. the public share page) — disables interactive
* affordances such as the CSV "Import as a table" action, which needs an
* authenticated workspace import.
*/
readOnly?: boolean
}
export const PreviewPanel = memo(function PreviewPanel({
@@ -94,6 +100,7 @@ export const PreviewPanel = memo(function PreviewPanel({
isStreaming,
disableAutoScroll,
onCheckboxToggle,
readOnly,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
@@ -113,6 +120,7 @@ export const PreviewPanel = memo(function PreviewPanel({
content={content}
workspaceId={workspaceId}
file={{ key: fileKey, name: filename }}
readOnly={readOnly}
/>
)
if (previewType === 'svg') return <SvgPreview content={content} />
@@ -1167,13 +1175,15 @@ const CsvPreview = memo(function CsvPreview({
content,
workspaceId,
file,
readOnly,
}: {
content: string
workspaceId: string
file: CsvImportFileDescriptor
readOnly?: boolean
}) {
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
useCsvTruncationImport(workspaceId, file, truncated)
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
if (headers.length === 0) {
return (
@@ -0,0 +1 @@
export { ShareModal } from './share-modal'
@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipSwitch,
} from '@/components/emcn'
import { Link } from '@/components/emcn/icons'
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
import { usePermissionConfig } from '@/hooks/use-permission-config'
interface ShareModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
fileId: string
fileName: string
/** Share state already known from the file row, used as the initial value to avoid flicker. */
initialShare?: ShareRecord | null
}
const VISIBILITY_OPTIONS = [
{ value: 'private', label: 'Private' },
{ value: 'public', label: 'Anyone with link' },
]
export function ShareModal({
open,
onOpenChange,
workspaceId,
fileId,
fileName,
initialShare,
}: ShareModalProps) {
const { data: share } = useFileShare(workspaceId, fileId, { enabled: open })
const { config: permissionConfig } = usePermissionConfig()
const upsertShare = useUpsertFileShare()
const saved = share ?? initialShare ?? null
const savedActive = saved?.isActive ?? false
// Org access-control policy can disable enabling new public links (the route is the
// source of truth; this just reflects it). Disabling an existing share stays allowed.
const enableBlockedByPolicy = permissionConfig.disablePublicFileSharing && !savedActive
// `null` until the user toggles, so the switch always reflects the authoritative
// saved state (which may resolve after mount via useFileShare) instead of a stale
// initial snapshot — otherwise a Save could silently flip sharing the wrong way.
const [draftActive, setDraftActive] = useState<boolean | null>(null)
const effectiveActive = draftActive ?? savedActive
const isDirty = draftActive !== null && draftActive !== savedActive
const handleSave = () => {
upsertShare.mutate(
{ workspaceId, fileId, isActive: effectiveActive },
{ onSuccess: () => onOpenChange(false) }
)
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} size='sm' srTitle={`Share ${fileName}`}>
<ChipModalHeader icon={Link} onClose={() => onOpenChange(false)}>
Share file
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='custom'
title='Access'
hint={
enableBlockedByPolicy
? 'Public sharing is disabled for this workspace by an administrator.'
: effectiveActive
? isDirty
? 'Save to make this file accessible to anyone with the link.'
: 'Anyone with the link can view and download this file.'
: 'Only workspace members can access this file.'
}
>
<ChipSwitch
value={effectiveActive ? 'public' : 'private'}
onChange={(value) => setDraftActive(value === 'public')}
options={VISIBILITY_OPTIONS}
aria-label='File access'
/>
</ChipModalField>
{saved?.isActive ? (
<ChipModalField type='copy' title='Link' value={saved.url} copyLabel='Copy link' />
) : null}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
primaryAction={{
label: upsertShare.isPending ? 'Saving...' : 'Save',
onClick: handleSave,
disabled: !isDirty || upsertShare.isPending || (effectiveActive && enableBlockedByPolicy),
}}
/>
</ChipModal>
)
}
@@ -22,7 +22,7 @@ import {
toast,
Upload,
} from '@/components/emcn'
import { Download } from '@/components/emcn/icons'
import { Download, Link } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { captureEvent } from '@/lib/posthog/client'
import { triggerFileDownload } from '@/lib/uploads/client/download'
@@ -70,6 +70,7 @@ import {
isTextEditable,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu'
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -269,6 +270,7 @@ export function Files() {
folderIds: string[]
name: string
} | null>(null)
const [shareFileId, setShareFileId] = useState<string | null>(null)
const listRename = useInlineRename({
onSave: (rowId, name) => {
@@ -296,6 +298,18 @@ export function Files() {
const selectedFileRef = useRef(selectedFile)
selectedFileRef.current = selectedFile
const shareFile = shareFileId ? (files.find((f) => f.id === shareFileId) ?? null) : null
const shareModal = shareFile ? (
<ShareModal
open
onOpenChange={(open) => !open && setShareFileId(null)}
workspaceId={workspaceId}
fileId={shareFile.id}
fileName={shareFile.name}
initialShare={shareFile.share ?? null}
/>
) : null
const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders])
const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null
@@ -978,6 +992,11 @@ export function Files() {
}
}, [])
const handleShareSelected = useCallback(() => {
const file = selectedFileRef.current
if (file) setShareFileId(file.id)
}, [])
const handleBulkDelete = useCallback(() => {
if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return
setDeleteTarget({
@@ -1055,6 +1074,7 @@ export function Files() {
...(canEdit
? [
{ label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename },
{ label: 'Share', icon: Link, onClick: handleShareSelected },
{ label: 'Delete', icon: Trash, onClick: handleDeleteSelected },
]
: []),
@@ -1071,6 +1091,7 @@ export function Files() {
headerRename.editValue,
handleStartHeaderRename,
handleDownloadSelected,
handleShareSelected,
handleDeleteSelected,
])
@@ -1220,6 +1241,12 @@ export function Files() {
closeContextMenu()
}, [listRename.startRename, closeContextMenu])
const handleContextMenuShare = useCallback(() => {
const item = contextMenuItemRef.current
if (item?.kind === 'file') setShareFileId(item.file.id)
closeContextMenu()
}, [closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
const item = contextMenuItemRef.current
if (!item) return
@@ -1448,6 +1475,11 @@ export function Files() {
},
...(canEdit
? [
{
text: 'Share',
icon: Link,
onSelect: handleShareSelected,
},
{
text: 'Delete',
icon: Trash,
@@ -1466,6 +1498,7 @@ export function Files() {
handleTogglePreview,
handleSave,
handleDownloadSelected,
handleShareSelected,
handleDeleteSelected,
])
@@ -1875,6 +1908,8 @@ export function Files() {
onDelete={handleDelete}
isPending={deleteFile.isPending || bulkArchiveItems.isPending}
/>
{shareModal}
</>
)
}
@@ -1956,6 +1991,7 @@ export function Files() {
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
onMove={handleContextMenuMove}
onShare={canEdit ? handleContextMenuShare : undefined}
moveOptions={contextMenuMoveOptions}
canEdit={canEdit}
selectedCount={selectedRowIds.size}
@@ -1971,6 +2007,8 @@ export function Files() {
isPending={deleteFile.isPending || bulkArchiveItems.isPending}
/>
{shareModal}
<input
ref={fileInputRef}
type='file'
@@ -644,6 +644,12 @@ export function AccessControl() {
category: 'Features',
configKey: 'disablePublicApi' as const,
},
{
id: 'disable-public-file-sharing',
label: 'Public Sharing',
category: 'Files',
configKey: 'disablePublicFileSharing' as const,
},
],
[]
)
@@ -32,6 +32,7 @@ const {
disableSkills: false,
disableInvitations: false,
disablePublicApi: false,
disablePublicFileSharing: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
@@ -84,6 +84,13 @@ export class PublicApiNotAllowedError extends Error {
}
}
export class PublicFileSharingNotAllowedError extends Error {
constructor() {
super('Public file sharing is not allowed based on your permission group settings')
this.name = 'PublicFileSharingNotAllowedError'
}
}
/**
* Merges the env allowlist into a permission config.
* If `config` is null and no env allowlist is set, returns null.
@@ -284,6 +291,21 @@ export async function getUserPermissionConfig(
return mergeEnvAllowlist(resolved?.config ?? null)
}
/**
* Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
* group for the workspace disables public file sharing. No-op when access control
* doesn't apply (non-enterprise / disabled), so non-governed orgs are unaffected.
*/
export async function validatePublicFileSharing(
userId: string,
workspaceId: string
): Promise<void> {
const config = await getUserPermissionConfig(userId, workspaceId)
if (config?.disablePublicFileSharing) {
throw new PublicFileSharingNotAllowedError()
}
}
/**
* Org-addressed variant of {@link getUserPermissionConfig}. Use when only the
* organization is known (e.g. organization-level invitations); resolves the
+81
View File
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import {
getFileShareContract,
type ShareRecord,
type UpsertFileShareBody,
upsertFileShareContract,
} from '@/lib/api/contracts/public-shares'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
/**
* Query key factories for public shares
*/
export const shareKeys = {
all: ['publicShares'] as const,
details: () => [...shareKeys.all, 'detail'] as const,
detail: (workspaceId: string, fileId: string) =>
[...shareKeys.details(), workspaceId, fileId] as const,
}
async function fetchFileShare(
workspaceId: string,
fileId: string,
signal?: AbortSignal
): Promise<ShareRecord | null> {
const data = await requestJson(getFileShareContract, {
params: { id: workspaceId, fileId },
signal,
})
return data.share
}
export function useFileShare(workspaceId: string, fileId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: shareKeys.detail(workspaceId, fileId),
queryFn: ({ signal }) => fetchFileShare(workspaceId, fileId, signal),
enabled: Boolean(workspaceId) && Boolean(fileId) && (options?.enabled ?? true),
staleTime: 30 * 1000,
})
}
interface UpsertFileShareVariables extends UpsertFileShareBody {
workspaceId: string
fileId: string
}
export function useUpsertFileShare() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ workspaceId, fileId, isActive }: UpsertFileShareVariables) =>
requestJson(upsertFileShareContract, {
params: { id: workspaceId, fileId },
body: { isActive },
}),
onSuccess: (data, { workspaceId, fileId, isActive }) => {
queryClient.setQueryData(shareKeys.detail(workspaceId, fileId), data.share)
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
if (!isActive) {
toast.success('Sharing turned off')
return
}
const { url } = data.share
toast.success('Public link enabled', {
description: url,
action: {
label: 'Copy link',
onClick: () => {
navigator.clipboard.writeText(url).then(
() => toast.success('Link copied'),
() => toast.error('Failed to copy link')
)
},
},
})
},
onError: (error) => {
toast.error(error.message)
},
})
}
+16 -15
View File
@@ -22,6 +22,7 @@ import {
} from '@/lib/uploads/client/direct-upload'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { UserFile } from '@/executor/types'
import { useFileContentSource } from '@/hooks/use-file-content-source'
const logger = createLogger('WorkspaceFilesQuery')
@@ -114,16 +115,11 @@ export function useWorkspaceFiles(
}
/**
* Fetch file content as text via the serve URL
* Fetch file content as text via a content-source URL
*/
async function fetchWorkspaceFileContent(
key: string,
signal?: AbortSignal,
raw?: boolean
): Promise<string> {
const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}${raw ? '&raw=1' : ''}`
async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Promise<string> {
// boundary-raw-fetch: binary/text download, response is not JSON
const response = await fetch(serveUrl, { signal, cache: 'no-store' })
const response = await fetch(url, { signal, cache: 'no-store' })
if (!response.ok) {
throw new Error('Failed to fetch file content')
@@ -143,9 +139,11 @@ export function useWorkspaceFileContent(
key: string,
raw?: boolean
) {
const source = useFileContentSource()
return useQuery({
queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text', key),
queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal, raw),
queryFn: ({ signal }) =>
fetchWorkspaceFileContent(source.buildUrl(key, { raw, bust: true }), signal),
enabled: !!workspaceId && !!fileId && !!key,
staleTime: 30 * 1000,
refetchOnWindowFocus: 'always',
@@ -177,16 +175,13 @@ export class DocNotReadyError extends Error {
* so the query keeps polling.
*/
async function fetchWorkspaceFileBinary(
key: string,
url: string,
version: string | number | undefined,
signal?: AbortSignal
): Promise<ArrayBuffer> {
const cacheParam =
version != null ? `v=${encodeURIComponent(String(version))}` : `t=${Date.now()}`
const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&${cacheParam}`
const init: RequestInit = version != null ? { signal } : { signal, cache: 'no-store' }
// boundary-raw-fetch: binary download consumed as ArrayBuffer
const response = await fetch(serveUrl, init)
const response = await fetch(url, init)
if (response.status === 409) throw new DocNotReadyError()
if (!response.ok) throw new Error('Failed to fetch file content')
return response.arrayBuffer()
@@ -210,12 +205,18 @@ export function useWorkspaceFileBinary(
key: string,
options?: { enabled?: boolean; version?: string | number }
) {
const source = useFileContentSource()
return useQuery({
queryKey:
options?.version != null
? [...workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), options.version]
: workspaceFilesKeys.content(workspaceId, fileId, 'binary', key),
queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, options?.version, signal),
queryFn: ({ signal }) =>
fetchWorkspaceFileBinary(
source.buildUrl(key, { version: options?.version, bust: true }),
options?.version,
signal
),
// Callers gate this on a readiness signal (e.g. the file has committed
// content) so we don't 409-poll the serve route for a generated doc whose
// compiled artifact hasn't been written yet — the doc is fetched once, when
@@ -0,0 +1,42 @@
'use client'
import { createContext, useContext } from 'react'
export interface FileContentUrlOptions {
/** Request the uncompiled source instead of the rendered/compiled bytes. */
raw?: boolean
/** Content version (e.g. the record's `updatedAt`) — makes the URL cacheable/immutable. */
version?: string | number
/** Append a timestamp cache-buster when there is no `version`. */
bust?: boolean
}
/**
* Seam for "where do a file's bytes come from". The in-app viewer resolves the
* auth-gated workspace serve URL; the public share page swaps in a token-scoped
* URL. Renderers and the binary/text query hooks build their fetch URL through
* this source so the same components work in both contexts.
*/
export interface FileContentSource {
buildUrl: (key: string, opts?: FileContentUrlOptions) => string
}
/** Default source: the auth-gated workspace serve URL (the historical behavior). */
export const workspaceFileContentSource: FileContentSource = {
buildUrl: (key, opts) => {
const base = `/api/files/serve/${encodeURIComponent(key)}?context=workspace`
const params: string[] = []
if (opts?.version != null) params.push(`v=${encodeURIComponent(String(opts.version))}`)
else if (opts?.bust) params.push(`t=${Date.now()}`)
if (opts?.raw) params.push('raw=1')
return params.length > 0 ? `${base}&${params.join('&')}` : base
},
}
const FileContentSourceContext = createContext<FileContentSource>(workspaceFileContentSource)
export const FileContentSourceProvider = FileContentSourceContext.Provider
export function useFileContentSource(): FileContentSource {
return useContext(FileContentSourceContext)
}
@@ -21,6 +21,7 @@ export const permissionGroupFullConfigSchema = z.object({
disableSkills: z.boolean(),
disableInvitations: z.boolean(),
disablePublicApi: z.boolean(),
disablePublicFileSharing: z.boolean(),
hideDeployApi: z.boolean(),
hideDeployMcp: z.boolean(),
hideDeployA2a: z.boolean(),
@@ -0,0 +1,99 @@
import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const shareResourceTypeSchema = z.enum(['file', 'folder'])
/**
* Public-safe representation of a `public_share` row. Never carries the
* underlying storage key.
*/
export const shareRecordSchema = z.object({
id: z.string(),
token: z.string(),
url: z.string(),
isActive: z.boolean(),
resourceType: shareResourceTypeSchema,
resourceId: z.string(),
})
export type ShareRecord = z.output<typeof shareRecordSchema>
const fileShareParamsSchema = z.object({
id: workspaceIdSchema,
fileId: z.string().min(1, 'File ID is required'),
})
export const upsertFileShareBodySchema = z.object({
isActive: z.boolean(),
})
export type UpsertFileShareBody = z.input<typeof upsertFileShareBodySchema>
const getFileShareResponseSchema = z.object({
share: shareRecordSchema.nullable(),
})
export type GetFileShareResponse = z.output<typeof getFileShareResponseSchema>
export const getFileShareContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/files/[fileId]/share',
params: fileShareParamsSchema,
response: {
mode: 'json',
schema: getFileShareResponseSchema,
},
})
const upsertFileShareResponseSchema = z.object({
share: shareRecordSchema,
})
export type UpsertFileShareResponse = z.output<typeof upsertFileShareResponseSchema>
export const upsertFileShareContract = defineRouteContract({
method: 'PUT',
path: '/api/workspaces/[id]/files/[fileId]/share',
params: fileShareParamsSchema,
body: upsertFileShareBodySchema,
response: {
mode: 'json',
schema: upsertFileShareResponseSchema,
},
})
export const publicFileTokenParamsSchema = z.object({
token: z.string().min(1, 'Token is required'),
})
const publicFileMetadataSchema = z.object({
token: z.string(),
name: z.string(),
type: z.string(),
size: z.number(),
workspaceName: z.string().nullable(),
ownerName: z.string().nullable(),
})
export type PublicFileMetadata = z.output<typeof publicFileMetadataSchema>
export const getPublicFileContract = defineRouteContract({
method: 'GET',
path: '/api/files/public/[token]',
params: publicFileTokenParamsSchema,
response: {
mode: 'json',
schema: publicFileMetadataSchema,
},
})
/** Binary stream of the shared file's bytes. Authorized solely by an active token. */
export const getPublicFileContentContract = defineRouteContract({
method: 'GET',
path: '/api/files/public/[token]/content',
params: publicFileTokenParamsSchema,
response: {
mode: 'binary',
},
})
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { shareRecordSchema } from '@/lib/api/contracts/public-shares'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
@@ -49,6 +50,7 @@ export const workspaceFileRecordSchema = z.object({
uploadedAt: z.coerce.date(),
updatedAt: z.coerce.date(),
storageContext: z.enum(['workspace', 'mothership']).optional(),
share: shareRecordSchema.nullable().optional(),
})
const workspaceFileSuccessSchema = z.object({
@@ -414,3 +414,38 @@ export async function loadCompiledDocByExt(
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
return buffer ? { buffer, contentType: fmt.contentType } : null
}
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF-
function bufferStartsWith(buffer: Buffer, magic: Buffer): boolean {
return buffer.length >= magic.length && buffer.subarray(0, magic.length).equals(magic)
}
/**
* How a read-only consumer (e.g. the public share route) should serve a stored doc
* WITHOUT compiling:
* - `passthrough` — serve the raw stored bytes as-is (a non-doc file, or an uploaded
* binary that already carries its format magic).
* - `artifact` — serve this prebuilt content-addressed compiled binary.
* - `unavailable` — a generated doc stored as source whose compiled artifact does
* not exist yet; the raw bytes are source, so serving them under the file's binary
* content type would be corrupt. The caller should signal "not ready" instead.
*/
export type ServableDoc =
| { kind: 'passthrough' }
| { kind: 'artifact'; buffer: Buffer; contentType: string }
| { kind: 'unavailable' }
export async function resolveServableDoc(
workspaceId: string,
storedBytes: Buffer,
fileName: string
): Promise<ServableDoc> {
const fmt = await getE2BDocFormat(fileName)
if (!fmt) return { kind: 'passthrough' }
const magic = fmt.ext === 'pdf' ? PDF_MAGIC : ZIP_MAGIC
if (bufferStartsWith(storedBytes, magic)) return { kind: 'passthrough' }
const artifact = await loadCompiledDocByExt(workspaceId, storedBytes.toString('utf-8'), fmt.ext)
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
}
+5
View File
@@ -31,6 +31,7 @@ export const permissionGroupConfigSchema = z.object({
disableSkills: z.boolean().optional(),
disableInvitations: z.boolean().optional(),
disablePublicApi: z.boolean().optional(),
disablePublicFileSharing: z.boolean().optional(),
hideDeployApi: z.boolean().optional(),
hideDeployMcp: z.boolean().optional(),
hideDeployA2a: z.boolean().optional(),
@@ -60,6 +61,7 @@ export interface PermissionGroupConfig {
disableSkills: boolean
disableInvitations: boolean
disablePublicApi: boolean
disablePublicFileSharing: boolean
hideDeployApi: boolean
hideDeployMcp: boolean
hideDeployA2a: boolean
@@ -85,6 +87,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
disableSkills: false,
disableInvitations: false,
disablePublicApi: false,
disablePublicFileSharing: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
@@ -120,6 +123,8 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false,
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
disablePublicFileSharing:
typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false,
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
import { getClientIp } from '@/lib/core/utils/request'
const rateLimiter = new RateLimiter()
/** Metadata reads are cheap (one indexed lookup) — generous per-IP budget. */
const METADATA_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 120,
refillRate: 120,
refillIntervalMs: 60_000,
}
/** Content reads stream bytes from storage (S3 egress) — tighter per-IP budget. */
const CONTENT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 60,
refillRate: 60,
refillIntervalMs: 60_000,
}
/**
* Per-IP rate limit for the unauthenticated public share endpoints, returning a
* `429` response when exceeded (or `null` to proceed). The token is unguessable,
* so this defends a *known* link against hammering (DoS / S3 egress) rather than
* enumeration. Fails open on storage errors (availability over strictness),
* matching the chat public route.
*/
export async function enforcePublicFileRateLimit(
request: { headers: { get(name: string): string | null } },
scope: 'metadata' | 'content'
): Promise<NextResponse | null> {
const ip = getClientIp(request)
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
if (result.allowed) return null
const headers =
result.retryAfterMs != null
? { 'Retry-After': String(Math.ceil(result.retryAfterMs / 1000)) }
: undefined
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers }
)
}
+153
View File
@@ -0,0 +1,153 @@
import { db } from '@sim/db'
import { publicShare, user, workspace, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId, generateShortId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { z } from 'zod'
import type { ShareRecord, shareResourceTypeSchema } from '@/lib/api/contracts/public-shares'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('PublicShareManager')
type ShareResourceType = z.infer<typeof shareResourceTypeSchema>
type PublicShareRow = typeof publicShare.$inferSelect
/** Public share URL for a token: `{baseUrl}/f/{token}`. */
export function buildShareUrl(token: string): string {
return `${getBaseUrl()}/f/${token}`
}
function mapShareRecord(row: PublicShareRow): ShareRecord {
return {
id: row.id,
token: row.token,
url: buildShareUrl(row.token),
isActive: row.isActive,
resourceType: row.resourceType as ShareResourceType,
resourceId: row.resourceId,
}
}
export async function getShareForResource(
resourceType: ShareResourceType,
resourceId: string
): Promise<ShareRecord | null> {
const [row] = await db
.select()
.from(publicShare)
.where(and(eq(publicShare.resourceType, resourceType), eq(publicShare.resourceId, resourceId)))
.limit(1)
return row ? mapShareRecord(row) : null
}
/**
* Batch-fetch shares for many resources of the same type, keyed by `resourceId`.
* Used to enrich the files list without an N+1 query.
*/
export async function getSharesForResources(
resourceType: ShareResourceType,
resourceIds: string[]
): Promise<Map<string, ShareRecord>> {
const result = new Map<string, ShareRecord>()
if (resourceIds.length === 0) return result
const rows = await db
.select()
.from(publicShare)
.where(
and(eq(publicShare.resourceType, resourceType), inArray(publicShare.resourceId, resourceIds))
)
for (const row of rows) {
result.set(row.resourceId, mapShareRecord(row))
}
return result
}
interface UpsertFileShareInput {
workspaceId: string
fileId: string
userId: string
isActive: boolean
}
/**
* Enable or disable the public share for a file. First enable inserts a row with
* a fresh unguessable token; subsequent calls flip `isActive` and keep the token
* stable (so an existing link resolves again after re-enable).
*/
export async function upsertFileShare({
workspaceId,
fileId,
userId,
isActive,
}: UpsertFileShareInput): Promise<ShareRecord> {
const [row] = await db
.insert(publicShare)
.values({
id: generateId(),
resourceType: 'file',
resourceId: fileId,
workspaceId,
createdBy: userId,
token: generateShortId(),
isActive,
})
.onConflictDoUpdate({
target: [publicShare.resourceType, publicShare.resourceId],
set: { isActive, updatedAt: new Date() },
})
.returning()
logger.info('Upserted file share', { fileId, workspaceId, isActive, token: row.token })
return mapShareRecord(row)
}
/**
* Resolve a public token to its active share and the underlying (non-deleted)
* file. Returns null if the token is unknown, the share is inactive, or the file
* is gone. The caller treats null as a 404 — the existence of a file is never
* leaked through this path.
*/
export interface ResolvedShare {
share: PublicShareRow
file: typeof workspaceFiles.$inferSelect
/** Owning workspace name, for provenance on the public page. */
workspaceName: string | null
/** Display name of the file's uploader. */
ownerName: string | null
}
export async function resolveActiveShareByToken(token: string): Promise<ResolvedShare | null> {
const [row] = await db
.select({
share: publicShare,
file: workspaceFiles,
workspaceName: workspace.name,
ownerName: user.name,
})
.from(publicShare)
.innerJoin(workspaceFiles, eq(workspaceFiles.id, publicShare.resourceId))
.leftJoin(workspace, eq(workspace.id, workspaceFiles.workspaceId))
.leftJoin(user, eq(user.id, workspaceFiles.userId))
.where(
and(
eq(publicShare.token, token),
eq(publicShare.isActive, true),
eq(publicShare.resourceType, 'file'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)
if (!row) return null
return {
share: row.share,
file: row.file,
workspaceName: row.workspaceName,
ownerName: row.ownerName,
}
}
@@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { and, eq, isNull, sql } from 'drizzle-orm'
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
import {
checkStorageQuota,
decrementStorageUsage,
@@ -72,6 +73,8 @@ export interface WorkspaceFileRecord {
updatedAt: Date
/** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */
storageContext?: 'workspace' | 'mothership'
/** Public share state, attached at the API boundary. `null` when never shared. */
share?: ShareRecord | null
}
interface ListWorkspaceFilesOptions {
+2
View File
@@ -64,6 +64,8 @@ export const AuditAction = {
FILE_DELETED: 'file.deleted',
FILE_RESTORED: 'file.restored',
FILE_MOVED: 'file.moved',
FILE_SHARED: 'file.shared',
FILE_SHARE_DISABLED: 'file.share_disabled',
// Folders
FOLDER_CREATED: 'folder.created',
@@ -0,0 +1,18 @@
CREATE TABLE "public_share" (
"id" text PRIMARY KEY NOT NULL,
"resource_type" text NOT NULL,
"resource_id" text NOT NULL,
"workspace_id" text NOT NULL,
"created_by" text,
"token" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "public_share" ADD CONSTRAINT "public_share_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "public_share" ADD CONSTRAINT "public_share_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "public_share_token_unique" ON "public_share" USING btree ("token");--> statement-breakpoint
CREATE UNIQUE INDEX "public_share_resource_unique" ON "public_share" USING btree ("resource_type","resource_id");--> statement-breakpoint
CREATE INDEX "public_share_resource_id_idx" ON "public_share" USING btree ("resource_id");--> statement-breakpoint
CREATE INDEX "public_share_workspace_id_idx" ON "public_share" USING btree ("workspace_id");
File diff suppressed because it is too large Load Diff
@@ -1688,6 +1688,13 @@
"when": 1781700000000,
"tag": "0241_drop_table_row_cap_guard",
"breakpoints": true
},
{
"idx": 242,
"version": "7",
"when": 1781818772450,
"tag": "0242_public_share",
"breakpoints": true
}
]
}
+33
View File
@@ -1389,6 +1389,39 @@ export const workspaceFiles = pgTable(
})
)
/**
* Public share links for workspace resources. Polymorphic on `resourceType` so a
* single mechanism serves files now and folders later. One row per resource
* (disable/re-enable flips `isActive` and keeps the same token).
*/
export const publicShare = pgTable(
'public_share',
{
id: text('id').primaryKey(),
resourceType: text('resource_type').notNull(), // 'file' | 'folder' (folder reserved for future)
resourceId: text('resource_id').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
// SET NULL (not CASCADE) so a share — and its public link — outlives the user
// who created it; the file still belongs to the workspace.
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
token: text('token').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
tokenIdx: uniqueIndex('public_share_token_unique').on(table.token),
resourceUniqueIdx: uniqueIndex('public_share_resource_unique').on(
table.resourceType,
table.resourceId
),
resourceIdIdx: index('public_share_resource_id_idx').on(table.resourceId),
workspaceIdIdx: index('public_share_workspace_id_idx').on(table.workspaceId),
})
)
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
export const invitationWorkspaceGrant = pgTable(
+2
View File
@@ -76,6 +76,8 @@ export const auditMock = {
FILE_DELETED: 'file.deleted',
FILE_RESTORED: 'file.restored',
FILE_MOVED: 'file.moved',
FILE_SHARED: 'file.shared',
FILE_SHARE_DISABLED: 'file.share_disabled',
FOLDER_CREATED: 'folder.created',
FOLDER_UPDATED: 'folder.updated',
FOLDER_DELETED: 'folder.deleted',
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 853,
zodRoutes: 853,
totalRoutes: 856,
zodRoutes: 856,
nonZodRoutes: 0,
} as const