Skip to content

Commit 0d59177

Browse files
feat(file): add Set File Sharing operation to the File block
Adds a new file_set_sharing operation to the File block (file_v5) that idempotently enables/disables a file's public share link and sets its access mode (public, password, email, SSO). The set_sharing route case reuses upsertFileShare, requires write/admin, gates enabling through the EE public-sharing policy, and records a share audit. Returns an empty url when set to private so a disabled link isn't handed back as a dead link.
1 parent 707c3cc commit 0d59177

8 files changed

Lines changed: 412 additions & 3 deletions

File tree

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Buffer, isUtf8 } from 'buffer'
22
import type { Readable } from 'stream'
3+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
34
import { createLogger } from '@sim/logger'
45
import { getErrorMessage } from '@sim/utils/errors'
56
import { generateShortId } from '@sim/utils/id'
@@ -14,6 +15,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1415
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
18+
import { ShareValidationError, upsertFileShare } from '@/lib/public-shares/share-manager'
1719
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
1820
import {
1921
fetchWorkspaceFileBuffer,
@@ -27,9 +29,14 @@ import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
2729
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
2830
import {
2931
assertActiveWorkspaceAccess,
32+
getUserEntityPermissions,
3033
isWorkspaceAccessDeniedError,
3134
} from '@/lib/workspaces/permissions/utils'
3235
import { assertToolFileAccess } from '@/app/api/files/authorization'
36+
import {
37+
PublicFileSharingNotAllowedError,
38+
validatePublicFileSharing,
39+
} from '@/ee/access-control/utils/permission-check'
3340
import type { UserFile } from '@/executor/types'
3441

3542
export const dynamic = 'force-dynamic'
@@ -565,6 +572,68 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
565572
})
566573
}
567574

575+
case 'set_sharing': {
576+
const { fileId, isActive, authType, password, allowedEmails } = body
577+
578+
const file = await getWorkspaceFile(workspaceId, fileId)
579+
if (!file) {
580+
return NextResponse.json(
581+
{ success: false, error: `File not found: "${fileId}"` },
582+
{ status: 404 }
583+
)
584+
}
585+
586+
// Publishing a file is more sensitive than the other mutating ops, so it
587+
// requires write/admin (not just workspace access) to match the share route.
588+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
589+
if (permission !== 'admin' && permission !== 'write') {
590+
return NextResponse.json(
591+
{ success: false, error: 'Insufficient permissions' },
592+
{ status: 403 }
593+
)
594+
}
595+
596+
// Enabling a share is gated by the org's access-control policy; disabling
597+
// is always allowed so users can un-share after the policy is turned on.
598+
if (isActive) {
599+
try {
600+
await validatePublicFileSharing(userId, workspaceId, authType ?? 'public')
601+
} catch (error) {
602+
if (error instanceof PublicFileSharingNotAllowedError) {
603+
return NextResponse.json({ success: false, error: error.message }, { status: 403 })
604+
}
605+
throw error
606+
}
607+
}
608+
609+
const share = await upsertFileShare({
610+
workspaceId,
611+
fileId,
612+
userId,
613+
isActive,
614+
authType,
615+
password,
616+
allowedEmails,
617+
})
618+
619+
recordAudit({
620+
workspaceId,
621+
actorId: userId,
622+
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
623+
resourceType: AuditResourceType.FILE,
624+
resourceId: fileId,
625+
resourceName: file.name,
626+
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
627+
request,
628+
})
629+
630+
logger.info('File sharing updated', { fileId, isActive, authType: share.authType })
631+
632+
// A disabled link doesn't resolve, so don't hand back a dead URL.
633+
const responseShare = share.isActive ? share : { ...share, url: '' }
634+
return NextResponse.json({ success: true, data: { share: responseShare } })
635+
}
636+
568637
case 'append': {
569638
const { fileName, content } = body
570639

@@ -911,6 +980,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
911980
{ status: 403 }
912981
)
913982
}
983+
if (error instanceof ShareValidationError) {
984+
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
985+
}
914986
const message = getErrorMessage(error, 'Unknown error')
915987
logger.error('File operation failed', { operation: body.operation, error: message })
916988
return NextResponse.json({ success: false, error: message }, { status: 500 })

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ describe.concurrent('Blocks Module', () => {
174174
'file_append',
175175
'file_compress',
176176
'file_decompress',
177+
'file_set_sharing',
177178
])
178179
expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress')
179180
expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress')

apps/sim/blocks/blocks/file.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,92 @@ describe('FileV5Block', () => {
117117
'File is required for get content'
118118
)
119119
})
120+
121+
it('maps set sharing to public access for a canonical file ID', () => {
122+
expect(
123+
buildParams({
124+
operation: 'file_set_sharing',
125+
shareInput: 'file-1',
126+
shareVisibility: 'public',
127+
_context: { workspaceId: 'workspace-1' },
128+
})
129+
).toEqual({
130+
fileId: 'file-1',
131+
isActive: true,
132+
authType: 'public',
133+
password: undefined,
134+
allowedEmails: undefined,
135+
workspaceId: 'workspace-1',
136+
})
137+
})
138+
139+
it('maps private visibility to a disabled share with no authType', () => {
140+
expect(
141+
buildParams({
142+
operation: 'file_set_sharing',
143+
shareInput: 'file-1',
144+
shareVisibility: 'private',
145+
_context: { workspaceId: 'workspace-1' },
146+
})
147+
).toMatchObject({
148+
fileId: 'file-1',
149+
isActive: false,
150+
authType: undefined,
151+
})
152+
})
153+
154+
it('passes the password through for password visibility', () => {
155+
expect(
156+
buildParams({
157+
operation: 'file_set_sharing',
158+
shareInput: 'file-1',
159+
shareVisibility: 'password',
160+
sharePassword: 'hunter2',
161+
_context: { workspaceId: 'workspace-1' },
162+
})
163+
).toMatchObject({
164+
fileId: 'file-1',
165+
isActive: true,
166+
authType: 'password',
167+
password: 'hunter2',
168+
})
169+
})
170+
171+
it('splits allowed emails for email visibility', () => {
172+
expect(
173+
buildParams({
174+
operation: 'file_set_sharing',
175+
shareInput: 'file-1',
176+
shareVisibility: 'email',
177+
shareAllowedEmails: 'a@example.com, b@example.com\n@acme.com',
178+
_context: { workspaceId: 'workspace-1' },
179+
})
180+
).toMatchObject({
181+
fileId: 'file-1',
182+
isActive: true,
183+
authType: 'email',
184+
allowedEmails: ['a@example.com', 'b@example.com', '@acme.com'],
185+
})
186+
})
187+
188+
it('resolves the file ID from a selected workspace file object for set sharing', () => {
189+
expect(
190+
buildParams({
191+
operation: 'file_set_sharing',
192+
shareInput: [{ id: 'file-9', name: 'report.pdf' }],
193+
shareVisibility: 'public',
194+
_context: { workspaceId: 'workspace-1' },
195+
})
196+
).toMatchObject({
197+
fileId: 'file-9',
198+
isActive: true,
199+
authType: 'public',
200+
})
201+
})
202+
203+
it('throws when no file is provided for set sharing', () => {
204+
expect(() => buildParams({ operation: 'file_set_sharing' })).toThrow(
205+
'File is required for set sharing'
206+
)
207+
})
120208
})

0 commit comments

Comments
 (0)