From 8a1b0ce138e7882c3e157f9fc4924fb896bd90f5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 18:01:46 -0700 Subject: [PATCH 1/6] feat(files): add folder uploads, content search, and sharing --- .github/scripts/http-e2e.sh | 18 +- .github/workflows/checks.yml | 10 +- apps/docs/content/docs/integrations/file.mdx | 26 +- .../api/files/public/[token]/content/route.ts | 29 +- .../api/files/public/[token]/folder/route.ts | 46 ++ .../api/files/public/[token]/inline/route.ts | 30 +- .../app/api/files/public/[token]/otp/route.ts | 6 +- .../sim/app/api/files/public/[token]/route.ts | 52 +- .../app/api/files/public/[token]/sso/route.ts | 4 +- .../files/folders/[folderId]/share/route.ts | 42 ++ .../files/folders/prepare-upload/route.ts | 25 + .../api/workspaces/[id]/files/search/route.ts | 41 ++ apps/sim/app/f/[token]/loading.tsx | 22 + apps/sim/app/f/[token]/opengraph-image.tsx | 12 +- apps/sim/app/f/[token]/page.tsx | 42 +- apps/sim/app/f/[token]/public-file-auth.tsx | 8 +- .../app/f/[token]/public-file-email-auth.tsx | 5 +- .../app/f/[token]/public-file-sso-auth.tsx | 11 +- apps/sim/app/f/[token]/public-file-view.tsx | 89 ++- apps/sim/app/f/[token]/public-folder-view.tsx | 228 ++++++ apps/sim/app/f/[token]/search-params.ts | 14 + .../components/folders/folder-breadcrumbs.ts | 21 +- .../resource-header/resource-header.tsx | 191 +++--- .../file-content-search.tsx | 170 +++++ .../files-list-context-menu.tsx | 10 +- .../components/share-modal/share-modal.tsx | 48 +- .../files/components/upload-menu.tsx | 63 ++ .../workspace/[workspaceId]/files/files.tsx | 448 ++++++------ .../files/hooks/use-file-uploads.ts | 193 ++++++ .../workspace/[workspaceId]/files/page.tsx | 24 +- .../workspace/[workspaceId]/files/prefetch.ts | 5 +- .../[workspaceId]/files/search-params.ts | 7 +- .../files/utils/upload-selection.test.ts | 35 + .../files/utils/upload-selection.ts | 123 ++++ .../resource-content/resource-content.tsx | 3 +- apps/sim/blocks/blocks/file.ts | 61 +- apps/sim/hooks/queries/public-shares.ts | 130 +++- .../queries/workspace-file-folder-upload.ts | 19 + .../hooks/queries/workspace-file-search.ts | 61 ++ apps/sim/hooks/use-file-content-source.tsx | 9 +- apps/sim/lib/api/contracts/public-shares.ts | 55 +- apps/sim/lib/api/contracts/tools/file.ts | 11 + .../contracts/workspace-file-folder-upload.ts | 30 + .../api/contracts/workspace-file-search.ts | 38 + apps/sim/lib/internal/file/execute-tool.ts | 1 + apps/sim/lib/internal/file/operations.ts | 14 + .../tool-operations/registry.server.ts | 1 + apps/sim/lib/public-shares/access.ts | 49 ++ apps/sim/lib/public-shares/folder-reader.ts | 190 +++++ apps/sim/lib/public-shares/folder-scope.ts | 39 ++ .../lib/public-shares/folders.integration.ts | 166 +++++ apps/sim/lib/public-shares/share-manager.ts | 120 +++- .../application/operations.test.ts | 2 + .../workspace-files/application/operations.ts | 23 + .../application/prepare-upload-folders.ts | 44 ++ .../search-workspace-file-content.ts | 3 + .../application/share-password.ts | 29 + .../share-workspace-file-folder.ts | 163 +++++ .../application/share-workspace-file.ts | 35 +- .../lib/workspace-files/search/candidates.ts | 4 + .../search/chunks.integration.ts | 16 + .../lib/workspace-files/search/repository.ts | 19 + .../workspace-files/upload-directory-plan.ts | 54 ++ .../upload-folders.integration.ts | 108 +++ .../sim/lib/workspace-files/upload-folders.ts | 116 ++++ apps/sim/package.json | 1 + apps/sim/scripts/files-e2e/index.ts | 4 + .../sim/scripts/files-e2e/navigation-scale.ts | 90 +++ .../sim/scripts/files-e2e/search-workflows.ts | 356 ++++++++++ apps/sim/scripts/files-e2e/sharing.ts | 510 ++++++++++++++ apps/sim/scripts/files-e2e/types.ts | 29 + apps/sim/scripts/test-files-e2e.ts | 647 ++++++++++++++++++ apps/sim/tools/file/index.ts | 1 + apps/sim/tools/file/manage-folder-sharing.ts | 80 +++ apps/sim/tools/generated/tool-ids.ts | 2 +- apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/generated/tool-outputs.ts | 2 +- apps/sim/tools/registry.ts | 2 + bun.lock | 17 +- packages/audit/src/types.ts | 2 + .../testing/src/mocks/public-shares.mock.ts | 6 + scripts/check-unused-exports.baseline.json | 3 - 82 files changed, 4874 insertions(+), 591 deletions(-) create mode 100644 apps/sim/app/api/files/public/[token]/folder/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/search/route.ts create mode 100644 apps/sim/app/f/[token]/loading.tsx create mode 100644 apps/sim/app/f/[token]/public-folder-view.tsx create mode 100644 apps/sim/app/f/[token]/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-content-search/file-content-search.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/upload-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/hooks/use-file-uploads.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.ts create mode 100644 apps/sim/hooks/queries/workspace-file-folder-upload.ts create mode 100644 apps/sim/hooks/queries/workspace-file-search.ts create mode 100644 apps/sim/lib/api/contracts/workspace-file-folder-upload.ts create mode 100644 apps/sim/lib/api/contracts/workspace-file-search.ts create mode 100644 apps/sim/lib/public-shares/access.ts create mode 100644 apps/sim/lib/public-shares/folder-reader.ts create mode 100644 apps/sim/lib/public-shares/folder-scope.ts create mode 100644 apps/sim/lib/public-shares/folders.integration.ts create mode 100644 apps/sim/lib/workspace-files/application/prepare-upload-folders.ts create mode 100644 apps/sim/lib/workspace-files/application/share-password.ts create mode 100644 apps/sim/lib/workspace-files/application/share-workspace-file-folder.ts create mode 100644 apps/sim/lib/workspace-files/upload-directory-plan.ts create mode 100644 apps/sim/lib/workspace-files/upload-folders.integration.ts create mode 100644 apps/sim/lib/workspace-files/upload-folders.ts create mode 100644 apps/sim/scripts/files-e2e/index.ts create mode 100644 apps/sim/scripts/files-e2e/navigation-scale.ts create mode 100644 apps/sim/scripts/files-e2e/search-workflows.ts create mode 100644 apps/sim/scripts/files-e2e/sharing.ts create mode 100644 apps/sim/scripts/files-e2e/types.ts create mode 100644 apps/sim/scripts/test-files-e2e.ts create mode 100644 apps/sim/tools/file/manage-folder-sharing.ts diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index 313122ee810..7ecf63239d8 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Runs one end-to-end suite group over real HTTP, each against its own `next dev` app. # -# Usage: http-e2e.sh (run from apps/sim) +# Usage: http-e2e.sh (run from apps/sim) # # The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of # its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e. @@ -17,7 +17,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=300 mkdir -p "$report_dir" @@ -75,6 +75,20 @@ start_app() { } case "$group" in + files) + export NEXT_PUBLIC_FORCE_HOSTED=false STORAGE_PROVIDER=local + export NEXT_PUBLIC_CHAT_DISABLED=true + export INTERNAL_API_SECRET=files-http-ci-local-secret-at-least-32-characters + export CRON_SECRET=files-e2e-cron-secret-at-least-32-characters + start_app files 3027 Files record-http-status + FILES_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + FILES_E2E_DATABASE_URL="$DATABASE_URL" \ + FILES_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + FILES_E2E_CRON_SECRET="$CRON_SECRET" \ + FILES_E2E_REPORT_PATH="$report_dir/files-e2e-report.json" \ + bun --no-env-file scripts/test-files-e2e.ts + ;; + scim) export NEXT_PUBLIC_FORCE_HOSTED=true export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7210aa37488..0107f5a2d14 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -138,8 +138,8 @@ jobs: # The suites exercise HTTP behavior rather than a provisioning path, so they run once, against # the production (migrate) path. Each group's app environment lives in http-e2e.sh. # - # SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU - # runner; the others boot a smaller self-hosted app and fit on 4. + # SCIM's hosted app and the Files browser suite use 8 vCPU runners; the smaller self-hosted + # suites fit on 4. e2e: name: e2e (${{ matrix.group }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }} @@ -156,6 +156,8 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - group: desktop-inbox runner: blacksmith-4vcpu-ubuntu-2404 + - group: files + runner: blacksmith-8vcpu-ubuntu-2404 services: postgres: image: pgvector/pgvector:pg17 @@ -199,6 +201,10 @@ jobs: working-directory: packages/db run: bun run db:migrate + - name: Install Files browser from the pinned Playwright dependency + if: matrix.group == 'files' + run: node node_modules/@playwright/test/cli.js install --with-deps chromium + # No step timeout: the job's bound covers a hang without cutting a slow but healthy suite # short of writing its report. - name: Run end-to-end suites diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index 0fc151aad4e..34fa44139af 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -28,7 +28,7 @@ In Sim, the File block allows your agents to search, read, and extract text from ## Usage Instructions -Read workspace file objects, search indexed text across the workspace or selected folder scopes, 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. +Read workspace file objects, search indexed text across the workspace or selected folder scopes, 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 or folder. @@ -278,6 +278,30 @@ Enable or disable the public share link for a workspace file, and set its access | `hasPassword` | boolean | Whether the share is password-protected | | `allowedEmails` | array | Allowed emails/domains for email or SSO access | +### Manage Folder Sharing + +Enable or disable the public share link for a workspace file folder and its current descendants. Set public, password, email, or SSO access. The link stays stable across changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `path` | string | Yes | Canonical percent-encoded folder path, e.g. "/Reports/Q3%20Results". The workspace root cannot be shared. | +| `isActive` | boolean | Yes | Whether the public folder link is enabled. Set false to disable access. | +| `authType` | string | No | Access mode: "public", "password", "email", or "sso". Defaults to "public". | +| `password` | string | No | Password to protect the link. Required when authType is "password". | +| `allowedEmails` | array | No | Allowed emails or "@domain" patterns for email or SSO access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `url` | string | Public folder URL; empty when the link is disabled | +| `isActive` | boolean | Whether the public folder link is enabled | +| `authType` | string | Access mode: public, password, email, or sso | +| `hasPassword` | boolean | Whether the link is password-protected | +| `allowedEmails` | array | Allowed emails/domains for email or SSO access | + ### List Files and Folders List what is inside a workspace folder: its subfolders and its files together. Lists direct children by default; set Recursive to walk the whole subtree. diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index d8fc088918e..68dab9493cb 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -9,8 +9,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveServableDoc } from '@/lib/mothership/tools/server/files/doc-compile' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -54,21 +54,11 @@ export const GET = withRouteHandler( const { token } = parsed.data.params const preview = parsed.data.query.preview === '1' - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } + const resolved = await readPublicSharedFile({ + token, + fileId: parsed.data.query.fileId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), + }) const { file } = resolved // The same ceiling the authenticated serve route reads this object under @@ -161,6 +151,13 @@ export const GET = withRouteHandler( cacheControl: 'private, no-cache, must-revalidate', }) } catch (error) { + if (error instanceof PublicShareAccessError) { + if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found')) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) + } logger.error('Error serving public shared file:', error) if (error instanceof FileNotFoundError) { return createErrorResponse(error) diff --git a/apps/sim/app/api/files/public/[token]/folder/route.ts b/apps/sim/app/api/files/public/[token]/folder/route.ts new file mode 100644 index 00000000000..2a4d459b4d3 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/folder/route.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getPublicFolderContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFolder } from '@/lib/public-shares/access' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' + +export const dynamic = 'force-dynamic' + +/** Public capabilities use the share authentication protocol rather than workspace membership. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const headers = { 'Cache-Control': 'private, no-store' } + const limited = await enforcePublicFileRateLimit(request, 'metadata') + if (limited) { + limited.headers.set('Cache-Control', headers['Cache-Control']) + return limited + } + const parsed = await parseRequest(getPublicFolderContract, request, context) + if (!parsed.success) { + parsed.response.headers.set('Cache-Control', headers['Cache-Control']) + return parsed.response + } + try { + const page = await readPublicSharedFolder({ + token: parsed.data.params.token, + ...parsed.data.query, + authorize: (share) => + validateDeploymentAuth(generateRequestId(), share, request, undefined, 'file'), + }) + return NextResponse.json(page, { headers }) + } catch (error) { + if (error instanceof PublicShareAccessError) { + return NextResponse.json({ error: error.message }, { status: error.status, headers }) + } + if (error instanceof OrchestrationError && error.code === 'validation') { + return NextResponse.json({ error: error.message }, { status: 400, headers }) + } + throw error + } + } +) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 77e52efe587..9b535395b13 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -8,8 +8,8 @@ import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' @@ -61,21 +61,11 @@ export const GET = withRouteHandler( const { token } = parsed.data.params const ref = parsed.data.query - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } + const resolved = await readPublicSharedFile({ + token, + fileId: parsed.data.query.documentId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), + }) const { file: doc } = resolved if (!doc.workspaceId) { @@ -129,8 +119,16 @@ export const GET = withRouteHandler( request, }) + response.headers.set('Cache-Control', 'private, no-store') return response } catch (error) { + if (error instanceof PublicShareAccessError) { + if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found')) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) + } if (error instanceof FileNotFoundError) { return createErrorResponse(error) } diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 57a707c189b..e39e4585bc0 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -26,7 +26,7 @@ import { afterResponse } from '@/lib/core/utils/after-response' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' export const dynamic = 'force-dynamic' @@ -116,7 +116,7 @@ export const POST = withRouteHandler( // all key off the same value (allow-list entries are stored lowercase). const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } @@ -155,7 +155,7 @@ export const PUT = withRouteHandler( const { otp } = parsed.data.body const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 5874d977b9b..02ba19a4bbd 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -11,8 +11,9 @@ import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' export const dynamic = 'force-dynamic' @@ -38,32 +39,33 @@ export const GET = withRouteHandler( if (!parsed.success) return parsed.response const { token } = parsed.data.params - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file, workspaceName, ownerName } = resolved - return NextResponse.json({ + const resolved = await readPublicSharedFile({ token, - name: file.originalName, - type: file.contentType, - size: getWorkspaceFileSize(file), - workspaceName, - ownerName, + fileId: parsed.data.query.fileId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), }) + + const { file, workspaceName, ownerName } = resolved + return NextResponse.json( + { + token, + name: file.originalName, + type: file.contentType, + size: getWorkspaceFileSize(file), + workspaceName, + ownerName, + ...(parsed.data.query.fileId + ? { version: file.updatedAt.getTime(), folderId: file.folderId } + : {}), + }, + { headers: { 'Cache-Control': 'private, no-store' } } + ) } catch (error) { + if (error instanceof PublicShareAccessError) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) logger.error('Error fetching public file metadata:', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to fetch file') }, @@ -89,7 +91,7 @@ export const POST = withRouteHandler( const { token } = parsed.data.params const { password } = parsed.data.body - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index bc94fdcd0b6..3019a8ccd0c 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -9,7 +9,7 @@ import { RateLimiter } from '@/lib/core/rate-limiter' import { isEmailAllowed } from '@/lib/core/security/deployment' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -66,7 +66,7 @@ export const POST = withRouteHandler( const { token } = parsed.data.params const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts new file mode 100644 index 00000000000..fe60cb7c9a3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts @@ -0,0 +1,42 @@ +import { + getFolderShareContract, + upsertFolderShareContract, +} from '@/lib/api/contracts/public-shares' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + getWorkspaceFileFolderShare, + updateWorkspaceFileFolderShare, +} from '@/lib/workspace-files/application/share-workspace-file-folder' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getFolderShareContract, + auth: internalSessionAuth, + operation: fileOperations.readFolderShare, + rateLimit: internalRateLimits.none({ + reason: 'Same authenticated settings read as file sharing', + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: getWorkspaceFileFolderShare, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) + +export const PUT = defineInternalJsonRoute({ + contract: upsertFolderShareContract, + auth: internalSessionAuth, + operation: fileOperations.updateFolderShare, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-folder-sharing' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), + useCase: updateWorkspaceFileFolderShare, + present: ({ share }) => ({ share }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts new file mode 100644 index 00000000000..58bad792680 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts @@ -0,0 +1,25 @@ +import { prepareUploadFoldersContract } from '@/lib/api/contracts/workspace-file-folder-upload' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { prepareUploadFoldersOperation } from '@/lib/workspace-files/application/prepare-upload-folders' + +export const POST = defineInternalJsonRoute({ + contract: prepareUploadFoldersContract, + auth: internalSessionAuth, + operation: fileOperations.prepareUploadFolders, + rateLimit: internalRateLimits.user({ + bucketName: 'file-folder-upload', + config: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + }), + parseOptions: { maxBodyBytes: 1024 * 1024 }, + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ ...body, workspaceId: params.id }), + useCase: prepareUploadFoldersOperation, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/search/route.ts b/apps/sim/app/api/workspaces/[id]/files/search/route.ts new file mode 100644 index 00000000000..3d296ee4900 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/search/route.ts @@ -0,0 +1,41 @@ +import { searchWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-file-search' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: searchWorkspaceFileContentContract, + auth: internalSessionAuth, + operation: fileOperations.searchContent, + rateLimit: internalRateLimits.user({ + bucketName: 'workspace-file-content-search', + config: { maxTokens: 60, refillRate: 60, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ params, query }, { request }) => ({ + workspaceId: params.id, + query: query.query, + mode: 'exact' as const, + maxResults: query.maxResults, + folderPaths: query.folderPath === undefined ? undefined : [query.folderPath], + includeFileMetadata: true, + signal: request.signal, + }), + useCase: searchWorkspaceFileContent, + present: ({ results, count, truncated, complete, indexStatus, files }) => ({ + results, + count, + truncated, + complete, + indexStatus, + files: files ?? [], + }), +}) diff --git a/apps/sim/app/f/[token]/loading.tsx b/apps/sim/app/f/[token]/loading.tsx new file mode 100644 index 00000000000..a7f748d5356 --- /dev/null +++ b/apps/sim/app/f/[token]/loading.tsx @@ -0,0 +1,22 @@ +import { SimWordmark } from '@sim/emcn' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' +import { getBrandConfig } from '@/ee/whitelabeling' + +export default function SharedResourceLoading() { + const brand = getBrandConfig() + return ( +
+ +
+ {!brand.logoUrl && } + Shared files +
+
+ Loading… +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx index 2d56c0e3b2f..b64f3c50957 100644 --- a/apps/sim/app/f/[token]/opengraph-image.tsx +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { buildProvenance } from '@/app/f/[token]/utils' export const dynamic = 'force-dynamic' @@ -14,19 +14,19 @@ export const size = COVER_OG_SIZE */ export default async function Image({ params }: { params: Promise<{ token: string }> }) { const { token } = await params - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved || resolved.share.authType !== 'public') { return createCoverOgImage({ - title: 'Protected file', - subtitle: 'Authentication is required to view this file', + title: resolved?.kind === 'folder' ? 'Protected folder' : 'Protected file', + subtitle: `Authentication is required to view this ${resolved?.kind ?? 'file'}`, }) } - const { file, workspaceName, ownerName } = resolved + const { workspaceName, ownerName } = resolved return createCoverOgImage({ - title: file.originalName, + title: resolved.kind === 'folder' ? resolved.folder.name : resolved.file.originalName, subtitle: buildProvenance(workspaceName, ownerName) || 'Shared via Sim', }) } diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 2c59e81d057..936ec67e267 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -1,4 +1,4 @@ -import { cache } from 'react' +import { cache, Suspense } from 'react' import type { Metadata } from 'next' import { cookies } from 'next/headers' import { notFound } from 'next/navigation' @@ -8,19 +8,21 @@ import { isEmailAllowed, validateAuthToken, } from '@/lib/core/security/deployment' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import SharedResourceLoading from '@/app/f/[token]/loading' import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' import { PublicFileSSOAuth } from '@/app/f/[token]/public-file-sso-auth' import { PublicFileView } from '@/app/f/[token]/public-file-view' +import { PublicFolderView } from '@/app/f/[token]/public-folder-view' import { buildProvenance } from '@/app/f/[token]/utils' import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' /** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ -const resolveShare = cache(resolveActiveShareByToken) +const resolveShare = cache(resolveActiveResourceShareByToken) /** Shared links must never be indexed by search engines. */ const NOINDEX = { index: false, follow: false } as const @@ -44,12 +46,13 @@ export async function generateMetadata({ params }: PublicFilePageProps): Promise let title: string let description: string if (resolved.share.authType !== 'public') { - title = 'Shared file' - description = 'Authentication is required to view this file.' + title = resolved.kind === 'folder' ? 'Shared folder' : 'Shared file' + description = `Authentication is required to view this ${resolved.kind}.` } else { - title = resolved.file.originalName + title = resolved.kind === 'file' ? resolved.file.originalName : resolved.folder.name description = - buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}` + buildProvenance(resolved.workspaceName, resolved.ownerName) || + `Shared ${resolved.kind} · ${title}` } const brand = getBrandConfig() @@ -75,7 +78,7 @@ interface GateShare { * or `null` when the visitor may view the file. `password`/`email` use the * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. */ -async function renderAuthGate(token: string, share: GateShare) { +async function renderAuthGate(token: string, share: GateShare, resourceType: 'file' | 'folder') { if (share.authType === 'public') return null if (share.authType === 'sso') { @@ -86,7 +89,7 @@ async function renderAuthGate(token: string, share: GateShare) { const authorized = Boolean( session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) ) - return authorized ? null : + return authorized ? null : } const cookieStore = await cookies() @@ -94,9 +97,9 @@ async function renderAuthGate(token: string, share: GateShare) { if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null return share.authType === 'email' ? ( - + ) : ( - + ) } @@ -108,11 +111,24 @@ export default async function PublicFilePage({ params }: PublicFilePageProps) { notFound() } - const { share, file, workspaceName, ownerName } = resolved + const { share, workspaceName, ownerName } = resolved - const gate = await renderAuthGate(token, share) + const gate = await renderAuthGate(token, share, resolved.kind) if (gate) return gate + if (resolved.kind === 'folder') { + return ( + }> + + + ) + } + const { file } = resolved return ( +
{ e.preventDefault() diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx index 8839f44f4a7..483548b9a9a 100644 --- a/apps/sim/app/f/[token]/public-file-email-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -12,6 +12,7 @@ import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries interface PublicFileEmailAuthProps { token: string + resourceType?: 'file' | 'folder' } /** @@ -19,7 +20,7 @@ interface PublicFileEmailAuthProps { * send a 6-digit code, verify it. On success the server sets the * `file_auth_{shareId}` cookie and the page re-renders the viewer. */ -export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { +export function PublicFileEmailAuth({ token, resourceType = 'file' }: PublicFileEmailAuthProps) { const router = useRouter() const requestOtp = usePublicFileOtpRequest(token) const verifyOtp = usePublicFileOtpVerify(token) @@ -78,7 +79,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { return ( { diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx index 3e73581f715..c708b42fd01 100644 --- a/apps/sim/app/f/[token]/public-file-sso-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx @@ -13,6 +13,7 @@ import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' interface PublicFileSSOAuthProps { token: string + resourceType?: 'file' | 'folder' } /** @@ -20,7 +21,7 @@ interface PublicFileSSOAuthProps { * then hand off to the global `/sso` flow with this share as the callback. After * sign-in the page gate authorizes via the Sim session. */ -export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { +export function PublicFileSSOAuth({ token, resourceType = 'file' }: PublicFileSSOAuthProps) { const router = useRouter() const [email, setEmail] = useState('') const [error, setError] = useState(null) @@ -40,16 +41,16 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { body: { email: normalizedEmail }, }) if (!eligible) { - setError('Email not authorized for this file.') + setError(`Email not authorized for this ${resourceType}.`) setIsLoading(false) return } - const callbackUrl = `/f/${token}` + const callbackUrl = `/f/${token}${window.location.search}` router.push( `/sso?email=${encodeURIComponent(normalizedEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}` ) } catch (err) { - setError(getErrorMessage(err, 'Email not authorized for this file.')) + setError(getErrorMessage(err, `Email not authorized for this ${resourceType}.`)) setIsLoading(false) } } @@ -57,7 +58,7 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { return ( { diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 4b57b43f56d..2bdc17b407d 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -36,35 +36,6 @@ export function PublicFileView({ const brand = useBrandConfig() const provenance = buildProvenance(workspaceName, ownerName) - // The public viewer reuses the in-app FileViewer; the content source seam swaps - // the auth-gated workspace serve URL for the token-scoped public endpoint, and a - // synthetic record carries the metadata the renderers/query keys need. `key` and - // `updatedAt` fold in the content version so the React Query caches (keyed on the - // storage key + `updatedAt`) refetch when the shared file changes — even when its - // size is unchanged. - // Embedded images route through the token-scoped cascade endpoint, which serves them only when the - // shared document actually references them and they live in its workspace. - const source = useMemo( - () => createPublicFileContentSource(token, contentUrl), - [token, contentUrl] - ) - const file = useMemo( - () => ({ - id: token, - workspaceId: token, - name, - key: `${token}@${version}`, - path: contentUrl, - size, - type, - uploadedBy: '', - folderId: null, - uploadedAt: new Date(version), - updatedAt: new Date(version), - }), - [token, name, type, size, version, contentUrl] - ) - return (
@@ -108,15 +79,59 @@ export function PublicFileView({
- +
) } + +interface PublicFilePreviewProps { + token: string + fileId?: string + name: string + type: string + size: number + version: number +} + +/** The same read-only renderer serves direct file shares and children of a folder capability. */ +export function PublicFilePreview({ + token, + fileId, + name, + type, + size, + version, +}: PublicFilePreviewProps) { + const contentUrl = `/api/files/public/${token}/content${fileId ? `?fileId=${encodeURIComponent(fileId)}` : ''}` + const source = useMemo( + () => createPublicFileContentSource(token, contentUrl, fileId), + [token, contentUrl, fileId] + ) + const file = useMemo( + () => ({ + id: fileId ?? token, + workspaceId: token, + name, + key: `${token}:${fileId ?? ''}@${version}`, + path: contentUrl, + size, + type, + uploadedBy: '', + folderId: null, + uploadedAt: new Date(version), + updatedAt: new Date(version), + }), + [fileId, token, name, type, size, version, contentUrl] + ) + return ( + + ) +} diff --git a/apps/sim/app/f/[token]/public-folder-view.tsx b/apps/sim/app/f/[token]/public-folder-view.tsx new file mode 100644 index 00000000000..b9101249973 --- /dev/null +++ b/apps/sim/app/f/[token]/public-folder-view.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useRef } from 'react' +import { + Chip, + cn, + OverflowText, + SimWordmark, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { ChevronRight, Download, File, Folder } from '@sim/emcn/icons' +import Link from 'next/link' +import { useQueryStates } from 'nuqs' +import { SITE_URL } from '@/lib/core/utils/urls' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' +import { PublicFilePreview } from '@/app/f/[token]/public-file-view' +import { publicFolderParsers, publicFolderUrlKeys } from '@/app/f/[token]/search-params' +import { buildProvenance } from '@/app/f/[token]/utils' +import { useBrandConfig } from '@/ee/whitelabeling' +import { usePublicFolder, usePublicSharedFile } from '@/hooks/queries/public-shares' + +interface PublicFolderViewProps { + token: string + name: string + workspaceName: string | null + ownerName: string | null +} + +/** A public capability browses the live folder tree, with all file reads bound to its token. */ +export function PublicFolderView({ token, name, workspaceName, ownerName }: PublicFolderViewProps) { + const [{ folderId, fileId, cursor }, setLocation] = useQueryStates( + publicFolderParsers, + publicFolderUrlKeys + ) + const file = usePublicSharedFile(token, fileId) + const currentFolderId = fileId ? (file.data?.folderId ?? folderId) : folderId + const listing = usePublicFolder( + token, + currentFolderId, + fileId ? null : cursor, + !fileId || file.isSuccess + ) + const page = listing.data + const brand = useBrandConfig() + const provenance = buildProvenance(workspaceName, ownerName) + const error = fileId ? (file.error ?? listing.error) : listing.error + const loading = fileId ? file.isPending || listing.isPending : listing.isPending + const entries = page?.entries ?? [] + const breadcrumbs = page?.breadcrumbs ?? [] + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { enabled: !fileId && listing.isSuccess }) + + return ( +
+ +
+
+ {!brand.logoUrl && ( + <> + + + +
+ + )} +
+ + {provenance && ( + + )} +
+
+ {fileId && file.data && !error && ( + { + const anchor = document.createElement('a') + anchor.href = `/api/files/public/${token}/content?fileId=${encodeURIComponent(fileId)}` + anchor.download = file.data.name + anchor.click() + }} + > + Download + + )} +
+
+ {error ? ( +
+

This shared content is unavailable.

+ { + if (fileId && file.isError) void file.refetch() + else void listing.refetch() + }} + > + Try again + +
+ ) : loading ? ( +
+ Loading… +
+ ) : fileId && file.data ? ( + + ) : ( +
+ + + + + + + + + {entries.map((entry) => ( + + + + + ))} + +
NameSize
+ + setLocation( + entry.kind === 'folder' + ? { folderId: entry.id, fileId: null, cursor: null } + : { + folderId: page?.folder.id ?? folderId, + fileId: entry.id, + cursor: null, + } + ) + } + > + {entry.name} + + + {entry.kind === 'file' ? formatFileSize(entry.size) : '—'} +
+ {entries.length === 0 && ( +

+ This folder is empty. +

+ )} + {(page?.previousCursor || page?.nextCursor) && ( + + )} +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/search-params.ts b/apps/sim/app/f/[token]/search-params.ts new file mode 100644 index 00000000000..3b4a0717779 --- /dev/null +++ b/apps/sim/app/f/[token]/search-params.ts @@ -0,0 +1,14 @@ +import { parseAsString } from 'nuqs/server' + +/** Null selects the shared root, its listing, and the first page respectively. */ +export const publicFolderParsers = { + folderId: parseAsString, + fileId: parseAsString, + cursor: parseAsString, +} as const + +export const publicFolderUrlKeys = { + history: 'push', + clearOnDefault: true, + urlKeys: { folderId: 'folder-id', fileId: 'file-id' }, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 1ff57a3becb..5e438d42807 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -1,4 +1,5 @@ import type { ElementType } from 'react' +import { Folder } from '@sim/emcn' import { folderAncestorChain } from '@/lib/folders/tree' import type { BreadcrumbEditing, @@ -44,6 +45,7 @@ interface FolderBreadcrumbItemsBase { breadcrumbs: BreadcrumbFolder[] /** Called with the folder to open, or `null` for the workspace root. */ onNavigate: (folderId: string | null) => void + childFolders?: ReadonlyMap } /** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */ @@ -90,15 +92,32 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options const trailing = options.trailing ?? NO_TRAILING_CRUMBS + const navigationItems = (folderId: string | null) => + options.childFolders?.get(folderId)?.map((folder) => ({ + id: folder.id, + label: folder.name, + icon: Folder, + onClick: () => onNavigate(folder.id), + })) ?? (options.childFolders ? [] : undefined) + const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, + { + id: 'root', + label: rootLabel, + icon: rootIcon, + folderId: null, + onClick: () => onNavigate(null), + navigationItems: navigationItems(null), + }, ] breadcrumbs.forEach((folder, index) => { /** Where you already are — and on a detail page that is a trailing crumb, not a folder. */ const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ + id: folder.id, label: folder.name, + navigationItems: navigationItems(folder.id), folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 301cc2e7dba..11d78ecb574 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -14,6 +14,8 @@ import { import { Chip, ChipChevronDown, + ChipCombobox, + type ComboboxOption, chipContentIconClass, chipDropTargetSurfaceClass, chipGeometryClass, @@ -37,12 +39,12 @@ import { useIsOverflowing, } from '@sim/emcn' import { ArrowUpLeft } from '@sim/emcn/icons' -import { createPortal } from 'react-dom' import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { orderHeaderActions, SettingsActionChip } from '@/components/settings/settings-header' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' export interface DropdownOption { + id?: string label: string icon?: React.ElementType onClick: () => void @@ -65,6 +67,8 @@ export interface BreadcrumbEditing { } export interface BreadcrumbItem { + id?: string + navigationItems?: DropdownOption[] label: string /** * The folder this crumb navigates to (`null` is the workspace root). Supplying it makes the @@ -145,7 +149,6 @@ export const ResourceHeader = memo(function ResourceHeader({ aside, breadcrumbDrop, }: ResourceHeaderProps) { - const headerRef = useRef(null) /** * Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb * "breadcrumb" is just the current page, so it falls through to the static @@ -156,6 +159,10 @@ export const ResourceHeader = memo(function ResourceHeader({ const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined const TitleIcon = Icon ?? rootCrumb?.icon const titleLabel = title ?? rootCrumb?.label + const collapsePath = + breadcrumbs != null && + breadcrumbs.length > 4 && + breadcrumbs.some((item) => item.navigationItems !== undefined) const terminalBreadcrumbIndex = hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1 const currentResourceIndex = @@ -167,7 +174,6 @@ export const ResourceHeader = memo(function ResourceHeader({ return (
{hasBreadcrumbs ? ( breadcrumbs.map((crumb, i) => { + if (collapsePath && i > 0 && i < breadcrumbs.length - 2) { + if (i !== 1) return null + return ( + + + / + + + item.onClick + ? [ + { + id: item.id, + label: item.label, + onClick: item.onClick, + }, + ] + : [] + )} + className='shrink-0' + /> + + ) + } const segmentClassName = getBreadcrumbSegmentClassName( i, breadcrumbs.length, @@ -208,31 +239,41 @@ export const ResourceHeader = memo(function ResourceHeader({ : undefined return ( - + {i > 0 && ( / )} - {showLocationPopover ? ( - - ) : ( - - )} +
+ {showLocationPopover ? ( + + ) : ( + + )} + {crumb.navigationItems !== undefined && ( + + )} +
) }) @@ -382,7 +423,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ {dropdownItems.map((item) => { const ItemIcon = item.icon return ( - + {ItemIcon && } {item.label} @@ -438,7 +483,6 @@ interface BreadcrumbLocationPopoverProps { icon: React.ElementType breadcrumbs: BreadcrumbItem[] className?: string - veilBoundaryRef: React.RefObject drag?: BreadcrumbSegmentProps['drag'] } @@ -454,7 +498,6 @@ function BreadcrumbLocationPopover({ icon: Icon, breadcrumbs, className, - veilBoundaryRef, drag, }: BreadcrumbLocationPopoverProps) { const [open, setOpen] = useState(false) @@ -474,7 +517,7 @@ function BreadcrumbLocationPopover({ * click-to-navigate: a stationary click fires no enter event, so once * {@link navigateAndClose} sets `open` false nothing re-opens it before the * route swaps. (A move-driven open would re-fire under the resting cursor and - * flash the popover/veil back in mid-navigation.) + * reopen the popover mid-navigation.) */ const openPopover = () => { cancelScheduledClose() @@ -489,11 +532,6 @@ function BreadcrumbLocationPopover({ }, POPOVER_CLOSE_DELAY_MS) } - /** - * Closes the popover up front, then runs the crumb's handler. Closing first - * lets the veil fade and the popover play its exit animation instead of - * snapping away when navigation unmounts the header. - */ const navigateAndClose = (onClick?: () => void) => { if (!onClick) return cancelScheduledClose() @@ -509,7 +547,6 @@ function BreadcrumbLocationPopover({ return ( <> -
- ) : fileId && file.data ? ( + ) : fileId && currentFile ? ( ) : (
0, activeIndex: activeDropTarget?.kind === 'crumb' ? activeDropTarget.index : null, onDragOver: (e: DragEvent, folderId: string | null, index: number) => { if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 11d78ecb574..06b55c0c4a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -117,6 +117,8 @@ export interface ResourceAction { * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. */ export interface BreadcrumbDropConfig { + /** Expands collapsed ancestors while rows are being moved. */ + isDragging?: boolean /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ activeIndex: number | null onDragOver: (e: DragEvent, folderId: string | null, index: number) => void @@ -149,12 +151,7 @@ export const ResourceHeader = memo(function ResourceHeader({ aside, breadcrumbDrop, }: ResourceHeaderProps) { - /** - * Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb - * "breadcrumb" is just the current page, so it falls through to the static - * title below — keeping the top-left non-interactive and hover-free, - * identical to a title-only page (e.g. the Files root matches the Tables root). - */ + /** Single-crumb roots retain their static title and may offer child-folder navigation. */ const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1 const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined const TitleIcon = Icon ?? rootCrumb?.icon @@ -162,6 +159,7 @@ export const ResourceHeader = memo(function ResourceHeader({ const collapsePath = breadcrumbs != null && breadcrumbs.length > 4 && + !breadcrumbDrop?.isDragging && breadcrumbs.some((item) => item.navigationItems !== undefined) const terminalBreadcrumbIndex = hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1 @@ -287,14 +285,23 @@ export const ResourceHeader = memo(function ResourceHeader({ * layout (the JS-driven `--sidebar-width` settling) and the title * CSS-truncates to "T…" while the `shrink-0` actions hold width. */ - - {TitleIcon && } - {titleLabel && ( - - {titleLabel} - + <> + + {TitleIcon && } + {titleLabel && ( + + {titleLabel} + + )} + + {rootCrumb?.navigationItems !== undefined && ( + )} - + )}
{(aside || (actions && actions.length > 0)) && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/upload-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/upload-menu.tsx index bf02dfe814e..ef1814cdf9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/upload-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/upload-menu.tsx @@ -19,6 +19,7 @@ interface UploadMenuProps { onCancel: () => void } +/** Offers file and folder uploads, or cancellation while an upload is running. */ export function UploadMenu({ label, disabled, @@ -30,12 +31,7 @@ export function UploadMenu({ return ( - } - disabled={disabled} - aria-label='Upload' - > + } disabled={disabled}> {label} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index fb3f0dc2abf..2bcf039d7a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1004,7 +1004,7 @@ function FilesContent() { matches: hasExternalFiles, onDropIntoFolder: (dataTransfer, targetFolderId) => { dismissUploadOverlay() - void upload(selectionFromDrop(dataTransfer), targetFolderId) + void upload((signal) => selectionFromDrop(dataTransfer, signal), targetFolderId) }, }, }) @@ -1014,10 +1014,7 @@ function FilesContent() { if (!list || list.length === 0) return const selected = Array.from(list) e.currentTarget.value = '' - await upload( - Promise.resolve().then(() => selectionFromFiles(selected)), - currentFolderId - ) + await upload(() => selectionFromFiles(selected), currentFolderId) } const handleDragEnter = (e: React.DragEvent) => { @@ -1049,7 +1046,7 @@ function FilesContent() { */ rowDragDropConfig.externalDropHandled() dismissUploadOverlay() - await upload(selectionFromDrop(e.dataTransfer), currentFolderId) + await upload((signal) => selectionFromDrop(e.dataTransfer, signal), currentFolderId) } const handleDownload = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-file-uploads.ts b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-file-uploads.ts index 36ea06f0fff..4a26364351b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-file-uploads.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-file-uploads.ts @@ -27,9 +27,8 @@ interface UseFileUploadsOptions { /** Serializes captured upload batches and reconciles the browser once each batch settles. */ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploadsOptions) { const queue = useRef(Promise.resolve()) - const controller = useRef(null) - const pendingBatches = useRef(0) - const generation = useRef(0) + const batchesRef = useRef | null>(null) + const batches = (batchesRef.current ??= new Set()) const mounted = useRef(true) const [progress, setProgress] = useState(IDLE_PROGRESS) const queryClient = useQueryClient() @@ -41,35 +40,40 @@ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploads mounted.current = true return () => { mounted.current = false - generation.current += 1 - controller.current?.abort() + for (const batch of batches) batch.abort() } - }, []) + }, [workspaceId, canEdit, batches]) const upload = useCallback( - (selection: UploadSelection | Promise, targetFolderId: string | null) => { - const selected = Promise.resolve(selection) - // Attach rejection handling immediately while a preceding batch is still uploading. - const captured = selected.then( - (value) => ({ success: true as const, value }), - (error: unknown) => ({ success: false as const, error }) - ) + ( + captureSelection: (signal: AbortSignal) => UploadSelection | Promise, + targetFolderId: string | null + ) => { if (!workspaceId || !canEdit || !mounted.current) return Promise.resolve() - if (pendingBatches.current >= MAX_PENDING_UPLOAD_BATCHES) { + if (batches.size >= MAX_PENDING_UPLOAD_BATCHES) { toast.error('The upload queue is full', { description: 'Wait for an upload to finish before adding more files or folders.', }) return Promise.resolve() } - pendingBatches.current += 1 - const batchGeneration = generation.current + const abort = new AbortController() + batches.add(abort) + let selected: Promise + try { + selected = Promise.resolve(captureSelection(abort.signal)) + } catch (error) { + selected = Promise.reject(error) + } + // Native drag entries must be captured before the drop event releases its data store. + const captured = selected.then( + (value) => ({ success: true as const, value }), + (error: unknown) => ({ success: false as const, error }) + ) const run = async () => { - if (!mounted.current || batchGeneration !== generation.current) { - pendingBatches.current -= 1 + if (!mounted.current || abort.signal.aborted) { + batches.delete(abort) return } - const abort = new AbortController() - controller.current = abort let attempted = false try { onStart() @@ -95,6 +99,7 @@ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploads paths: directories, signal: abort.signal, }) + abort.signal.throwIfAborted() for (const folder of prepared.folders) folderIds.set(JSON.stringify(folder.path), folder.id) } catch (error) { @@ -125,7 +130,7 @@ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploads skipToast: true, skipInvalidation: true, onProgress: ({ percent }) => { - if (mounted.current) + if (mounted.current && !abort.signal.aborted) setProgress({ completed: index, total: allowed.length, @@ -147,7 +152,7 @@ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploads }) } } - if (mounted.current) + if (mounted.current && !abort.signal.aborted) setProgress({ completed: index + 1, total: allowed.length, @@ -172,22 +177,20 @@ export function useFileUploads({ workspaceId, canEdit, onStart }: UseFileUploads toast.error(getErrorMessage(error, 'Could not upload the selection')) } } finally { - pendingBatches.current -= 1 + batches.delete(abort) if (attempted) invalidateWorkspaceFileBrowsers(queryClient, workspaceId) - controller.current = null if (mounted.current) setProgress(IDLE_PROGRESS) } } queue.current = queue.current.then(run, run) return queue.current }, - [workspaceId, canEdit, onStart, prepareFolders, uploadFile, notifyLimit, queryClient] + [workspaceId, canEdit, onStart, prepareFolders, uploadFile, notifyLimit, queryClient, batches] ) const cancel = useCallback(() => { - generation.current += 1 - controller.current?.abort() - }, []) + for (const batch of batches) batch.abort() + }, [batches]) return { upload, cancel, progress, uploading: progress.preparing || progress.total > 0 } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.ts b/apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.ts index 8d5a80cda67..456a88a0da2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/utils/upload-selection.ts @@ -65,8 +65,35 @@ export function selectionFromFiles(files: readonly File[]): UploadSelection { return builder.finish() } -/** Snapshots native drag entries before yielding, then drains every directory reader. */ -export async function selectionFromDrop(dataTransfer: DataTransfer): Promise { +function readEntry( + signal: AbortSignal, + read: (resolve: (value: T) => void, reject: (error: DOMException) => void) => void +): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted() + const onAbort = () => reject(signal.reason) + signal.addEventListener('abort', onAbort, { once: true }) + const settle = (callback: () => void) => { + signal.removeEventListener('abort', onAbort) + callback() + } + try { + read( + (value) => settle(() => resolve(value)), + (error) => settle(() => reject(error)) + ) + } catch (error) { + settle(() => reject(error)) + } + }) +} + +/** Captures native drag entries synchronously and drains directory readers until canceled. */ +export async function selectionFromDrop( + dataTransfer: DataTransfer, + signal: AbortSignal +): Promise { + signal.throwIfAborted() if (dataTransfer.items.length === 0) return selectionFromFiles(Array.from(dataTransfer.files)) const roots = Array.from(dataTransfer.items) .filter((item) => item.kind === 'file') @@ -84,21 +111,24 @@ export async function selectionFromDrop(dataTransfer: DataTransfer): Promise => { + signal.throwIfAborted() const path = [...parent, entry.name] if (entry.isDirectory) { builder.addDirectory(path) const reader = (entry as FileSystemDirectoryEntry).createReader() while (true) { - const children = await new Promise((resolve, reject) => + const children = await readEntry(signal, (resolve, reject) => reader.readEntries(resolve, reject) ) + signal.throwIfAborted() if (children.length === 0) break for (const child of children) await visit(child, path) } } else if (entry.isFile) { - const file = await new Promise((resolve, reject) => + const file = await readEntry(signal, (resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject) ) + signal.throwIfAborted() builder.addFile(file, path) } else { throw new Error( @@ -108,6 +138,7 @@ export async function selectionFromDrop(dataTransfer: DataTransfer): Promise { it('admits a short whole-value reference on v2 only', () => { const body = { workspaceId: 'workspace-1', isActive: true, authType: 'password' } - expect(v2UpsertFileShareBodySchema.safeParse({ ...body, password: '{{PW}}' }).success).toBe( - true - ) + for (const password of ['{{PW}}', '{{1PASSWORD}}', '{{9}}']) { + expect(v2UpsertFileShareBodySchema.safeParse({ ...body, password }).success).toBe(true) + expect(sharePasswordSchema.safeParse(password).success).toBe(false) + } expect( v2UpsertFileShareBodySchema.safeParse({ ...body, password: 'x-{{PW}}' }).error?.issues[0] .message ).toBe('Password must be at least 15 characters') - expect(sharePasswordSchema.safeParse('{{PW}}').success).toBe(false) }) it('caps a reference at the password length limit with one issue', () => { diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 2daef77b243..983946bd900 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { privateSecretProvenanceBundleSchema } from '@/lib/api/contracts/primitives' -import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares' +import { shareAuthTypeSchema, upsertFileShareBodySchema } from '@/lib/api/contracts/public-shares' import { toolJsonResponseSchema } from '@/lib/api/contracts/tools/media/shared' import { defineRouteContract } from '@/lib/api/contracts/types' import { @@ -170,7 +170,7 @@ const fileManageFolderSharingBodySchema = z.object({ isActive: z.boolean({ error: 'isActive is required for manage_folder_sharing operation' }), authType: shareAuthTypeSchema.optional(), password: z.string().min(1).max(1024).optional(), - allowedEmails: z.array(z.string().min(1)).max(200).optional(), + allowedEmails: upsertFileShareBodySchema.shape.allowedEmails, }) export const fileManageReadBodySchema = z diff --git a/apps/sim/lib/api/contracts/workspace-file-folder-upload.ts b/apps/sim/lib/api/contracts/workspace-file-folder-upload.ts index f3001010dec..3b59a49bd3b 100644 --- a/apps/sim/lib/api/contracts/workspace-file-folder-upload.ts +++ b/apps/sim/lib/api/contracts/workspace-file-folder-upload.ts @@ -1,16 +1,17 @@ import { z } from 'zod' +import { folderIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { workspaceFileFoldersParamsSchema } from '@/lib/api/contracts/workspace-file-folders' -import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { MAX_FOLDER_NAME_LENGTH, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' import { MAX_UPLOAD_DIRECTORIES } from '@/lib/workspace-files/upload-directory-plan' const uploadDirectoryPathSchema = z - .array(z.string().min(1).max(4096)) + .array(z.string().min(1).max(MAX_FOLDER_NAME_LENGTH)) .min(1) .max(MAX_FOLDER_PATH_SEGMENTS) const prepareUploadFoldersBodySchema = z.object({ - targetFolderId: z.string().min(1).max(100).nullable(), + targetFolderId: folderIdSchema.nullable(), paths: z.array(uploadDirectoryPathSchema).min(1).max(MAX_UPLOAD_DIRECTORIES), }) diff --git a/apps/sim/lib/core/application/environment-reference.test.ts b/apps/sim/lib/core/application/environment-reference.test.ts index 8699f9afd48..b696e9dd42f 100644 --- a/apps/sim/lib/core/application/environment-reference.test.ts +++ b/apps/sim/lib/core/application/environment-reference.test.ts @@ -34,23 +34,21 @@ function environment(variables: Record) { describe('resolveCopilotSecretReference', () => { afterEach(resetEnvironmentUtilsMock) - it("resolves an agent's whole-value reference from the subject user's environment", async () => { - environment({ CHAT_PASSWORD: 'newly-created-password' }) + it.each(['CHAT_PASSWORD', '1PASSWORD', '9'])( + "resolves an agent's whole-value reference %s from the subject user's environment", + async (name) => { + environment({ [name]: 'newly-created-password' }) - await expect( - resolveCopilotSecretReference( - copilotPrincipal(), + await expect( + resolveCopilotSecretReference(copilotPrincipal(), WORKSPACE_ID, `{{ ${name} }}`, 'password') + ).resolves.toBe('newly-created-password') + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledWith( + 'user-1', WORKSPACE_ID, - '{{ CHAT_PASSWORD }}', - 'password' + [name] ) - ).resolves.toBe('newly-created-password') - expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledWith( - 'user-1', - WORKSPACE_ID, - ['CHAT_PASSWORD'] - ) - }) + } + ) it('refuses an unset variable by name instead of storing the placeholder', async () => { await expect( @@ -71,7 +69,7 @@ describe('resolveCopilotSecretReference', () => { }) it('leaves literal and embedded-reference passwords alone without loading the environment', async () => { - for (const value of ['$literal_password', 'prefix-{{CHAT_PW}}', undefined]) { + for (const value of ['$literal_password', 'prefix-{{CHAT_PW}}', '{{NOT-A-NAME}}', undefined]) { await expect( resolveCopilotSecretReference(copilotPrincipal(), WORKSPACE_ID, value, 'password') ).resolves.toBe(value) @@ -81,9 +79,11 @@ describe('resolveCopilotSecretReference', () => { it('keeps literal semantics for every caller that is not an admitted agent invocation', async () => { for (const principal of [sessionPrincipal, copilotPrincipal({ admitted: false })]) { - await expect( - resolveCopilotSecretReference(principal, WORKSPACE_ID, '{{CHAT_PW}}', 'password') - ).resolves.toBe('{{CHAT_PW}}') + for (const value of ['{{CHAT_PW}}', '{{1PASSWORD}}']) { + await expect( + resolveCopilotSecretReference(principal, WORKSPACE_ID, value, 'password') + ).resolves.toBe(value) + } } expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/environment/reference.ts b/apps/sim/lib/environment/reference.ts index 71ff496ca93..b1de2b251d4 100644 --- a/apps/sim/lib/environment/reference.ts +++ b/apps/sim/lib/environment/reference.ts @@ -2,7 +2,7 @@ * A value that is exactly one `{{NAME}}` environment-variable reference, with the * variable name captured. Embedded references (`prefix-{{NAME}}`) do not match. */ -export const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/ +export const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/ /** Returns the referenced variable name when `value` is a whole-value `{{NAME}}` reference. */ export function parseExactEnvironmentReference(value: string | undefined): string | undefined { diff --git a/apps/sim/lib/folders/naming.ts b/apps/sim/lib/folders/naming.ts index 3f8d66738e6..aa8336acf5c 100644 --- a/apps/sim/lib/folders/naming.ts +++ b/apps/sim/lib/folders/naming.ts @@ -1,5 +1,6 @@ import type { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' +import { truncateAtCodePoint } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' import type { FolderResourceType } from '@/lib/api/contracts/folders' @@ -18,13 +19,16 @@ type DbOrTx = Pick * The `" (N)"` shape deliberately matches both the client-side dedup in * `nextUntitledFolderName` and the backfill in migration 0272, so a deduped name reads the * same however it was produced. + * An optional length cap reserves room for the suffix without splitting Unicode pairs; + * callers still validate the requested name before attempting a mutation. */ export async function deduplicateFolderName( tx: DbOrTx, workspaceId: string, parentId: string | null, requestedName: string, - resourceType: FolderResourceType + resourceType: FolderResourceType, + maxLength?: number ): Promise { const siblingRows = await tx .select({ name: folderTable.name }) @@ -42,6 +46,17 @@ export async function deduplicateFolderName( if (!siblingNames.has(requestedName)) return requestedName let suffix = 1 - while (siblingNames.has(`${requestedName} (${suffix})`)) suffix += 1 - return `${requestedName} (${suffix})` + while (true) { + const ending = ` (${suffix})` + if (maxLength !== undefined && ending.length >= maxLength) { + throw new Error('Folder name limit cannot accommodate a unique suffix') + } + const stem = + maxLength === undefined + ? requestedName + : truncateAtCodePoint(requestedName, maxLength - ending.length, '') + const candidate = `${stem}${ending}` + if (!siblingNames.has(candidate)) return candidate + suffix += 1 + } } diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts index 8e1a76a8990..eb6104bea3f 100644 --- a/apps/sim/lib/folders/paths.ts +++ b/apps/sim/lib/folders/paths.ts @@ -3,6 +3,7 @@ import { containsNulCharacter } from '@sim/utils/string' import { OrchestrationError } from '@/lib/core/orchestration/types' export const ROOT_FOLDER_PATH = '/' +export const MAX_FOLDER_NAME_LENGTH = 255 export const MAX_FOLDER_PATH_SEGMENTS = 64 export const MAX_FOLDER_PATH_BYTES = 4096 diff --git a/apps/sim/lib/public-shares/access.ts b/apps/sim/lib/public-shares/access.ts index d1a145aa1bc..ee09463e587 100644 --- a/apps/sim/lib/public-shares/access.ts +++ b/apps/sim/lib/public-shares/access.ts @@ -31,7 +31,6 @@ async function authorizeShare(input: PublicShareReadInput) { /** Reads metadata for one file after token authentication and live folder containment. */ export async function readPublicSharedFile(input: PublicShareReadInput & { fileId?: string }) { const resolved = await authorizeShare(input) - if (resolved.kind === 'file' && input.fileId === undefined) return resolved const file = await resolveSharedFile(resolved, input.fileId) if (!file) throw new PublicShareAccessError(404, 'Not found') return { ...resolved, file } diff --git a/apps/sim/lib/public-shares/folder-reader.ts b/apps/sim/lib/public-shares/folder-reader.ts index ba76c8113f0..a3b7e021c84 100644 --- a/apps/sim/lib/public-shares/folder-reader.ts +++ b/apps/sim/lib/public-shares/folder-reader.ts @@ -55,7 +55,7 @@ async function shareIsCurrent(resolved: ResolvedResourceShare): Promise share.password === null ? isNull(publicShare.password) : eq(publicShare.password, share.password), - sql`${publicShare.allowedEmails}::jsonb IS NOT DISTINCT FROM ${JSON.stringify(share.allowedEmails)}::jsonb` + sql`coalesce(nullif(${publicShare.allowedEmails}::jsonb, 'null'::jsonb), '[]'::jsonb) = ${JSON.stringify(share.allowedEmails ?? [])}::jsonb` ) ) .limit(1) diff --git a/apps/sim/lib/public-shares/folders.integration.ts b/apps/sim/lib/public-shares/folders.integration.ts index 460885362c1..35064d3b9f6 100644 --- a/apps/sim/lib/public-shares/folders.integration.ts +++ b/apps/sim/lib/public-shares/folders.integration.ts @@ -1,8 +1,9 @@ import { db } from '@sim/db' import { folder, publicShare, user, workspace, workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { eq, inArray } from 'drizzle-orm' +import { eq, inArray, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { readPublicSharedFile } from '@/lib/public-shares/access' import { readSharedFolderPage, resolveSharedFile } from '@/lib/public-shares/folder-reader' import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' @@ -17,6 +18,8 @@ describe('public folder capabilities in PostgreSQL', () => { const fileId = generateId() const token = generateId() const shareId = generateId() + const directShareId = generateId() + const directToken = generateId() beforeAll(async () => { await db.insert(user).values({ @@ -73,6 +76,14 @@ describe('public folder capabilities in PostgreSQL', () => { createdBy: userId, token, }) + await db.insert(publicShare).values({ + id: directShareId, + resourceType: 'file', + resourceId: fileId, + workspaceId, + createdBy: userId, + token: directToken, + }) }) afterAll(async () => { @@ -163,4 +174,58 @@ describe('public folder capabilities in PostgreSQL', () => { await db.update(publicShare).set({ isActive: true }).where(eq(publicShare.id, shareId)) } }) + + it.each(['SQL NULL', 'JSON null'] as const)( + 'accepts a %s allow-list as empty while still rejecting a changed policy', + async (storage) => { + await db + .update(publicShare) + .set({ allowedEmails: storage === 'SQL NULL' ? null : sql`'null'::jsonb` }) + .where(eq(publicShare.id, shareId)) + try { + const resolved = await share() + expect((await readSharedFolderPage(resolved, {}))?.folder.id).toBe(rootId) + expect((await resolveSharedFile(resolved, fileId))?.id).toBe(fileId) + await db + .update(publicShare) + .set({ allowedEmails: ['restricted@fixture.test'] }) + .where(eq(publicShare.id, shareId)) + expect(await readSharedFolderPage(resolved, {})).toBeNull() + expect(await resolveSharedFile(resolved, fileId)).toBeNull() + } finally { + await db.update(publicShare).set({ allowedEmails: [] }).where(eq(publicShare.id, shareId)) + } + } + ) + + it.each([ + { name: 'revocation', update: { isActive: false } }, + { name: 'password protection', update: { authType: 'password', password: 'changed-policy' } }, + { name: 'email restrictions', update: { allowedEmails: ['restricted@fixture.test'] } }, + ])('denies direct file reads when $name occurs during authorization', async ({ update }) => { + expect( + ( + await readPublicSharedFile({ + token: directToken, + authorize: async () => ({ authorized: true }), + }) + ).file.id + ).toBe(fileId) + try { + await expect( + readPublicSharedFile({ + token: directToken, + authorize: async () => { + await db.update(publicShare).set(update).where(eq(publicShare.id, directShareId)) + return { authorized: true } + }, + }) + ).rejects.toMatchObject({ status: 404 }) + } finally { + await db + .update(publicShare) + .set({ isActive: true, authType: 'public', password: null, allowedEmails: [] }) + .where(eq(publicShare.id, directShareId)) + } + }) }) diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index 04316ef79be..8f43b0777f3 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -27,30 +27,33 @@ describe('file operation registry', () => { } }) + /** The independent allowlist rejects newly added or accidentally widened executor authority. */ it('allows executor delegation only for operations used by the internal file tool', () => { const executorOperationIds = Object.values(fileOperations) .filter((operation) => operation.delegatedServices?.includes('executor')) .map((operation) => operation.id) - expect(executorOperationIds).toEqual([ - 'files.list', - 'files.read_metadata', - 'files.read_content', - 'files.search_content', - 'files.download', - 'files.create', - 'files.update_content', - 'files.move', - 'files.share.read', - 'files.share.update', - 'files.folders.share.read', - 'files.folders.share.update', - 'files.folders.list', - 'files.folders.create', - 'files.folders.update', - 'files.folders.delete', - 'files.folders.restore', - ]) + expect(new Set(executorOperationIds)).toEqual( + new Set([ + 'files.list', + 'files.read_metadata', + 'files.read_content', + 'files.search_content', + 'files.download', + 'files.create', + 'files.update_content', + 'files.move', + 'files.share.read', + 'files.share.update', + 'files.folders.share.read', + 'files.folders.share.update', + 'files.folders.list', + 'files.folders.create', + 'files.folders.update', + 'files.folders.delete', + 'files.folders.restore', + ]) + ) }) it('keeps external sharing policy changes human-delegated', () => { diff --git a/apps/sim/lib/workspace-files/search/chunks.integration.ts b/apps/sim/lib/workspace-files/search/chunks.integration.ts index 44617d76eb4..35573d9415b 100644 --- a/apps/sim/lib/workspace-files/search/chunks.integration.ts +++ b/apps/sim/lib/workspace-files/search/chunks.integration.ts @@ -608,7 +608,8 @@ describe('chunked workspace file search on PostgreSQL', () => { }) it('projects metadata only for returned matches inside the current folder scope', async () => { await index('needle') - await index('needle\nneedle', await addFile('file-2', 'workspace-1', 'Report.md', 'folder-1')) + await index('needle', await addFile('file-2', 'workspace-1', 'Report.md', 'folder-1')) + await index('needle', await addFile('file-4', 'workspace-1', 'Z-Other.md', 'folder-1')) await index('needle', await addFile('file-3', 'workspace-2', 'Private.md', 'folder-1')) const result = await searchWorkspaceFileIndex({ workspaceId: 'workspace-1', diff --git a/apps/sim/lib/workspace-files/upload-directory-plan.ts b/apps/sim/lib/workspace-files/upload-directory-plan.ts index aca9a6affb6..afd0fc566d9 100644 --- a/apps/sim/lib/workspace-files/upload-directory-plan.ts +++ b/apps/sim/lib/workspace-files/upload-directory-plan.ts @@ -1,5 +1,9 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' -import { buildFolderPath, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { + buildFolderPath, + MAX_FOLDER_NAME_LENGTH, + MAX_FOLDER_PATH_SEGMENTS, +} from '@/lib/folders/paths' export const MAX_UPLOAD_DIRECTORIES = 1_000 const MAX_UPLOAD_ROOT_DIRECTORIES = 100 @@ -11,6 +15,12 @@ export function validateUploadPath(path: readonly string[]): void { throw new OrchestrationError('validation', 'The selected path is too deep') } for (const name of path) { + if (name.length > MAX_FOLDER_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Folder names cannot exceed ${MAX_FOLDER_NAME_LENGTH} characters` + ) + } if (!name || name !== name.trim() || name === '.' || name === '..' || /[/\\]/.test(name)) { throw new OrchestrationError( 'validation', diff --git a/apps/sim/lib/workspace-files/upload-folders.integration.ts b/apps/sim/lib/workspace-files/upload-folders.integration.ts index 6de99689da3..d2678a1ae89 100644 --- a/apps/sim/lib/workspace-files/upload-folders.integration.ts +++ b/apps/sim/lib/workspace-files/upload-folders.integration.ts @@ -54,6 +54,28 @@ describe('folder upload preparation in Postgres', () => { expect(stored.find((entry) => entry.name === 'Empty')?.parentId).toBe(firstRoot?.id) }) + it.each(['A'.repeat(255), `${'A'.repeat(250)}😀XYZ`])( + 'keeps repeated maximum-length roots addressable without splitting Unicode: %s', + async (name) => { + const imports: Awaited>[] = [] + for (let attempt = 0; attempt < 3; attempt += 1) { + imports.push(await prepare([[name], [name, 'Child']])) + } + const stored = await db.select().from(folder) + const roots = stored.filter((row) => row.parentId === null) + expect(roots).toHaveLength(3) + expect(new Set(roots.map((row) => row.name)).size).toBe(3) + expect(roots.some((row) => row.name === name)).toBe(true) + for (const row of roots) { + expect(row.name.length).toBeLessThanOrEqual(255) + expect(() => encodeURIComponent(row.name)).not.toThrow() + expect(stored.filter((child) => child.parentId === row.id)).toHaveLength(1) + } + expect(imports[1][0].name.endsWith(' (1)')).toBe(true) + expect(imports[2][0].name.endsWith(' (2)')).toBe(true) + } + ) + it.each(['elsewhere', 'archived', 'workflow'])( 'refuses the %s destination without creating anything', async (kind) => { @@ -70,10 +92,15 @@ describe('folder upload preparation in Postgres', () => { } ) - it('rejects an invalid descendant before any folder can commit', async () => { - await expect(prepare([['Safe'], ['Safe', '..']])).rejects.toMatchObject({ code: 'validation' }) - expect(await db.select().from(folder)).toHaveLength(0) - }) + it.each(['..', 'A'.repeat(256)])( + 'rejects an invalid descendant before any folder can commit: %s', + async (name) => { + await expect(prepare([['Safe'], ['Safe', name]])).rejects.toMatchObject({ + code: 'validation', + }) + expect(await db.select().from(folder)).toHaveLength(0) + } + ) it('requires declared ancestors instead of silently flattening files', async () => { await expect(prepare([['Missing', 'Child']])).rejects.toMatchObject({ code: 'validation' }) diff --git a/apps/sim/lib/workspace-files/upload-folders.ts b/apps/sim/lib/workspace-files/upload-folders.ts index 7f1e6780353..c6840f5c9eb 100644 --- a/apps/sim/lib/workspace-files/upload-folders.ts +++ b/apps/sim/lib/workspace-files/upload-folders.ts @@ -6,7 +6,11 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbTransaction } from '@/lib/db/types' import { acquireFolderMutationLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' -import { buildFolderPath, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { + buildFolderPath, + MAX_FOLDER_NAME_LENGTH, + MAX_FOLDER_PATH_SEGMENTS, +} from '@/lib/folders/paths' import { assertFolderCollectionHasRoom } from '@/lib/folders/queries' import { validateUploadDirectories } from '@/lib/workspace-files/upload-directory-plan' @@ -91,7 +95,8 @@ export async function prepareUploadFolders( params.workspaceId, params.targetFolderId, requestedName, - 'file' + 'file', + MAX_FOLDER_NAME_LENGTH ) const resolvedPath = [...(parent?.resolvedPath ?? destination), name] buildFolderPath(resolvedPath) diff --git a/apps/sim/scripts/files-e2e/browser.ts b/apps/sim/scripts/files-e2e/browser.ts new file mode 100644 index 00000000000..629caaa6b6c --- /dev/null +++ b/apps/sim/scripts/files-e2e/browser.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test' + +/** Drops real OS paths through Chromium’s native file-entry boundary. */ +export async function dropPaths(page: Page, paths: string[]) { + const session = await page.context().newCDPSession(page) + try { + const data = { items: [], files: paths, dragOperationsMask: 1 } + await session.send('Input.dispatchDragEvent', { type: 'dragEnter', x: 800, y: 700, data }) + await session.send('Input.dispatchDragEvent', { type: 'dragOver', x: 800, y: 700, data }) + await session.send('Input.dispatchDragEvent', { type: 'drop', x: 800, y: 700, data }) + } finally { + await session.detach() + } +} diff --git a/apps/sim/scripts/files-e2e/index.ts b/apps/sim/scripts/files-e2e/index.ts index 0a3e0d727c1..02b6e70e866 100644 --- a/apps/sim/scripts/files-e2e/index.ts +++ b/apps/sim/scripts/files-e2e/index.ts @@ -1,4 +1,6 @@ +export { dropPaths } from './browser' export { runNavigationScaleChecks } from './navigation-scale' export { runSearchWorkflowChecks } from './search-workflows' export { runSharingChecks } from './sharing' export type { FilesE2EContext, FilesE2EFixture, FilesE2ERequestOptions } from './types' +export { runUploadLifecycleChecks } from './upload-lifecycle' diff --git a/apps/sim/scripts/files-e2e/sharing.ts b/apps/sim/scripts/files-e2e/sharing.ts index fdd6a2486fb..b4764e06866 100644 --- a/apps/sim/scripts/files-e2e/sharing.ts +++ b/apps/sim/scripts/files-e2e/sharing.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { join } from 'node:path' import { sha256Hex } from '@sim/security/hash' +import { createDeferred } from '@sim/testing/helpers/deferred' import { generateId, generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { makeSignature } from 'better-auth/crypto' @@ -76,6 +77,8 @@ export async function runSharingChecks({ const readerSessionToken = generateShortId() const workspaceKeyId = generateId() const workspaceKey = `sk-sim-folder-sharing-fixture-${generateId()}` + const personalKeyId = generateId() + const personalKey = `sk-sim-folder-policy-fixture-${generateId()}` const foreignWorkspaceId = generateId() const foreignFolderId = generateId() const foreignShareId = generateId() @@ -122,7 +125,9 @@ export async function runSharingChecks({ await tx`insert into session (id, token, user_id, active_organization_id, expires_at, created_at, updated_at) values (${generateId()}, ${readerSessionToken}, ${readerId}, ${fixture.orgId}, now() + interval '1 hour', now(), now())` await tx`insert into api_key (id, user_id, workspace_id, name, key, key_hash, type) - values (${workspaceKeyId}, ${fixture.ownerId}, ${fixture.workspaceId}, 'Files folder sharing fixture', ${workspaceKey}, ${sha256Hex(workspaceKey)}, 'workspace')` + values + (${workspaceKeyId}, ${fixture.ownerId}, ${fixture.workspaceId}, 'Files folder sharing fixture', ${workspaceKey}, ${sha256Hex(workspaceKey)}, 'workspace'), + (${personalKeyId}, ${fixture.ownerId}, null, 'Files folder policy fixture', ${personalKey}, ${sha256Hex(personalKey)}, 'personal')` await tx`insert into workspace (id, name, owner_id, billed_account_user_id) values (${foreignWorkspaceId}, 'Foreign sharing fixture', ${readerId}, ${readerId})` await tx`insert into folder (id, workspace_id, user_id, resource_type, name) @@ -228,6 +233,38 @@ export async function runSharingChecks({ await page.keyboard.press('Escape') }) + await check( + 'folder sharing tools reject oversized email restrictions without mutating policy', + async () => { + const headers = { 'x-api-key': personalKey } + await json(`/api/v2/files?workspaceId=${fixture.workspaceId}&limit=1`, { + authenticated: false, + headers, + }) + const before = await sql`select * from public_share where id = ${shareId}` + const result = await json('/api/v2/tools/file_manage_folder_sharing/execute', { + method: 'POST', + authenticated: false, + headers, + body: { + workspaceId: fixture.workspaceId, + input: { + path: '/Sharing%20fixture', + isActive: true, + authType: 'email', + allowedEmails: [`${'a'.repeat(308)}@fixture.test`], + }, + }, + }) + assert(isRecordLike(result.data)) + assert.equal(result.data.status, 'failed') + assert(isRecordLike(result.data.output)) + assert.equal(result.data.output.status, 400) + const after = await sql`select * from public_share where id = ${shareId}` + assert.deepEqual(after, before, 'Rejected input changed the stored sharing policy') + } + ) + await check( 'anonymous folder reads are bounded, private, and contained to the live subtree', async () => { @@ -469,6 +506,87 @@ export async function runSharingChecks({ } ) + for (const view of ['folder', 'file'] as const) { + await check(`cached ${view} data stays hidden while authorization is rechecked`, async () => { + const location = new URL(`/f/${token}`, baseUrl) + location.searchParams.set('folder-id', childId) + if (view === 'file') location.searchParams.set('file-id', firstId) + await visitor.goto(location.href, { waitUntil: 'domcontentloaded' }) + if (view === 'file') { + await visitor.getByText('FOLDER_FIRST_PREVIEW=one', { exact: false }).waitFor() + } else { + await visitor.getByRole('button', { name: 'env.template', exact: true }).waitFor() + } + const release = createDeferred() + const authorizationRequest = (url: URL) => + url.pathname === (view === 'folder' ? folderPath() : publicPath()) + await visitor.route(authorizationRequest, async (route) => { + await release.promise + await route.continue() + }) + try { + if (view === 'folder') { + await updateShare({ isActive: false }) + } else { + await sql`update workspace_files set folder_id = ${privateId} where id = ${firstId}` + } + const pending = visitor.waitForRequest((request) => + authorizationRequest(new URL(request.url())) + ) + const folderRechecked = + view === 'file' + ? visitor.waitForResponse( + (response) => + new URL(response.url()).pathname === folderPath() && response.status() === 200 + ) + : Promise.resolve() + await visitor.evaluate(() => { + window.dispatchEvent(new Event('offline')) + window.dispatchEvent(new Event('online')) + }) + await pending + const checkedListing = await folderRechecked + if (checkedListing) { + await checkedListing.finished() + await visitor.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + } + await visitor.getByRole('status').filter({ hasText: 'Loading…' }).waitFor() + assert.equal(await visitor.locator('tbody tr').count(), 0) + assert.equal( + await visitor.getByText('FOLDER_FIRST_PREVIEW=one', { exact: false }).count(), + 0 + ) + assert.equal( + await visitor.getByRole('button', { name: 'Download', exact: true }).count(), + 0 + ) + assert.equal(await visitor.getByText('env.template', { exact: true }).count(), 0) + await visitor.screenshot({ + path: join(reportDirectory, `public-${view}-revalidation.png`), + }) + const denied = visitor.waitForResponse( + (response) => authorizationRequest(new URL(response.url())) && response.status() === 404 + ) + release.resolve() + await denied + await visitor.getByText('This shared content is unavailable.', { exact: true }).waitFor() + } finally { + release.resolve() + await visitor.unroute(authorizationRequest) + if (view === 'folder') { + await updateShare({ isActive: true }) + } else { + await sql`update workspace_files set folder_id = ${childId} where id = ${firstId}` + } + } + }) + } + await check( 'disabled folder sharing revokes listing, metadata, bytes, and cached browser navigation', async () => { @@ -499,10 +617,13 @@ export async function runSharingChecks({ await anonymous.close() } finally { await sql.begin(async (tx) => { - await tx`delete from api_key where id = ${workspaceKeyId}` + await tx`delete from api_key where id in (${workspaceKeyId}, ${personalKeyId})` await tx`delete from workspace where id = ${foreignWorkspaceId}` await tx`delete from "user" where id = ${readerId}` - await tx`delete from rate_limit_bucket where key = ${`route:workspace-folder-sharing:${readerId}`}` + await tx`delete from rate_limit_bucket where key in ( + ${`route:workspace-folder-sharing:${readerId}`}, + ${`route:workspace-folder-sharing:${fixture.ownerId}`} + )` }) } } diff --git a/apps/sim/scripts/files-e2e/upload-lifecycle.ts b/apps/sim/scripts/files-e2e/upload-lifecycle.ts new file mode 100644 index 00000000000..b494f26d2b8 --- /dev/null +++ b/apps/sim/scripts/files-e2e/upload-lifecycle.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { dropPaths } from '@/scripts/files-e2e/browser' +import type { FilesE2EContext } from '@/scripts/files-e2e/types' + +/** Exercises cancellation while native readers and real upload requests are in flight. */ +export async function runUploadLifecycleChecks(context: FilesE2EContext, directory: string) { + const { fixture, sql, page, baseUrl, check } = context + await page.goto(new URL(`/workspace/${fixture.workspaceId}/files`, baseUrl).href, { + waitUntil: 'domcontentloaded', + }) + await page.locator('input[webkitdirectory]').waitFor({ state: 'attached' }) + await page.getByRole('button', { name: 'Upload', exact: true }).waitFor() + + await check( + 'cancel releases a delayed native directory read and permits a new upload', + async () => { + const root = join(directory, 'Canceled traversal') + await mkdir(join(root, 'Nested'), { recursive: true }) + await writeFile(join(root, 'Nested', 'must-not-upload.txt'), 'Canceled directory contents\n') + const recovery = join(directory, 'after-reader-cancel.txt') + await writeFile(recovery, 'Reader cancellation recovered\n') + const reader = await page.evaluateHandle(() => { + const original = FileSystemDirectoryReader.prototype.readEntries + const pending: (() => void)[] = [] + let calls = 0 + let released = false + FileSystemDirectoryReader.prototype.readEntries = function (success, failure) { + calls += 1 + original.call( + this, + (entries) => { + if (released) success(entries) + else pending.push(() => success(entries)) + }, + failure + ) + } + return { + get calls() { + return calls + }, + get pending() { + return pending.length + }, + release() { + released = true + for (const finish of pending.splice(0)) finish() + }, + restore() { + FileSystemDirectoryReader.prototype.readEntries = original + }, + } + }) + try { + await dropPaths(page, [root]) + const deadline = Date.now() + 10_000 + while ((await reader.evaluate((value) => value.pending)) === 0) { + assert(Date.now() < deadline, 'The native directory read never reached the held callback') + await sleep(50) + } + await page.getByRole('button', { name: 'Preparing…', exact: true }).click() + await page.getByRole('menuitem', { name: 'Cancel upload', exact: true }).click() + await page.getByRole('button', { name: 'Upload', exact: true }).waitFor({ timeout: 3000 }) + await dropPaths(page, [recovery]) + const uploaded = Date.now() + 120_000 + while (true) { + const rows = + await sql`select id from workspace_files where workspace_id = ${fixture.workspaceId} + and original_name = 'after-reader-cancel.txt'` + if (rows.length === 1) break + assert(Date.now() < uploaded, 'A delayed canceled reader blocked a new upload') + await sleep(200) + } + await reader.evaluate((value) => value.release()) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + ) + assert.equal( + await reader.evaluate((value) => value.calls), + 1, + 'Canceled traversal read another directory' + ) + const rows = await sql`select id from folder where workspace_id = ${fixture.workspaceId} + and name = 'Canceled traversal'` + assert.equal(rows.length, 0, 'Canceled traversal created its folder tree') + } finally { + await reader.evaluate((value) => { + value.release() + value.restore() + }) + await reader.dispose() + } + } + ) + + await check( + 'losing edit access aborts active and queued uploads before further requests', + async () => { + const names = ['access-active.txt', 'access-next.txt', 'access-queued.txt'] + const paths = names.map((name) => join(directory, name)) + for (const path of paths) await writeFile(path, 'Access revocation must stop this upload\n') + const replacementOwner = generateId() + const email = `files-e2e-replacement-${replacementOwner}@files-e2e.test` + await sql`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at) + values (${replacementOwner}, 'Replacement owner', ${email}, ${email}, true, now(), now())` + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let requests = 0 + const pattern = '**/api/files/uploads' + await page.route(pattern, async (route) => { + requests += 1 + if (requests === 1) await gate + await route.continue().catch(() => {}) + }) + try { + await page.getByRole('button', { name: 'Upload', exact: true }).waitFor() + await dropPaths(page, paths.slice(0, 2)) + const deadline = Date.now() + 30_000 + while (requests === 0) { + assert(Date.now() < deadline, 'The first upload did not reach its HTTP boundary') + await sleep(50) + } + await dropPaths(page, [paths[2]]) + await sql.begin(async (tx) => { + await tx`update workspace set owner_id = ${replacementOwner} where id = ${fixture.workspaceId}` + await tx`update member set role = 'member' where user_id = ${fixture.ownerId} and organization_id = ${fixture.orgId}` + await tx`update permissions set permission_type = 'read' where user_id = ${fixture.ownerId} + and entity_type = 'workspace' and entity_id = ${fixture.workspaceId}` + }) + await page.clock.setFixedTime(Date.now() + 60_000) + await page.evaluate(() => { + window.dispatchEvent(new Event('offline')) + window.dispatchEvent(new Event('online')) + }) + await page.waitForFunction(() => + Array.from(document.querySelectorAll('button')).some( + (button) => button.textContent?.trim() === 'New folder' && button.disabled + ) + ) + release() + await page.getByRole('button', { name: 'Upload', exact: true }).waitFor({ timeout: 5000 }) + assert.equal(requests, 1, 'The batch issued more uploads after edit access was revoked') + const rows = + await sql`select id from workspace_files where workspace_id = ${fixture.workspaceId} + and original_name = any(${names})` + assert.equal(rows.length, 0, 'An upload persisted after edit access was revoked') + } finally { + release() + await page.unroute(pattern) + await page.clock.setSystemTime(Date.now()) + await sql.begin(async (tx) => { + await tx`update workspace set owner_id = ${fixture.ownerId} where id = ${fixture.workspaceId}` + await tx`update member set role = 'owner' where user_id = ${fixture.ownerId} and organization_id = ${fixture.orgId}` + await tx`update permissions set permission_type = 'admin' where user_id = ${fixture.ownerId} + and entity_type = 'workspace' and entity_id = ${fixture.workspaceId}` + await tx`delete from "user" where id = ${replacementOwner}` + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + } + } + ) +} diff --git a/apps/sim/scripts/test-files-e2e.ts b/apps/sim/scripts/test-files-e2e.ts index 55930f6f628..2c461cc65fb 100644 --- a/apps/sim/scripts/test-files-e2e.ts +++ b/apps/sim/scripts/test-files-e2e.ts @@ -13,12 +13,14 @@ import { isRecordLike } from '@sim/utils/object' import { makeSignature } from 'better-auth/crypto' import postgres from 'postgres' import { + dropPaths, type FilesE2EContext, type FilesE2EFixture, type FilesE2ERequestOptions, runNavigationScaleChecks, runSearchWorkflowChecks, runSharingChecks, + runUploadLifecycleChecks, } from '@/scripts/files-e2e' const logger = createLogger('FilesE2E') @@ -34,7 +36,14 @@ const databaseUrl = new URL(requiredEnvironment('FILES_E2E_DATABASE_URL')) const authSecret = requiredEnvironment('FILES_E2E_AUTH_SECRET') const reportPath = resolve(requiredEnvironment('FILES_E2E_REPORT_PATH')) const reportDirectory = dirname(reportPath) -const supportedSuites = ['upload', 'navigation', 'navigation-scale', 'search', 'sharing'] +const supportedSuites = [ + 'upload', + 'upload-lifecycle', + 'navigation', + 'navigation-scale', + 'search', + 'sharing', +] const selectedSuites = process.env.FILES_E2E_SUITES?.split(',').map((name) => name.trim()) ?? supportedSuites assert( @@ -196,7 +205,7 @@ async function uploadDirectory( await page.waitForFunction( () => !document.querySelector('input[webkitdirectory]')?.disabled ) - await page.locator('button[aria-label="Upload"]').click() + await page.getByRole('button', { name: 'Upload', exact: true }).click() const [chooser] = await Promise.all([ page.waitForEvent('filechooser'), page.getByRole('menuitem', { name: 'Upload folder', exact: true }).click(), @@ -294,8 +303,26 @@ async function runUploadChecks(page: Page, sourceDirectory: string) { } }) + await check('directory uploads accept a destination with a 128-character legacy ID', async () => { + const destinationId = `${fixture.workspaceId}-`.padEnd(128, 'x') + await sql`insert into folder (id, workspace_id, user_id, resource_type, name) + values (${destinationId}, ${fixture.workspaceId}, ${fixture.ownerId}, 'file', 'Legacy destination')` + const prepared = await json( + `/api/workspaces/${fixture.workspaceId}/files/folders/prepare-upload`, + { + method: 'POST', + body: { targetFolderId: destinationId, paths: [['Legacy upload']] }, + } + ) + assert(Array.isArray(prepared.folders) && prepared.folders.length === 1) + const uploadedId = string(record(prepared.folders[0]).id) + const [stored] = await sql`select parent_id from folder where id = ${uploadedId} + and workspace_id = ${fixture.workspaceId}` + assert.equal(stored?.parent_id, destinationId) + }) + await check( - 'folder preparation refuses unauthenticated, missing, and unsafe destinations', + 'folder preparation refuses unauthenticated, missing, unsafe, and oversized destinations', async () => { const path = `/api/workspaces/${fixture.workspaceId}/files/folders/prepare-upload` await json(path, { @@ -314,6 +341,11 @@ async function runUploadChecks(page: Page, sourceDirectory: string) { body: { targetFolderId: null, paths: [['Denied'], ['Denied', '..']] }, expected: 400, }) + await json(path, { + method: 'POST', + body: { targetFolderId: null, paths: [['Denied'], ['Denied', 'x'.repeat(256)]] }, + expected: 400, + }) assert.equal( ( await sql`select id from folder where workspace_id = ${fixture.workspaceId} and name = 'Denied'` @@ -324,18 +356,6 @@ async function runUploadChecks(page: Page, sourceDirectory: string) { ) } -async function dropPaths(page: Page, paths: string[]) { - const session = await page.context().newCDPSession(page) - try { - const data = { items: [], files: paths, dragOperationsMask: 1 } - await session.send('Input.dispatchDragEvent', { type: 'dragEnter', x: 800, y: 700, data }) - await session.send('Input.dispatchDragEvent', { type: 'dragOver', x: 800, y: 700, data }) - await session.send('Input.dispatchDragEvent', { type: 'drop', x: 800, y: 700, data }) - } finally { - await session.detach() - } -} - async function runDropChecks(page: Page, sourceDirectory: string) { await check( 'native directory drops drain multiple reader pages and preserve empty folders', @@ -407,7 +427,7 @@ async function runDropChecks(page: Page, sourceDirectory: string) { await dropPaths(page, [paths[2]]) await dropPaths(page, [paths[3]]) await page.getByText('The upload queue is full', { exact: true }).waitFor() - await page.locator('button[aria-label="Upload"]').click() + await page.getByRole('button', { name: /^\d+\/\d+/ }).click() await page.getByRole('menuitem', { name: 'Cancel upload', exact: true }).click() release() await page.waitForFunction( @@ -453,6 +473,18 @@ async function runNavigationChecks(page: Page) { await page.goto(url(b), { waitUntil: 'domcontentloaded', timeout: 180_000 }) await page.getByRole('combobox', { name: 'Navigate within Navigation A', exact: true }).waitFor() + await check('the Files root picker searches and opens top-level folders', async () => { + await page.goto(new URL(`/workspace/${fixture.workspaceId}/files`, baseUrl).href, { + waitUntil: 'domcontentloaded', + }) + await page.getByRole('combobox', { name: 'Navigate within Files', exact: true }).click() + await page.getByPlaceholder('Find folder...').fill('Navigation A') + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await page.waitForURL(url(a)) + await page.goto(url(b), { waitUntil: 'domcontentloaded' }) + }) + await check('short breadcrumb labels remain fully visible at desktop width', async () => { await page.evaluate(() => document.fonts.ready.then(() => undefined)) const labels = [ @@ -500,7 +532,7 @@ async function runNavigationChecks(page: Page) { await check('breadcrumb hover leaves other header actions visually unchanged', async () => { await page.evaluate(() => document.fonts.ready.then(() => undefined)) - const upload = page.locator('button[aria-label="Upload"]') + const upload = page.getByRole('button', { name: 'Upload', exact: true }) await page.mouse.move(900, 400) const before = await upload.screenshot({ animations: 'disabled' }) await page.getByRole('button', { name: 'Files', exact: true }).first().hover() @@ -568,6 +600,53 @@ async function runNavigationChecks(page: Page) { } ) + await check('dragging from a deep folder exposes hidden ancestor drop targets', async () => { + const movedFolder = await createFolder('Move to ancestor', d) + await page.setViewportSize({ width: 900, height: 800 }) + await page.goto(url(d), { waitUntil: 'domcontentloaded' }) + const source = page.locator(`[data-row-id="folder:${movedFolder}"]`) + await source.waitFor() + const drag = await page.evaluateHandle(() => new DataTransfer()) + try { + await source.dispatchEvent('dragstart', { dataTransfer: drag }) + const target = page.getByRole('button', { name: 'Navigation B', exact: true }) + await target.waitFor({ state: 'visible', timeout: 5000 }) + const geometry = await target.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const hit = document.elementFromPoint( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ) + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + reachable: hit === element || (hit !== null && element.contains(hit)), + } + }) + await writeFile( + join(reportDirectory, 'ancestor-drop-geometry.json'), + `${JSON.stringify(geometry, null, 2)}\n` + ) + assert( + geometry.width > 0 && geometry.height > 0 && geometry.reachable, + 'The expanded ancestor must remain a reachable drop target at a constrained viewport' + ) + await target.dispatchEvent('dragover', { dataTransfer: drag }) + await target.dispatchEvent('drop', { dataTransfer: drag }) + await eventually( + () => sql`select parent_id from folder where id = ${movedFolder}`, + (rows) => rows[0]?.parent_id === b, + 'A drop onto a collapsed ancestor did not persist the move' + ) + } finally { + await page.evaluate(() => window.dispatchEvent(new DragEvent('dragend'))) + await drag.dispose() + await page.setViewportSize({ width: 1440, height: 1000 }) + } + }) + await check('deep folder trails collapse ancestors into a navigable overflow menu', async () => { await page.goto(url(d), { waitUntil: 'domcontentloaded' }) await page.getByRole('button', { name: '…', exact: true }).click() @@ -610,6 +689,7 @@ async function run() { json, cronSecret: process.env.FILES_E2E_CRON_SECRET, } + if (selectedSuites.includes('upload-lifecycle')) await runUploadLifecycleChecks(suite, scratch) if (selectedSuites.includes('navigation-scale')) await runNavigationScaleChecks(suite) if (selectedSuites.includes('search')) await runSearchWorkflowChecks(suite) if (selectedSuites.includes('sharing')) await runSharingChecks(suite) diff --git a/packages/testing/src/mocks/public-shares.mock.ts b/packages/testing/src/mocks/public-shares.mock.ts index df1725e6b5d..dac31d850d1 100644 --- a/packages/testing/src/mocks/public-shares.mock.ts +++ b/packages/testing/src/mocks/public-shares.mock.ts @@ -21,6 +21,8 @@ export class MockShareValidationError extends Error { * - `mockGetShareForResource` resolves `null` (no share). * - `mockGetSharesForResources` / `mockGetWorkspaceSharesForResources` / * `mockGetWorkspaceShares` resolve an empty `Map`. + * - `mockResolveActiveResourceShareByToken` adapts the legacy file resolver by default; + * override it directly for folder shares. * * @example * ```ts @@ -44,6 +46,10 @@ export const publicSharesMockFns = { mockUpsertFileShare: vi.fn(), mockUpsertResourceShare: vi.fn(), mockResolveActiveShareByToken: vi.fn(), + mockResolveActiveResourceShareByToken: vi.fn(async (token: string): Promise => { + const resolved = await publicSharesMockFns.mockResolveActiveShareByToken(token) + return resolved ? { ...resolved, kind: 'file' as const } : null + }), } /** @@ -65,8 +71,5 @@ export const publicSharesMock = { upsertFileShare: publicSharesMockFns.mockUpsertFileShare, resolveActiveShareByToken: publicSharesMockFns.mockResolveActiveShareByToken, upsertResourceShare: publicSharesMockFns.mockUpsertResourceShare, - resolveActiveResourceShareByToken: async (token: string) => { - const resolved = await publicSharesMockFns.mockResolveActiveShareByToken(token) - return resolved ? { ...resolved, kind: 'file' as const } : null - }, + resolveActiveResourceShareByToken: publicSharesMockFns.mockResolveActiveResourceShareByToken, } From 35958a8c2d3418f9535b20786b0b79d5a5f0cc86 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 19:16:01 -0700 Subject: [PATCH 3/6] fix(files): enforce live sharing checks and stabilize browser coverage --- .github/workflows/checks.yml | 6 +-- .../public/[token]/content/route.test.ts | 15 +++++- .../files/public/[token]/inline/route.test.ts | 15 +++++- .../api/files/public/[token]/route.test.ts | 17 +++++-- .../resource-header/resource-header.tsx | 2 +- apps/sim/lib/api/contracts/tools/file.ts | 13 +++-- apps/sim/lib/public-shares/folder-reader.ts | 16 +++--- .../lib/public-shares/folders.integration.ts | 49 +++++++++++++++++++ .../upload-folders.integration.ts | 3 +- apps/sim/scripts/files-e2e/browser.ts | 5 ++ apps/sim/scripts/files-e2e/index.ts | 2 +- .../sim/scripts/files-e2e/navigation-scale.ts | 5 ++ apps/sim/scripts/files-e2e/sharing.ts | 44 +++++++++-------- .../sim/scripts/files-e2e/upload-lifecycle.ts | 12 ++--- apps/sim/scripts/test-files-e2e.ts | 17 +++++-- 15 files changed, 168 insertions(+), 53 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f77e0176348..af91b70f005 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -203,11 +203,11 @@ jobs: - name: Cache Files browser download if: matrix.group == 'files' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + uses: ./.github/actions/cache with: + provider: ${{ vars.CI_PROVIDER }} path: ~/.cache/ms-playwright - key: files-browser-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} - restore-keys: files-browser-${{ runner.os }}-${{ runner.arch }}- + key: ${{ github.repository }}-files-browser-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} - name: Install Files browser from the pinned Playwright dependency if: matrix.group == 'files' diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts index 23f73184184..e880791fb67 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.test.ts @@ -1,4 +1,4 @@ -import { publicShare } from '@sim/db/schema' +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createRouteContext } from '@sim/testing/helpers/http' import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' @@ -43,7 +43,17 @@ const request = (token = 'tok_1') => createMockRequest({ url: `http://localhost/api/files/public/${token}/content` }) const passwordShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, + share: { + id: 'sh_1', + token: 'tok_1', + resourceId: 'wf_1', + workspaceId: 'ws-1', + resourceType: 'file', + isActive: true, + authType: 'password', + password: 'enc:secret', + allowedEmails: [], + }, file: { id: 'wf_1', key: 'workspace/ws/secret-key.pdf', @@ -63,6 +73,7 @@ describe('GET /api/files/public/[token]/content', () => { mockDownloadFile.mockResolvedValue(Buffer.from('data')) mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) queueTableRows(publicShare, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [passwordShare.file]) }) it('returns 401 and never reads storage when a password share is unauthorized', async () => { diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts index 088defc2064..50016210751 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts @@ -1,4 +1,4 @@ -import { publicShare } from '@sim/db/schema' +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createRouteContext } from '@sim/testing/helpers/http' import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' @@ -40,7 +40,17 @@ const req = (q: string) => createMockRequest({ url: `http://localhost/api/files/public/${TOKEN}/inline?${q}` }) const share = { - share: { id: 'sh_1', token: TOKEN, authType: 'public' }, + share: { + id: 'sh_1', + token: TOKEN, + resourceId: 'wf_doc', + workspaceId: 'ws-1', + resourceType: 'file', + isActive: true, + authType: 'public', + password: null, + allowedEmails: [], + }, file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, workspaceName: 'Acme', ownerName: 'Jane', @@ -64,6 +74,7 @@ describe('GET /api/files/public/[token]/inline', () => { }) mockDownloadFile.mockImplementation(downloadByKey()) queueTableRows(publicShare, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [share.file]) }) it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts index c43685bd1f0..4c4503bdc52 100644 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/route.test.ts @@ -1,4 +1,4 @@ -import { publicShare as publicShareTable } from '@sim/db/schema' +import { publicShare as publicShareTable, workspaceFiles } from '@sim/db/schema' import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' import { NextRequest } from 'next/server' @@ -40,7 +40,17 @@ const postRequest = (password: string, token = 'tok_1') => }) const publicShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, + share: { + id: 'sh_1', + token: 'tok_1', + resourceId: 'wf_1', + workspaceId: 'ws-secret', + resourceType: 'file', + isActive: true, + authType: 'public', + password: null, + allowedEmails: [], + }, file: { id: 'wf_1', key: 'workspace/ws/secret-key.pdf', @@ -55,7 +65,7 @@ const publicShare = { const passwordShare = { ...publicShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, + share: { ...publicShare.share, authType: 'password', password: 'enc:secret' }, } describe('GET /api/files/public/[token]', () => { @@ -63,6 +73,7 @@ describe('GET /api/files/public/[token]', () => { mockEnforceRateLimit.mockResolvedValue(null) // allow by default mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default queueTableRows(publicShareTable, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [publicShare.file]) }) it('returns 429 when the per-IP rate limit is exceeded', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 06b55c0c4a5..fbce160646d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -264,7 +264,7 @@ export const ResourceHeader = memo(function ResourceHeader({ drag={crumbDrag} /> )} - {crumb.navigationItems !== undefined && ( + {crumb.navigationItems !== undefined && !crumb.editing?.isEditing && ( { if (!(await shareIsCurrent(resolved))) return null - if (resolved.kind === 'file') { - return fileId === undefined || fileId === resolved.file.id ? resolved.file : null - } - if (!fileId) return null + const targetId = resolved.kind === 'file' ? resolved.share.resourceId : fileId + if (!targetId || (resolved.kind === 'file' && fileId !== undefined && fileId !== targetId)) + return null const [file] = await db .select() .from(workspaceFiles) .where( and( - eq(workspaceFiles.id, fileId), + eq(workspaceFiles.id, targetId), eq(workspaceFiles.workspaceId, resolved.share.workspaceId), eq(workspaceFiles.context, 'workspace'), isNull(workspaceFiles.deletedAt) ) ) .limit(1) - if (!file?.folderId || !(await folderBreadcrumbs(resolved, file.folderId))) return null + if (!file) return null + if ( + resolved.kind === 'folder' && + (!file.folderId || !(await folderBreadcrumbs(resolved, file.folderId))) + ) + return null return file } diff --git a/apps/sim/lib/public-shares/folders.integration.ts b/apps/sim/lib/public-shares/folders.integration.ts index 35064d3b9f6..92a602c6013 100644 --- a/apps/sim/lib/public-shares/folders.integration.ts +++ b/apps/sim/lib/public-shares/folders.integration.ts @@ -228,4 +228,53 @@ describe('public folder capabilities in PostgreSQL', () => { .where(eq(publicShare.id, directShareId)) } }) + + it.each([ + { name: 'deleted', update: { deletedAt: new Date() } }, + { name: 'moved out of workspace storage', update: { context: 'chat' } }, + { + name: 'moved to another workspace', + update: { workspaceId: otherWorkspaceId, folderId: null }, + }, + ])('denies a direct file that is $name during authorization', async ({ update }) => { + try { + await expect( + readPublicSharedFile({ + token: directToken, + authorize: async () => { + await db.update(workspaceFiles).set(update).where(eq(workspaceFiles.id, fileId)) + return { authorized: true } + }, + }) + ).rejects.toMatchObject({ status: 404 }) + } finally { + await db + .update(workspaceFiles) + .set({ deletedAt: null, context: 'workspace', workspaceId, folderId: childId }) + .where(eq(workspaceFiles.id, fileId)) + } + }) + + it('reads the current direct file metadata and storage key after authorization', async () => { + const updatedKey = `workspace/${workspaceId}/${generateId()}` + try { + const result = await readPublicSharedFile({ + token: directToken, + authorize: async () => { + await db + .update(workspaceFiles) + .set({ originalName: 'updated.txt', key: updatedKey }) + .where(eq(workspaceFiles.id, fileId)) + return { authorized: true } + }, + }) + expect(result.file.originalName).toBe('updated.txt') + expect(result.file.key).toBe(updatedKey) + } finally { + await db + .update(workspaceFiles) + .set({ originalName: 'nested.txt', key: `workspace/${workspaceId}/${fileId}` }) + .where(eq(workspaceFiles.id, fileId)) + } + }) }) diff --git a/apps/sim/lib/workspace-files/upload-folders.integration.ts b/apps/sim/lib/workspace-files/upload-folders.integration.ts index d2678a1ae89..b49d93e0b19 100644 --- a/apps/sim/lib/workspace-files/upload-folders.integration.ts +++ b/apps/sim/lib/workspace-files/upload-folders.integration.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' import { eq, sql } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { prepareUploadFolders } from '@/lib/workspace-files/upload-folders' const connection = vi.hoisted(() => ({ close: async () => {} })) @@ -125,7 +126,7 @@ describe('folder upload preparation in Postgres', () => { await db.execute(sql` INSERT INTO folder (id, workspace_id, user_id, resource_type, name) SELECT 'existing-' || position, 'workspace', 'user', 'file', 'Existing ' || position - FROM generate_series(1, 9999) AS series(position) + FROM generate_series(1, ${MAX_FOLDERS_PER_WORKSPACE - 1}) AS series(position) `) await expect(prepare([['Incoming'], ['Incoming', 'Child']])).rejects.toMatchObject({ code: 'conflict', diff --git a/apps/sim/scripts/files-e2e/browser.ts b/apps/sim/scripts/files-e2e/browser.ts index 629caaa6b6c..c527361cf01 100644 --- a/apps/sim/scripts/files-e2e/browser.ts +++ b/apps/sim/scripts/files-e2e/browser.ts @@ -12,3 +12,8 @@ export async function dropPaths(page: Page, paths: string[]) { await session.detach() } } + +/** Locates the upload menu by its accessible state without matching the empty-state upload action. */ +export function uploadMenuTrigger(page: Page, name: string | RegExp = 'Upload') { + return page.getByRole('button', { name, exact: true }).and(page.locator('[aria-haspopup="menu"]')) +} diff --git a/apps/sim/scripts/files-e2e/index.ts b/apps/sim/scripts/files-e2e/index.ts index 02b6e70e866..ce6ddd423f6 100644 --- a/apps/sim/scripts/files-e2e/index.ts +++ b/apps/sim/scripts/files-e2e/index.ts @@ -1,4 +1,4 @@ -export { dropPaths } from './browser' +export { dropPaths, uploadMenuTrigger } from './browser' export { runNavigationScaleChecks } from './navigation-scale' export { runSearchWorkflowChecks } from './search-workflows' export { runSharingChecks } from './sharing' diff --git a/apps/sim/scripts/files-e2e/navigation-scale.ts b/apps/sim/scripts/files-e2e/navigation-scale.ts index 3281d647f6a..dc77f2f125c 100644 --- a/apps/sim/scripts/files-e2e/navigation-scale.ts +++ b/apps/sim/scripts/files-e2e/navigation-scale.ts @@ -60,6 +60,11 @@ export async function runNavigationScaleChecks(context: FilesE2EContext) { assert(rename, 'The current folder must expose its inline rename input') const renamed = `${name} renamed` await rename.fill(renamed) + assert.equal( + await picker(name).count(), + 0, + 'Folder actions must remain unavailable while a rename draft is being edited' + ) await rename.press('Enter') const deadline = Date.now() + 60_000 while (true) { diff --git a/apps/sim/scripts/files-e2e/sharing.ts b/apps/sim/scripts/files-e2e/sharing.ts index b4764e06866..f5cb5811cb1 100644 --- a/apps/sim/scripts/files-e2e/sharing.ts +++ b/apps/sim/scripts/files-e2e/sharing.ts @@ -234,7 +234,7 @@ export async function runSharingChecks({ }) await check( - 'folder sharing tools reject oversized email restrictions without mutating policy', + 'folder sharing tools reject invalid policies without mutating existing shares', async () => { const headers = { 'x-api-key': personalKey } await json(`/api/v2/files?workspaceId=${fixture.workspaceId}&limit=1`, { @@ -242,26 +242,30 @@ export async function runSharingChecks({ headers, }) const before = await sql`select * from public_share where id = ${shareId}` - const result = await json('/api/v2/tools/file_manage_folder_sharing/execute', { - method: 'POST', - authenticated: false, - headers, - body: { - workspaceId: fixture.workspaceId, - input: { - path: '/Sharing%20fixture', - isActive: true, - authType: 'email', - allowedEmails: [`${'a'.repeat(308)}@fixture.test`], + for (const policy of [ + { authType: 'email', allowedEmails: [`${'a'.repeat(308)}@fixture.test`] }, + { authType: 'password', password: 'short-password' }, + ]) { + const result = await json('/api/v2/tools/file_manage_folder_sharing/execute', { + method: 'POST', + authenticated: false, + headers, + body: { + workspaceId: fixture.workspaceId, + input: { + path: '/Sharing%20fixture', + isActive: true, + ...policy, + }, }, - }, - }) - assert(isRecordLike(result.data)) - assert.equal(result.data.status, 'failed') - assert(isRecordLike(result.data.output)) - assert.equal(result.data.output.status, 400) - const after = await sql`select * from public_share where id = ${shareId}` - assert.deepEqual(after, before, 'Rejected input changed the stored sharing policy') + }) + assert(isRecordLike(result.data)) + assert.equal(result.data.status, 'failed', `${policy.authType} policy was accepted`) + assert(isRecordLike(result.data.output)) + assert.equal(result.data.output.status, 400) + const after = await sql`select * from public_share where id = ${shareId}` + assert.deepEqual(after, before, 'Rejected input changed the stored sharing policy') + } } ) diff --git a/apps/sim/scripts/files-e2e/upload-lifecycle.ts b/apps/sim/scripts/files-e2e/upload-lifecycle.ts index b494f26d2b8..416776ad475 100644 --- a/apps/sim/scripts/files-e2e/upload-lifecycle.ts +++ b/apps/sim/scripts/files-e2e/upload-lifecycle.ts @@ -3,7 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { dropPaths } from '@/scripts/files-e2e/browser' +import { dropPaths, uploadMenuTrigger } from '@/scripts/files-e2e/browser' import type { FilesE2EContext } from '@/scripts/files-e2e/types' /** Exercises cancellation while native readers and real upload requests are in flight. */ @@ -13,7 +13,7 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo waitUntil: 'domcontentloaded', }) await page.locator('input[webkitdirectory]').waitFor({ state: 'attached' }) - await page.getByRole('button', { name: 'Upload', exact: true }).waitFor() + await uploadMenuTrigger(page).waitFor() await check( 'cancel releases a delayed native directory read and permits a new upload', @@ -62,9 +62,9 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo assert(Date.now() < deadline, 'The native directory read never reached the held callback') await sleep(50) } - await page.getByRole('button', { name: 'Preparing…', exact: true }).click() + await uploadMenuTrigger(page, 'Preparing…').click() await page.getByRole('menuitem', { name: 'Cancel upload', exact: true }).click() - await page.getByRole('button', { name: 'Upload', exact: true }).waitFor({ timeout: 3000 }) + await uploadMenuTrigger(page).waitFor({ timeout: 3000 }) await dropPaths(page, [recovery]) const uploaded = Date.now() + 120_000 while (true) { @@ -119,7 +119,7 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo await route.continue().catch(() => {}) }) try { - await page.getByRole('button', { name: 'Upload', exact: true }).waitFor() + await uploadMenuTrigger(page).waitFor() await dropPaths(page, paths.slice(0, 2)) const deadline = Date.now() + 30_000 while (requests === 0) { @@ -144,7 +144,7 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo ) ) release() - await page.getByRole('button', { name: 'Upload', exact: true }).waitFor({ timeout: 5000 }) + await uploadMenuTrigger(page).waitFor({ timeout: 5000 }) assert.equal(requests, 1, 'The batch issued more uploads after edit access was revoked') const rows = await sql`select id from workspace_files where workspace_id = ${fixture.workspaceId} diff --git a/apps/sim/scripts/test-files-e2e.ts b/apps/sim/scripts/test-files-e2e.ts index 2c461cc65fb..8cfa63538c0 100644 --- a/apps/sim/scripts/test-files-e2e.ts +++ b/apps/sim/scripts/test-files-e2e.ts @@ -21,6 +21,7 @@ import { runSearchWorkflowChecks, runSharingChecks, runUploadLifecycleChecks, + uploadMenuTrigger, } from '@/scripts/files-e2e' const logger = createLogger('FilesE2E') @@ -205,7 +206,7 @@ async function uploadDirectory( await page.waitForFunction( () => !document.querySelector('input[webkitdirectory]')?.disabled ) - await page.getByRole('button', { name: 'Upload', exact: true }).click() + await uploadMenuTrigger(page).click() const [chooser] = await Promise.all([ page.waitForEvent('filechooser'), page.getByRole('menuitem', { name: 'Upload folder', exact: true }).click(), @@ -427,7 +428,7 @@ async function runDropChecks(page: Page, sourceDirectory: string) { await dropPaths(page, [paths[2]]) await dropPaths(page, [paths[3]]) await page.getByText('The upload queue is full', { exact: true }).waitFor() - await page.getByRole('button', { name: /^\d+\/\d+/ }).click() + await uploadMenuTrigger(page, /^\d+\/\d+/).click() await page.getByRole('menuitem', { name: 'Cancel upload', exact: true }).click() release() await page.waitForFunction( @@ -532,7 +533,7 @@ async function runNavigationChecks(page: Page) { await check('breadcrumb hover leaves other header actions visually unchanged', async () => { await page.evaluate(() => document.fonts.ready.then(() => undefined)) - const upload = page.getByRole('button', { name: 'Upload', exact: true }) + const upload = uploadMenuTrigger(page) await page.mouse.move(900, 400) const before = await upload.screenshot({ animations: 'disabled' }) await page.getByRole('button', { name: 'Files', exact: true }).first().hover() @@ -558,7 +559,10 @@ async function runNavigationChecks(page: Page) { Awaited['boundingBox']>> >() for (const name of names) { - const action = page.getByRole('button', { name, exact: true }) + const action = + name === 'Upload' + ? uploadMenuTrigger(page) + : page.getByRole('button', { name, exact: true }) await action.evaluate( (element, marker) => element.setAttribute('data-files-e2e-identity', marker), name @@ -568,7 +572,10 @@ async function runNavigationChecks(page: Page) { await page.getByRole('button', { name: 'Navigation A', exact: true }).click() await page.waitForURL(url(a)) for (const name of names) { - const action = page.getByRole('button', { name, exact: true }) + const action = + name === 'Upload' + ? uploadMenuTrigger(page) + : page.getByRole('button', { name, exact: true }) assert.equal( await action.getAttribute('data-files-e2e-identity'), name, From 9beab14574573c3742b47611204d1cace1dfafb6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 19:29:34 -0700 Subject: [PATCH 4/6] fix(files): await browser readiness and guard sharing fixture cleanup --- apps/sim/scripts/files-e2e/sharing.ts | 44 ++++++++++--------- .../sim/scripts/files-e2e/upload-lifecycle.ts | 2 +- apps/sim/scripts/test-files-e2e.ts | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/sim/scripts/files-e2e/sharing.ts b/apps/sim/scripts/files-e2e/sharing.ts index f5cb5811cb1..a5b18812984 100644 --- a/apps/sim/scripts/files-e2e/sharing.ts +++ b/apps/sim/scripts/files-e2e/sharing.ts @@ -96,24 +96,26 @@ export async function runSharingChecks({ const browser = page.context().browser() assert(browser) const anonymous = await browser.newContext({ viewport: { width: 1200, height: 900 } }) - await anonymous.tracing.start({ screenshots: true, snapshots: true, sources: false }) - const publicGet = (path: string) => - anonymous.request.get(new URL(path, baseUrl).href, { timeout: 180_000 }) - const visitor = await anonymous.newPage() - visitor.setDefaultTimeout(60_000) - visitor.setDefaultNavigationTimeout(180_000) - const browserErrors: string[] = [] - visitor.on('pageerror', (error) => browserErrors.push(error.message)) - let token = '' - let shareId = '' - const publicPath = () => `/api/files/public/${token}` - const folderPath = () => `${publicPath()}/folder` - const contentPath = (fileId: string) => `${publicPath()}/content?fileId=${fileId}` - const anonymousJson = (path: string, expected = 200) => - json(path, { authenticated: false, expected }) - const updateShare = (body: Record) => json(sharePath, { method: 'PUT', body }) - + let tracingStarted = false try { + await anonymous.tracing.start({ screenshots: true, snapshots: true, sources: false }) + tracingStarted = true + const publicGet = (path: string) => + anonymous.request.get(new URL(path, baseUrl).href, { timeout: 180_000 }) + const visitor = await anonymous.newPage() + visitor.setDefaultTimeout(60_000) + visitor.setDefaultNavigationTimeout(180_000) + const browserErrors: string[] = [] + visitor.on('pageerror', (error) => browserErrors.push(error.message)) + let token = '' + let shareId = '' + const publicPath = () => `/api/files/public/${token}` + const folderPath = () => `${publicPath()}/folder` + const contentPath = (fileId: string) => `${publicPath()}/content?fileId=${fileId}` + const anonymousJson = (path: string, expected = 200) => + json(path, { authenticated: false, expected }) + const updateShare = (body: Record) => json(sharePath, { method: 'PUT', body }) + await sql.begin(async (tx) => { const readerEmail = `files-reader-${readerId}@files-e2e.test` await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at) @@ -613,9 +615,11 @@ export async function runSharingChecks({ ) } finally { try { - await anonymous.tracing.stop({ - path: join(reportDirectory, 'public-folder-browser-trace.zip'), - }) + if (tracingStarted) { + await anonymous.tracing.stop({ + path: join(reportDirectory, 'public-folder-browser-trace.zip'), + }) + } } finally { try { await anonymous.close() diff --git a/apps/sim/scripts/files-e2e/upload-lifecycle.ts b/apps/sim/scripts/files-e2e/upload-lifecycle.ts index 416776ad475..c366374f7b1 100644 --- a/apps/sim/scripts/files-e2e/upload-lifecycle.ts +++ b/apps/sim/scripts/files-e2e/upload-lifecycle.ts @@ -13,7 +13,7 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo waitUntil: 'domcontentloaded', }) await page.locator('input[webkitdirectory]').waitFor({ state: 'attached' }) - await uploadMenuTrigger(page).waitFor() + await uploadMenuTrigger(page).and(page.locator(':enabled')).waitFor() await check( 'cancel releases a delayed native directory read and permits a new upload', diff --git a/apps/sim/scripts/test-files-e2e.ts b/apps/sim/scripts/test-files-e2e.ts index 8cfa63538c0..5163e5ca34c 100644 --- a/apps/sim/scripts/test-files-e2e.ts +++ b/apps/sim/scripts/test-files-e2e.ts @@ -611,7 +611,7 @@ async function runNavigationChecks(page: Page) { const movedFolder = await createFolder('Move to ancestor', d) await page.setViewportSize({ width: 900, height: 800 }) await page.goto(url(d), { waitUntil: 'domcontentloaded' }) - const source = page.locator(`[data-row-id="folder:${movedFolder}"]`) + const source = page.locator(`[data-row-id="folder:${movedFolder}"][draggable="true"]`) await source.waitFor() const drag = await page.evaluateHandle(() => new DataTransfer()) try { From fd395b1fbb973789934beb0015d121b7caf0aba1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 20:30:14 -0700 Subject: [PATCH 5/6] fix(files): instrument native directory readers in browser checks --- .../sim/scripts/files-e2e/upload-lifecycle.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/sim/scripts/files-e2e/upload-lifecycle.ts b/apps/sim/scripts/files-e2e/upload-lifecycle.ts index c366374f7b1..a3d9f9ee3c9 100644 --- a/apps/sim/scripts/files-e2e/upload-lifecycle.ts +++ b/apps/sim/scripts/files-e2e/upload-lifecycle.ts @@ -24,21 +24,36 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo const recovery = join(directory, 'after-reader-cancel.txt') await writeFile(recovery, 'Reader cancellation recovered\n') const reader = await page.evaluateHandle(() => { - const original = FileSystemDirectoryReader.prototype.readEntries const pending: (() => void)[] = [] let calls = 0 let released = false - FileSystemDirectoryReader.prototype.readEntries = function (success, failure) { - calls += 1 - original.call( - this, - (entries) => { - if (released) success(entries) - else pending.push(() => success(entries)) - }, - failure - ) + let prototype: FileSystemDirectoryReader | undefined + let original: FileSystemDirectoryReader['readEntries'] | undefined + const interceptDrop = (event: DragEvent) => { + if (prototype) return + for (const item of Array.from(event.dataTransfer?.items ?? [])) { + const entry = item.webkitGetAsEntry() + if (!entry?.isDirectory) continue + const nativeReader = (entry as FileSystemDirectoryEntry).createReader() + const readerPrototype = Object.getPrototypeOf(nativeReader) as FileSystemDirectoryReader + const readEntries = readerPrototype.readEntries + prototype = readerPrototype + original = readEntries + readerPrototype.readEntries = function (success, failure) { + calls += 1 + readEntries.call( + this, + (entries) => { + if (released) success(entries) + else pending.push(() => success(entries)) + }, + failure + ) + } + break + } } + window.addEventListener('drop', interceptDrop, true) return { get calls() { return calls @@ -51,7 +66,8 @@ export async function runUploadLifecycleChecks(context: FilesE2EContext, directo for (const finish of pending.splice(0)) finish() }, restore() { - FileSystemDirectoryReader.prototype.readEntries = original + window.removeEventListener('drop', interceptDrop, true) + if (prototype && original) prototype.readEntries = original }, } }) From 10169828697bd2581defc55e342d054a29feadf3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 20:40:21 -0700 Subject: [PATCH 6/6] fix(files): capture header baseline after permissions settle --- apps/sim/scripts/test-files-e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/scripts/test-files-e2e.ts b/apps/sim/scripts/test-files-e2e.ts index 5163e5ca34c..d8e4bdb5361 100644 --- a/apps/sim/scripts/test-files-e2e.ts +++ b/apps/sim/scripts/test-files-e2e.ts @@ -534,6 +534,7 @@ async function runNavigationChecks(page: Page) { await check('breadcrumb hover leaves other header actions visually unchanged', async () => { await page.evaluate(() => document.fonts.ready.then(() => undefined)) const upload = uploadMenuTrigger(page) + await upload.and(page.locator(':enabled')).waitFor() await page.mouse.move(900, 400) const before = await upload.screenshot({ animations: 'disabled' }) await page.getByRole('button', { name: 'Files', exact: true }).first().hover()