diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 87c851d67d3..4fef2956e95 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, @@ -22,14 +28,20 @@ import { updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' 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 +577,102 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + case 'manage_sharing': { + const { fileId, fileInput, 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 } + ) + } + + // Resolve the canonical file id. The basic file picker provides an object + // with a storage `key` but no id, so map the key to the workspace file row. + let resolvedFileId = typeof fileId === 'string' ? fileId : undefined + if (!resolvedFileId && fileInput) { + const single = Array.isArray(fileInput) ? fileInput[0] : fileInput + if (single && typeof single === 'object') { + const record = single as Record + if (typeof record.id === 'string' && record.id) resolvedFileId = record.id + else if (typeof record.fileId === 'string' && record.fileId) + resolvedFileId = record.fileId + else if (typeof record.key === 'string' && record.key) { + const meta = await getFileMetadataByKey(record.key, 'workspace') + resolvedFileId = meta?.id + } + } + } + if (!resolvedFileId) { + return NextResponse.json( + { success: false, error: 'A valid file is required to manage sharing' }, + { status: 400 } + ) + } + + const file = await getWorkspaceFile(workspaceId, resolvedFileId) + if (!file) { + return NextResponse.json( + { success: false, error: `File not found: "${resolvedFileId}"` }, + { 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', resolvedFileId) + 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: resolvedFileId, + userId, + isActive, + authType, + password, + allowedEmails, + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: resolvedFileId, + resourceName: file.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + request, + }) + + logger.info('File sharing updated', { + fileId: resolvedFileId, + 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 +1019,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 8d13636dd81..b6b1338b9ef 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 30e0e7d977e..807ebc1f4f2 100644 --- a/apps/sim/blocks/blocks/file.test.ts +++ b/apps/sim/blocks/blocks/file.test.ts @@ -117,4 +117,125 @@ 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('passes a picker file object without an id through as fileInput for manage sharing', () => { + const picked = { + name: 'report.pdf', + key: 'workspace/workspace-1/123-abc-report.pdf', + path: '/api/files/serve/workspace%2Fworkspace-1%2F123-abc-report.pdf?context=workspace', + size: 10, + type: 'application/pdf', + } + expect( + buildParams({ + operation: 'file_manage_sharing', + shareInput: [picked], + shareVisibility: 'public', + _context: { workspaceId: 'workspace-1' }, + }) + ).toMatchObject({ + fileInput: picked, + 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 541192bfe2f..f43294ad6e0 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,54 @@ export const FileV5Block: BlockConfig = { } } + if (operation === 'file_manage_sharing') { + const shareInput = params.shareInput + if (!shareInput) { + throw new Error('File is required to manage sharing') + } + + 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' + const shareParams = { + 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, + } + + // Canonical IDs (advanced mode or upstream references) resolve directly. + 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') + } + return { fileId: Array.isArray(fileIds) ? fileIds[0] : fileIds, ...shareParams } + } + + // The basic picker yields a file object; it carries an id only sometimes, + // so prefer the id when present and otherwise pass the object for the + // route to resolve via its storage key. + const normalized = normalizeFileInput(shareInput, { single: true }) + const file = normalized as Record | null + if (!file) { + throw new Error('Could not determine the file to share') + } + if (typeof file.id === 'string' && file.id) { + return { fileId: file.id, ...shareParams } + } + return { fileInput: normalized, ...shareParams } + } + if (operation === 'file_fetch') { const fileUrl = resolveHttpFileUrl(params.fileUrl) @@ -1224,6 +1343,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 +1385,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 0b7a4396158..994ed444c88 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,23 @@ 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).optional(), + fileInput: z.unknown().optional(), + 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(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for manage_sharing operation', + }) + +export type FileManageSharingBody = z.input + export const fileManageReadBodySchema = z .object({ operation: z.literal('read'), @@ -92,6 +110,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 853b0c86695..feda5c045db 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 00000000000..b7197513997 --- /dev/null +++ b/apps/sim/tools/file/manage-sharing.ts @@ -0,0 +1,97 @@ +import type { ShareAuthType } from '@/lib/api/contracts/public-shares' +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileManageSharingParams { + fileId?: string + fileInput?: unknown + 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: false, + visibility: 'user-or-llm', + description: 'Canonical ID of the workspace file to update sharing for.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object (from the file picker).', + }, + 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, + fileInput: params.fileInput, + 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 3c40cdf7b5c..df8a070ec5c 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,