Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Buffer, isUtf8 } from 'buffer'
import type { Readable } from 'stream'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
Expand All @@ -14,6 +15,11 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
import {
getShareForResource,
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
Expand All @@ -27,9 +33,14 @@ import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
PublicFileSharingNotAllowedError,
validatePublicFileSharing,
} from '@/ee/access-control/utils/permission-check'
import type { UserFile } from '@/executor/types'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -565,6 +576,75 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}

case 'manage_sharing': {
const { fileId, isActive, authType, password, allowedEmails } = body

// Check permission before probing file existence so a read-only caller
// can't distinguish 404 from 403 as a file-existence side channel.
// Publishing is more sensitive than the other mutating ops, so it
// requires write/admin (not just workspace access) like the share route.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json(
{ success: false, error: 'Insufficient permissions' },
{ status: 403 }
)
}

const file = await getWorkspaceFile(workspaceId, fileId)
if (!file) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileId}"` },
{ status: 404 }
)
}

// Enabling a share is gated by the org's access-control policy; disabling
// is always allowed so users can un-share after the policy is turned on.
if (isActive) {
// Resolve the auth type the same way upsertFileShare will (falling back
// to the existing share's type) so the policy gate can't be bypassed by
// re-enabling a pre-existing restricted share without an explicit authType.
const existingShare = await getShareForResource('file', fileId)
const resolvedAuthType = authType ?? existingShare?.authType ?? 'public'
try {
await validatePublicFileSharing(userId, workspaceId, resolvedAuthType)
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
return NextResponse.json({ success: false, error: error.message }, { status: 403 })
}
throw error
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const share = await upsertFileShare({
workspaceId,
fileId,
userId,
isActive,
authType,
password,
allowedEmails,
})

recordAudit({
workspaceId,
actorId: userId,
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,
})

logger.info('File sharing updated', { fileId, isActive, authType: share.authType })

// A disabled link doesn't resolve, so don't hand back a dead URL.
const responseShare = share.isActive ? share : { ...share, url: '' }
return NextResponse.json({ success: true, data: { share: responseShare } })
}

case 'append': {
const { fileName, content } = body

Expand Down Expand Up @@ -911,6 +991,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 403 }
)
}
if (error instanceof ShareValidationError) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
}
const message = getErrorMessage(error, 'Unknown error')
logger.error('File operation failed', { operation: body.operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe.concurrent('Blocks Module', () => {
'file_append',
'file_compress',
'file_decompress',
'file_manage_sharing',
])
expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress')
expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress')
Expand Down
99 changes: 99 additions & 0 deletions apps/sim/blocks/blocks/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,103 @@ describe('FileV5Block', () => {
'File is required for get content'
)
})

it('maps manage sharing to public access for a canonical file ID', () => {
expect(
buildParams({
operation: 'file_manage_sharing',
shareInput: 'file-1',
shareVisibility: 'public',
_context: { workspaceId: 'workspace-1' },
})
).toEqual({
fileId: 'file-1',
isActive: true,
authType: 'public',
password: undefined,
allowedEmails: undefined,
workspaceId: 'workspace-1',
})
})

it('maps private visibility to a disabled share with no authType', () => {
expect(
buildParams({
operation: 'file_manage_sharing',
shareInput: 'file-1',
shareVisibility: 'private',
_context: { workspaceId: 'workspace-1' },
})
).toMatchObject({
fileId: 'file-1',
isActive: false,
authType: undefined,
})
})

it('passes the password through for password visibility', () => {
expect(
buildParams({
operation: 'file_manage_sharing',
shareInput: 'file-1',
shareVisibility: 'password',
sharePassword: 'hunter2',
_context: { workspaceId: 'workspace-1' },
})
).toMatchObject({
fileId: 'file-1',
isActive: true,
authType: 'password',
password: 'hunter2',
})
})

it('splits allowed emails for email visibility', () => {
expect(
buildParams({
operation: 'file_manage_sharing',
shareInput: 'file-1',
shareVisibility: 'email',
shareAllowedEmails: 'a@example.com, b@example.com\n@acme.com',
_context: { workspaceId: 'workspace-1' },
})
).toMatchObject({
fileId: 'file-1',
isActive: true,
authType: 'email',
allowedEmails: ['a@example.com', 'b@example.com', '@acme.com'],
})
})

it('resolves the file ID from a selected workspace file object for manage sharing', () => {
expect(
buildParams({
operation: 'file_manage_sharing',
shareInput: [{ id: 'file-9', name: 'report.pdf' }],
shareVisibility: 'public',
_context: { workspaceId: 'workspace-1' },
})
).toMatchObject({
fileId: 'file-9',
isActive: true,
authType: 'public',
})
})

it('throws when no file is provided for manage sharing', () => {
expect(() => buildParams({ operation: 'file_manage_sharing' })).toThrow(
'File is required to manage sharing'
)
})

it('rejects multiple file IDs for manage sharing', () => {
expect(() =>
buildParams({
operation: 'file_manage_sharing',
shareInput: '["file-1","file-2"]',
shareVisibility: 'public',
_context: { workspaceId: 'workspace-1' },
})
).toThrow('Manage Sharing accepts a single file at a time')
})
})
Loading
Loading