mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 01:23:02 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+9
-1
@@ -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'
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user