Skip to content

Commit 0cdff92

Browse files
committed
feat(files): add folder uploads, content search, and sharing
1 parent cf61888 commit 0cdff92

82 files changed

Lines changed: 4874 additions & 591 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/http-e2e.sh‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bash
22
# Runs one end-to-end suite group over real HTTP, each against its own `next dev` app.
33
#
4-
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox> (run from apps/sim)
4+
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|files> (run from apps/sim)
55
#
66
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
77
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
@@ -17,7 +17,7 @@
1717
# telemetry flush runs detached and still writes .next/dev).
1818
set -euo pipefail
1919

20-
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox>}
20+
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|files>}
2121
report_dir="$RUNNER_TEMP/e2e"
2222
ready_timeout_seconds=300
2323
mkdir -p "$report_dir"
@@ -75,6 +75,20 @@ start_app() {
7575
}
7676

7777
case "$group" in
78+
files)
79+
export NEXT_PUBLIC_FORCE_HOSTED=false STORAGE_PROVIDER=local
80+
export NEXT_PUBLIC_CHAT_DISABLED=true
81+
export INTERNAL_API_SECRET=files-http-ci-local-secret-at-least-32-characters
82+
export CRON_SECRET=files-e2e-cron-secret-at-least-32-characters
83+
start_app files 3027 Files record-http-status
84+
FILES_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
85+
FILES_E2E_DATABASE_URL="$DATABASE_URL" \
86+
FILES_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
87+
FILES_E2E_CRON_SECRET="$CRON_SECRET" \
88+
FILES_E2E_REPORT_PATH="$report_dir/files-e2e-report.json" \
89+
bun --no-env-file scripts/test-files-e2e.ts
90+
;;
91+
7892
scim)
7993
export NEXT_PUBLIC_FORCE_HOSTED=true
8094
export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true

‎.github/workflows/checks.yml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ jobs:
138138
# The suites exercise HTTP behavior rather than a provisioning path, so they run once, against
139139
# the production (migrate) path. Each group's app environment lives in http-e2e.sh.
140140
#
141-
# SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU
142-
# runner; the others boot a smaller self-hosted app and fit on 4.
141+
# SCIM's hosted app and the Files browser suite use 8 vCPU runners; the smaller self-hosted
142+
# suites fit on 4.
143143
e2e:
144144
name: e2e (${{ matrix.group }})
145145
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
@@ -156,6 +156,8 @@ jobs:
156156
runner: blacksmith-4vcpu-ubuntu-2404
157157
- group: desktop-inbox
158158
runner: blacksmith-4vcpu-ubuntu-2404
159+
- group: files
160+
runner: blacksmith-8vcpu-ubuntu-2404
159161
services:
160162
postgres:
161163
image: pgvector/pgvector:pg17
@@ -199,6 +201,10 @@ jobs:
199201
working-directory: packages/db
200202
run: bun run db:migrate
201203

204+
- name: Install Files browser from the pinned Playwright dependency
205+
if: matrix.group == 'files'
206+
run: node node_modules/@playwright/test/cli.js install --with-deps chromium
207+
202208
# No step timeout: the job's bound covers a hang without cutting a slow but healthy suite
203209
# short of writing its report.
204210
- name: Run end-to-end suites

‎apps/docs/content/docs/integrations/file.mdx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ In Sim, the File block allows your agents to search, read, and extract text from
2828

2929
## Usage Instructions
3030

31-
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.
31+
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.
3232

3333

3434

@@ -278,6 +278,30 @@ Enable or disable the public share link for a workspace file, and set its access
278278
| `hasPassword` | boolean | Whether the share is password-protected |
279279
| `allowedEmails` | array | Allowed emails/domains for email or SSO access |
280280

281+
### Manage Folder Sharing
282+
283+
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.
284+
285+
#### Input
286+
287+
| Parameter | Type | Required | Description |
288+
| --------- | ---- | -------- | ----------- |
289+
| `path` | string | Yes | Canonical percent-encoded folder path, e.g. "/Reports/Q3%20Results". The workspace root cannot be shared. |
290+
| `isActive` | boolean | Yes | Whether the public folder link is enabled. Set false to disable access. |
291+
| `authType` | string | No | Access mode: "public", "password", "email", or "sso". Defaults to "public". |
292+
| `password` | string | No | Password to protect the link. Required when authType is "password". |
293+
| `allowedEmails` | array | No | Allowed emails or "@domain" patterns for email or SSO access. |
294+
295+
#### Output
296+
297+
| Parameter | Type | Description |
298+
| --------- | ---- | ----------- |
299+
| `url` | string | Public folder URL; empty when the link is disabled |
300+
| `isActive` | boolean | Whether the public folder link is enabled |
301+
| `authType` | string | Access mode: public, password, email, or sso |
302+
| `hasPassword` | boolean | Whether the link is password-protected |
303+
| `allowedEmails` | array | Allowed emails/domains for email or SSO access |
304+
281305
### List Files and Folders
282306

283307
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.

‎apps/sim/app/api/files/public/[token]/content/route.ts‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
99
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { resolveServableDoc } from '@/lib/mothership/tools/server/files/doc-compile'
12+
import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access'
1213
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
13-
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1414
import { downloadFile } from '@/lib/uploads/core/storage-service'
1515
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
1616
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
@@ -54,21 +54,11 @@ export const GET = withRouteHandler(
5454
const { token } = parsed.data.params
5555
const preview = parsed.data.query.preview === '1'
5656

57-
const resolved = await resolveActiveShareByToken(token)
58-
if (!resolved) {
59-
throw new FileNotFoundError('Not found')
60-
}
61-
62-
const auth = await validateDeploymentAuth(
63-
requestId,
64-
resolved.share,
65-
request,
66-
undefined,
67-
'file'
68-
)
69-
if (!auth.authorized) {
70-
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
71-
}
57+
const resolved = await readPublicSharedFile({
58+
token,
59+
fileId: parsed.data.query.fileId,
60+
authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'),
61+
})
7262

7363
const { file } = resolved
7464
// The same ceiling the authenticated serve route reads this object under
@@ -161,6 +151,13 @@ export const GET = withRouteHandler(
161151
cacheControl: 'private, no-cache, must-revalidate',
162152
})
163153
} catch (error) {
154+
if (error instanceof PublicShareAccessError) {
155+
if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found'))
156+
return NextResponse.json(
157+
{ error: error.message },
158+
{ status: error.status, headers: { 'Cache-Control': 'private, no-store' } }
159+
)
160+
}
164161
logger.error('Error serving public shared file:', error)
165162
if (error instanceof FileNotFoundError) {
166163
return createErrorResponse(error)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { getPublicFolderContract } from '@/lib/api/contracts/public-shares'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { PublicShareAccessError, readPublicSharedFolder } from '@/lib/public-shares/access'
10+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
11+
12+
export const dynamic = 'force-dynamic'
13+
14+
/** Public capabilities use the share authentication protocol rather than workspace membership. */
15+
export const GET = withRouteHandler(
16+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
17+
const headers = { 'Cache-Control': 'private, no-store' }
18+
const limited = await enforcePublicFileRateLimit(request, 'metadata')
19+
if (limited) {
20+
limited.headers.set('Cache-Control', headers['Cache-Control'])
21+
return limited
22+
}
23+
const parsed = await parseRequest(getPublicFolderContract, request, context)
24+
if (!parsed.success) {
25+
parsed.response.headers.set('Cache-Control', headers['Cache-Control'])
26+
return parsed.response
27+
}
28+
try {
29+
const page = await readPublicSharedFolder({
30+
token: parsed.data.params.token,
31+
...parsed.data.query,
32+
authorize: (share) =>
33+
validateDeploymentAuth(generateRequestId(), share, request, undefined, 'file'),
34+
})
35+
return NextResponse.json(page, { headers })
36+
} catch (error) {
37+
if (error instanceof PublicShareAccessError) {
38+
return NextResponse.json({ error: error.message }, { status: error.status, headers })
39+
}
40+
if (error instanceof OrchestrationError && error.code === 'validation') {
41+
return NextResponse.json({ error: error.message }, { status: 400, headers })
42+
}
43+
throw error
44+
}
45+
}
46+
)

‎apps/sim/app/api/files/public/[token]/inline/route.ts‎

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access'
1112
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
12-
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1313
import { downloadFile } from '@/lib/uploads/core/storage-service'
1414
import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs'
1515
import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image'
@@ -61,21 +61,11 @@ export const GET = withRouteHandler(
6161
const { token } = parsed.data.params
6262
const ref = parsed.data.query
6363

64-
const resolved = await resolveActiveShareByToken(token)
65-
if (!resolved) {
66-
throw new FileNotFoundError('Not found')
67-
}
68-
69-
const auth = await validateDeploymentAuth(
70-
requestId,
71-
resolved.share,
72-
request,
73-
undefined,
74-
'file'
75-
)
76-
if (!auth.authorized) {
77-
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
78-
}
64+
const resolved = await readPublicSharedFile({
65+
token,
66+
fileId: parsed.data.query.documentId,
67+
authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'),
68+
})
7969

8070
const { file: doc } = resolved
8171
if (!doc.workspaceId) {
@@ -129,8 +119,16 @@ export const GET = withRouteHandler(
129119
request,
130120
})
131121

122+
response.headers.set('Cache-Control', 'private, no-store')
132123
return response
133124
} catch (error) {
125+
if (error instanceof PublicShareAccessError) {
126+
if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found'))
127+
return NextResponse.json(
128+
{ error: error.message },
129+
{ status: error.status, headers: { 'Cache-Control': 'private, no-store' } }
130+
)
131+
}
134132
if (error instanceof FileNotFoundError) {
135133
return createErrorResponse(error)
136134
}

‎apps/sim/app/api/files/public/[token]/otp/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { afterResponse } from '@/lib/core/utils/after-response'
2626
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
2727
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2828
import { sendEmail } from '@/lib/messaging/email/mailer'
29-
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
29+
import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager'
3030

3131
export const dynamic = 'force-dynamic'
3232

@@ -116,7 +116,7 @@ export const POST = withRouteHandler(
116116
// all key off the same value (allow-list entries are stored lowercase).
117117
const email = normalizeEmail(parsed.data.body.email)
118118

119-
const resolved = await resolveActiveShareByToken(token)
119+
const resolved = await resolveActiveResourceShareByToken(token)
120120
if (!resolved) {
121121
return NextResponse.json({ error: 'Not found' }, { status: 404 })
122122
}
@@ -155,7 +155,7 @@ export const PUT = withRouteHandler(
155155
const { otp } = parsed.data.body
156156
const email = normalizeEmail(parsed.data.body.email)
157157

158-
const resolved = await resolveActiveShareByToken(token)
158+
const resolved = await resolveActiveResourceShareByToken(token)
159159
if (!resolved) {
160160
return NextResponse.json({ error: 'Not found' }, { status: 404 })
161161
}

‎apps/sim/app/api/files/public/[token]/route.ts‎

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
1111
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access'
1415
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
15-
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
16+
import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager'
1617
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
1718

1819
export const dynamic = 'force-dynamic'
@@ -38,32 +39,33 @@ export const GET = withRouteHandler(
3839
if (!parsed.success) return parsed.response
3940
const { token } = parsed.data.params
4041

41-
const resolved = await resolveActiveShareByToken(token)
42-
if (!resolved) {
43-
return NextResponse.json({ error: 'Not found' }, { status: 404 })
44-
}
45-
46-
const auth = await validateDeploymentAuth(
47-
requestId,
48-
resolved.share,
49-
request,
50-
undefined,
51-
'file'
52-
)
53-
if (!auth.authorized) {
54-
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
55-
}
56-
57-
const { file, workspaceName, ownerName } = resolved
58-
return NextResponse.json({
42+
const resolved = await readPublicSharedFile({
5943
token,
60-
name: file.originalName,
61-
type: file.contentType,
62-
size: getWorkspaceFileSize(file),
63-
workspaceName,
64-
ownerName,
44+
fileId: parsed.data.query.fileId,
45+
authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'),
6546
})
47+
48+
const { file, workspaceName, ownerName } = resolved
49+
return NextResponse.json(
50+
{
51+
token,
52+
name: file.originalName,
53+
type: file.contentType,
54+
size: getWorkspaceFileSize(file),
55+
workspaceName,
56+
ownerName,
57+
...(parsed.data.query.fileId
58+
? { version: file.updatedAt.getTime(), folderId: file.folderId }
59+
: {}),
60+
},
61+
{ headers: { 'Cache-Control': 'private, no-store' } }
62+
)
6663
} catch (error) {
64+
if (error instanceof PublicShareAccessError)
65+
return NextResponse.json(
66+
{ error: error.message },
67+
{ status: error.status, headers: { 'Cache-Control': 'private, no-store' } }
68+
)
6769
logger.error('Error fetching public file metadata:', error)
6870
return NextResponse.json(
6971
{ error: getErrorMessage(error, 'Failed to fetch file') },
@@ -89,7 +91,7 @@ export const POST = withRouteHandler(
8991
const { token } = parsed.data.params
9092
const { password } = parsed.data.body
9193

92-
const resolved = await resolveActiveShareByToken(token)
94+
const resolved = await resolveActiveResourceShareByToken(token)
9395
if (!resolved) {
9496
return NextResponse.json({ error: 'Not found' }, { status: 404 })
9597
}

‎apps/sim/app/api/files/public/[token]/sso/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { RateLimiter } from '@/lib/core/rate-limiter'
99
import { isEmailAllowed } from '@/lib/core/security/deployment'
1010
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
12+
import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager'
1313

1414
export const dynamic = 'force-dynamic'
1515
export const runtime = 'nodejs'
@@ -66,7 +66,7 @@ export const POST = withRouteHandler(
6666
const { token } = parsed.data.params
6767
const email = normalizeEmail(parsed.data.body.email)
6868

69-
const resolved = await resolveActiveShareByToken(token)
69+
const resolved = await resolveActiveResourceShareByToken(token)
7070
if (!resolved) {
7171
return NextResponse.json({ error: 'Not found' }, { status: 404 })
7272
}

0 commit comments

Comments
 (0)