Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/scripts/http-e2e.sh
Original file line number Diff line number Diff line change
@@ -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 <scim|cli|stop-after|desktop-inbox> (run from apps/sim)
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|files> (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.
Expand All @@ -17,7 +17,7 @@
# telemetry flush runs detached and still writes .next/dev).
set -euo pipefail

group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox>}
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|files>}
report_dir="$RUNNER_TEMP/e2e"
ready_timeout_seconds=300
mkdir -p "$report_dir"
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand All @@ -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
Expand Down Expand Up @@ -199,6 +201,18 @@ jobs:
working-directory: packages/db
run: bun run db:migrate

- name: Cache Files browser download
if: matrix.group == 'files'
uses: ./.github/actions/cache
with:
provider: ${{ vars.CI_PROVIDER }}
path: ~/.cache/ms-playwright
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'
run: node node_modules/@playwright/test/cli.js install --with-deps chromium
Comment thread
waleedlatif1 marked this conversation as resolved.

# 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
Expand Down
26 changes: 25 additions & 1 deletion apps/docs/content/docs/integrations/file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion apps/sim/app/api/files/public/[token]/content/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
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'
import { createMockRequest } from '@sim/testing/mocks/request.mock'
import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
Expand Down Expand Up @@ -41,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',
Expand All @@ -60,6 +72,8 @@ describe('GET /api/files/public/[token]/content', () => {
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
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 () => {
Expand Down
29 changes: 13 additions & 16 deletions apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions apps/sim/app/api/files/public/[token]/folder/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
)
16 changes: 15 additions & 1 deletion apps/sim/app/api/files/public/[token]/inline/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
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'
import { createMockRequest } from '@sim/testing/mocks/request.mock'
import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
Expand Down Expand Up @@ -38,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',
Expand All @@ -61,6 +73,8 @@ describe('GET /api/files/public/[token]/inline', () => {
filename: 'photo.png',
})
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 () => {
Expand Down
30 changes: 14 additions & 16 deletions apps/sim/app/api/files/public/[token]/inline/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/files/public/[token]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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 })
}
Expand Down
Loading
Loading