diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 87c851d67d..d732f74f9b 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -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' @@ -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, @@ -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' @@ -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 + } + } + + 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 @@ -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 }) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 8d13636dd8..b6b1338b9e 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -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') diff --git a/apps/sim/blocks/blocks/file.test.ts b/apps/sim/blocks/blocks/file.test.ts index 30e0e7d977..9039bcd1f1 100644 --- a/apps/sim/blocks/blocks/file.test.ts +++ b/apps/sim/blocks/blocks/file.test.ts @@ -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') + }) }) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 541192bfe2..3f33b195be 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -822,9 +822,10 @@ export const FileV5Block: BlockConfig = { ...FileV4Block, type: 'file_v5', name: 'File', - description: 'Read, get content, fetch, write, append, compress, and decompress files', + description: + 'Read, get content, fetch, write, append, compress, decompress, and manage sharing for files', longDescription: - 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, or extract a .zip archive into the workspace.', + 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.', hideFromToolbar: false, bestPractices: ` - Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments). @@ -849,6 +850,7 @@ export const FileV5Block: BlockConfig = { { label: 'Append', id: 'file_append' }, { label: 'Compress', id: 'file_compress' }, { label: 'Decompress', id: 'file_decompress' }, + { label: 'Manage Sharing', id: 'file_manage_sharing' }, ], value: () => 'file_read', }, @@ -1016,6 +1018,74 @@ export const FileV5Block: BlockConfig = { condition: { field: 'operation', value: 'file_decompress' }, required: { field: 'operation', value: 'file_decompress' }, }, + { + id: 'shareFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'shareInput', + acceptedTypes: '*', + placeholder: 'Select a workspace file', + mode: 'basic', + condition: { field: 'operation', value: 'file_manage_sharing' }, + required: { field: 'operation', value: 'file_manage_sharing' }, + }, + { + id: 'shareFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'shareInput', + placeholder: 'Workspace file ID', + mode: 'advanced', + condition: { field: 'operation', value: 'file_manage_sharing' }, + required: { field: 'operation', value: 'file_manage_sharing' }, + }, + { + id: 'shareVisibility', + title: 'Visibility', + type: 'dropdown' as SubBlockType, + options: [ + { label: 'Private (disable link)', id: 'private' }, + { label: 'Anyone with the link', id: 'public' }, + { label: 'Password protected', id: 'password' }, + { label: 'Email allowlist', id: 'email' }, + { label: 'SSO', id: 'sso' }, + ], + value: () => 'public', + condition: { field: 'operation', value: 'file_manage_sharing' }, + }, + { + id: 'sharePassword', + title: 'Password', + type: 'short-input' as SubBlockType, + password: true, + placeholder: 'Password for the public link', + condition: { + field: 'operation', + value: 'file_manage_sharing', + and: { field: 'shareVisibility', value: 'password' }, + }, + required: { + field: 'operation', + value: 'file_manage_sharing', + and: { field: 'shareVisibility', value: 'password' }, + }, + }, + { + id: 'shareAllowedEmails', + title: 'Allowed Emails', + type: 'long-input' as SubBlockType, + placeholder: 'Comma- or newline-separated emails or @domain patterns', + condition: { + field: 'operation', + value: 'file_manage_sharing', + and: { field: 'shareVisibility', value: ['email', 'sso'] }, + }, + required: { + field: 'operation', + value: 'file_manage_sharing', + and: { field: 'shareVisibility', value: ['email', 'sso'] }, + }, + }, ], tools: { access: [ @@ -1026,6 +1096,7 @@ export const FileV5Block: BlockConfig = { 'file_append', 'file_compress', 'file_decompress', + 'file_manage_sharing', ], config: { tool: (params) => params.operation || 'file_read', @@ -1131,6 +1202,50 @@ export const FileV5Block: BlockConfig = { } } + if (operation === 'file_manage_sharing') { + const shareInput = params.shareInput + if (!shareInput) { + throw new Error('File is required to manage sharing') + } + + let fileId: string + const fileIds = parseReadFileIds(shareInput) + if (fileIds) { + if (Array.isArray(fileIds) && fileIds.length > 1) { + throw new Error('Manage Sharing accepts a single file at a time') + } + fileId = Array.isArray(fileIds) ? fileIds[0] : fileIds + } else { + const normalized = normalizeFileInput(shareInput, { single: true }) + const file = normalized as Record | null + fileId = (file?.id as string) ?? '' + } + if (!fileId) { + throw new Error('Could not determine the file to share') + } + + const allowedEmails = + typeof params.shareAllowedEmails === 'string' + ? params.shareAllowedEmails + .split(/[\n,]/) + .map((email) => email.trim()) + .filter(Boolean) + : undefined + + const visibility = (params.shareVisibility as string) || 'public' + const isActive = visibility !== 'private' + + return { + fileId, + isActive, + // When disabling, leave authType unset so the stored access mode is preserved. + authType: isActive ? visibility : undefined, + password: params.sharePassword, + allowedEmails, + workspaceId: params._context?.workspaceId, + } + } + if (operation === 'file_fetch') { const fileUrl = resolveHttpFileUrl(params.fileUrl) @@ -1224,6 +1339,19 @@ export const FileV5Block: BlockConfig = { type: 'json', description: 'Selected .zip archive or canonical file ID to extract', }, + shareInput: { + type: 'json', + description: 'Selected workspace file or canonical file ID to manage sharing for', + }, + shareVisibility: { + type: 'string', + description: 'Link visibility: private, public, password, email, or sso', + }, + sharePassword: { type: 'string', description: 'Password for a password-protected link' }, + shareAllowedEmails: { + type: 'string', + description: 'Allowed emails or @domain patterns for email/SSO access', + }, }, outputs: { files: { @@ -1253,7 +1381,24 @@ export const FileV5Block: BlockConfig = { }, url: { type: 'string', - description: 'URL to access the file (write and append)', + description: + 'URL to access the file (write and append), or the public share link when shared; empty when set to private (manage sharing)', + }, + isActive: { + type: 'boolean', + description: 'Whether the public link is enabled (manage sharing)', + }, + authType: { + type: 'string', + description: 'Public link access mode: public, password, email, or sso (manage sharing)', + }, + hasPassword: { + type: 'boolean', + description: 'Whether the public link is password-protected (manage sharing)', + }, + allowedEmails: { + type: 'array', + description: 'Allowed emails/domains for email or SSO access (manage sharing)', }, }, } diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 0b7a439615..c4aa17d7e5 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares' import { toolJsonResponseSchema } from '@/lib/api/contracts/tools/media/shared' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -42,6 +43,18 @@ export const fileManageMoveBodySchema = z.object({ export type FileManageMoveBody = z.input +export const fileManageSharingBodySchema = z.object({ + operation: z.literal('manage_sharing'), + workspaceId: z.string().min(1).optional(), + fileId: z.string().min(1, 'fileId is required for manage_sharing operation'), + isActive: z.boolean({ error: 'isActive is required for manage_sharing operation' }), + authType: shareAuthTypeSchema.optional(), + password: z.string().min(1).max(1024).optional(), + allowedEmails: z.array(z.string().min(1)).max(200).optional(), +}) + +export type FileManageSharingBody = z.input + export const fileManageReadBodySchema = z .object({ operation: z.literal('read'), @@ -92,6 +105,7 @@ export const fileManageBodySchema = z.union([ fileManageAppendBodySchema, fileManageGetBodySchema, fileManageMoveBodySchema, + fileManageSharingBodySchema, fileManageReadBodySchema, fileManageContentBodySchema, fileManageCompressBodySchema, diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 853b0c8669..feda5c045d 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -8,6 +8,7 @@ import { export { fileAppendTool } from '@/tools/file/append' export { fileCompressTool, fileDecompressTool } from '@/tools/file/compress' export { fileGetContentTool, fileGetTool, fileReadTool } from '@/tools/file/get' +export { fileManageSharingTool } from '@/tools/file/manage-sharing' export { fileWriteTool } from '@/tools/file/write' export const fileParseTool = fileParserTool diff --git a/apps/sim/tools/file/manage-sharing.ts b/apps/sim/tools/file/manage-sharing.ts new file mode 100644 index 0000000000..bfe3f879d9 --- /dev/null +++ b/apps/sim/tools/file/manage-sharing.ts @@ -0,0 +1,89 @@ +import type { ShareAuthType } from '@/lib/api/contracts/public-shares' +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileManageSharingParams { + fileId: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileManageSharingTool: ToolConfig = { + id: 'file_manage_sharing', + name: 'Manage Sharing', + description: + 'Enable or disable the public share link for a workspace file, and set its access mode (public, password, email, or SSO). Idempotent: the public link stays stable across changes.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the workspace file to update sharing for.', + }, + isActive: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'Whether the public link is enabled. Set to false to make the file private.', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Access mode for the link: "public", "password", "email", or "sso". Defaults to "public".', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password to protect the link. Required when authType is "password".', + }, + allowedEmails: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Allowed emails or "@domain" patterns. Required when authType is "email" or "sso".', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'manage_sharing', + fileId: params.fileId, + isActive: params.isActive, + authType: params.authType, + password: params.password, + allowedEmails: params.allowedEmails, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to update file sharing' } + } + return { success: true, output: data.data.share } + }, + + outputs: { + url: { type: 'string', description: 'Public share URL for the file' }, + isActive: { type: 'boolean', description: 'Whether the public link is enabled' }, + authType: { type: 'string', description: 'Access mode: public, password, email, or sso' }, + hasPassword: { type: 'boolean', description: 'Whether the share is password-protected' }, + allowedEmails: { + type: 'array', + description: 'Allowed emails/domains for email or SSO access', + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3c40cdf7b5..df8a070ec5 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -864,6 +864,7 @@ import { fileFetchTool, fileGetContentTool, fileGetTool, + fileManageSharingTool, fileParserV2Tool, fileParserV3Tool, fileParseTool, @@ -4219,6 +4220,7 @@ export const tools: Record = { file_get: fileGetTool, file_get_content: fileGetContentTool, file_read: fileReadTool, + file_manage_sharing: fileManageSharingTool, file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool,