mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(file): add Manage Sharing operation to the File block (#5177)
* 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.
* fix(file): harden set_sharing — explicit isActive, agent-controllable params, policy gate + perm-check ordering
Addresses review findings:
- Make isActive explicit/required so a bare call no longer silently enables a public link
- Expose isActive/authType/allowedEmails as user-or-llm so agents can disable/configure shares (password stays user-only)
- Resolve authType from the existing share before the EE policy gate to close a re-enable bypass
- Run the write/admin permission check before the file lookup to remove a file-existence side channel
* refactor(file): rename file operation to Manage Sharing
Renames the file_set_sharing operation to file_manage_sharing (route literal
manage_sharing, tool Manage Sharing) across the contract, route, tool, block,
registry, and tests.
* fix(file): complete Manage Sharing rename in tools barrel
Prior commit's lint-staged dropped the barrel re-export update, leaving
index.ts importing the deleted set-sharing module and breaking the block
registry check. Point the barrel at manage-sharing.
* fix(file): require isActive in tool params type, reject multiple files in manage sharing
- FileManageSharingParams.isActive is now required, matching the tool param
and contract (no compile-time gap that 400s at runtime)
- manage_sharing rejects multiple canonical file IDs instead of silently
sharing only the first, matching decompress
* fix(file): resolve basic-picker files for manage sharing
The basic file-upload picker stores a workspace file as { name, path, key,
size, type } with no canonical id, so manage_sharing failed those picks with
'Could not determine the file to share'. The block now passes the picked
object as fileInput when it lacks an id, and the route resolves the canonical
id from the storage key via getFileMetadataByKey. Contract accepts fileId OR
fileInput (mirroring read/get content).
This commit is contained in:
@@ -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<string, unknown>
|
||||
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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -822,9 +822,10 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
|
||||
...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<FileParserV3Output> = {
|
||||
{ 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<FileParserV3Output> = {
|
||||
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<FileParserV3Output> = {
|
||||
'file_append',
|
||||
'file_compress',
|
||||
'file_decompress',
|
||||
'file_manage_sharing',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => params.operation || 'file_read',
|
||||
@@ -1131,6 +1202,54 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<FileParserV3Output> = {
|
||||
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<FileParserV3Output> = {
|
||||
},
|
||||
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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<typeof fileManageMoveBodySchema>
|
||||
|
||||
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<typeof fileManageSharingBodySchema>
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<FileManageSharingParams, ToolResponse> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -864,6 +864,7 @@ import {
|
||||
fileFetchTool,
|
||||
fileGetContentTool,
|
||||
fileGetTool,
|
||||
fileManageSharingTool,
|
||||
fileParserV2Tool,
|
||||
fileParserV3Tool,
|
||||
fileParseTool,
|
||||
@@ -4219,6 +4220,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
file_get: fileGetTool,
|
||||
file_get_content: fileGetContentTool,
|
||||
file_read: fileReadTool,
|
||||
file_manage_sharing: fileManageSharingTool,
|
||||
file_write: fileWriteTool,
|
||||
firecrawl_scrape: firecrawlScrapeTool,
|
||||
firecrawl_search: firecrawlSearchTool,
|
||||
|
||||
Reference in New Issue
Block a user