Skip to content

Commit 83a0ba8

Browse files
committed
feat(files): folders + vfs update
1 parent 9d2dd8f commit 83a0ba8

36 files changed

Lines changed: 18584 additions & 214 deletions

File tree

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { fileManageContract } from '@/lib/api/contracts/tools/file'
44
import { parseRequest } from '@/lib/api/server'
55
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
67
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
78
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
911
import {
1012
fetchWorkspaceFileBuffer,
11-
getWorkspaceFileByName,
13+
resolveWorkspaceFileReference,
1214
updateWorkspaceFileContent,
1315
uploadWorkspaceFile,
1416
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -42,14 +44,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4244
switch (body.operation) {
4345
case 'write': {
4446
const { fileName, content, contentType } = body
45-
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
47+
const { folderSegments, leafName } = splitWorkspaceFilePath(fileName)
48+
const folderId = await ensureWorkspaceFileFolderPath({
49+
workspaceId,
50+
userId,
51+
pathSegments: folderSegments,
52+
})
53+
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName))
4654
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
4755
const result = await uploadWorkspaceFile(
4856
workspaceId,
4957
userId,
5058
fileBuffer,
51-
fileName,
52-
mimeType
59+
leafName,
60+
mimeType,
61+
{ folderId }
5362
)
5463

5564
logger.info('File created', {
@@ -72,7 +81,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7281
case 'append': {
7382
const { fileName, content } = body
7483

75-
const existing = await getWorkspaceFileByName(workspaceId, fileName)
84+
const existing = await resolveWorkspaceFileReference(workspaceId, fileName)
7685
if (!existing) {
7786
return NextResponse.json(
7887
{ success: false, error: `File not found: "${fileName}"` },
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { bulkArchiveWorkspaceFileItems } from '@/lib/uploads/contexts/workspace'
8+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
9+
10+
const logger = createLogger('WorkspaceFileBulkArchiveAPI')
11+
12+
export const POST = withRouteHandler(
13+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
14+
const session = await getSession()
15+
if (!session?.user?.id) {
16+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context)
20+
if (!parsed.success) return parsed.response
21+
const { id: workspaceId } = parsed.data.params
22+
const { fileIds, folderIds } = parsed.data.body
23+
24+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
25+
if (permission !== 'admin' && permission !== 'write') {
26+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
27+
}
28+
29+
try {
30+
const deletedItems = await bulkArchiveWorkspaceFileItems({ workspaceId, fileIds, folderIds })
31+
return NextResponse.json({ success: true, deletedItems })
32+
} catch (error) {
33+
logger.error('Failed to bulk archive workspace file items:', error)
34+
return NextResponse.json(
35+
{
36+
success: false,
37+
error: error instanceof Error ? error.message : 'Failed to archive items',
38+
},
39+
{ status: 400 }
40+
)
41+
}
42+
}
43+
)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { createLogger } from '@sim/logger'
2+
import JSZip from 'jszip'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import {
9+
fetchWorkspaceFileBuffer,
10+
listWorkspaceFileFolders,
11+
listWorkspaceFiles,
12+
} from '@/lib/uploads/contexts/workspace'
13+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
14+
15+
const logger = createLogger('WorkspaceFilesDownloadAPI')
16+
17+
function safeZipPath(path: string): string {
18+
return path
19+
.split('/')
20+
.map((segment) => segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_'))
21+
.filter(Boolean)
22+
.join('/')
23+
}
24+
25+
function collectDescendantFolderIds(
26+
selectedFolderIds: string[],
27+
folders: Array<{ id: string; parentId: string | null }>
28+
): Set<string> {
29+
const folderIds = new Set(selectedFolderIds)
30+
let changed = true
31+
while (changed) {
32+
changed = false
33+
for (const folder of folders) {
34+
if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) {
35+
folderIds.add(folder.id)
36+
changed = true
37+
}
38+
}
39+
}
40+
return folderIds
41+
}
42+
43+
export const GET = withRouteHandler(
44+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
45+
const session = await getSession()
46+
if (!session?.user?.id) {
47+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
48+
}
49+
50+
const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context)
51+
if (!parsed.success) return parsed.response
52+
const { id: workspaceId } = parsed.data.params
53+
const { fileIds, folderIds } = parsed.data.query
54+
55+
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
56+
if (!permission) {
57+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
58+
}
59+
60+
try {
61+
const [files, folders] = await Promise.all([
62+
listWorkspaceFiles(workspaceId),
63+
listWorkspaceFileFolders(workspaceId),
64+
])
65+
const selectedFolderIds = collectDescendantFolderIds(folderIds, folders)
66+
const requestedFileIds = new Set(fileIds)
67+
const filesToZip = files.filter(
68+
(file) =>
69+
requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId))
70+
)
71+
72+
if (filesToZip.length === 0) {
73+
return NextResponse.json({ error: 'No files selected for download' }, { status: 400 })
74+
}
75+
76+
const zip = new JSZip()
77+
const usedPaths = new Set<string>()
78+
for (const file of filesToZip) {
79+
const buffer = await fetchWorkspaceFileBuffer(file)
80+
const basePath = safeZipPath(
81+
file.folderPath ? `${file.folderPath}/${file.name}` : file.name
82+
)
83+
let zipPath = basePath || file.name
84+
let suffix = 2
85+
while (usedPaths.has(zipPath)) {
86+
const dotIndex = basePath.lastIndexOf('.')
87+
zipPath =
88+
dotIndex > 0
89+
? `${basePath.slice(0, dotIndex)} (${suffix})${basePath.slice(dotIndex)}`
90+
: `${basePath} (${suffix})`
91+
suffix++
92+
}
93+
usedPaths.add(zipPath)
94+
zip.file(zipPath, buffer)
95+
}
96+
97+
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
98+
return new NextResponse(new Uint8Array(zipBuffer), {
99+
headers: {
100+
'Content-Type': 'application/zip',
101+
'Content-Disposition': 'attachment; filename="workspace-files.zip"',
102+
'Cache-Control': 'no-store',
103+
},
104+
})
105+
} catch (error) {
106+
logger.error('Failed to download workspace file selection:', error)
107+
return NextResponse.json(
108+
{ error: error instanceof Error ? error.message : 'Failed to download selected files' },
109+
{ status: 500 }
110+
)
111+
}
112+
}
113+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
deleteWorkspaceFileFolderContract,
5+
updateWorkspaceFileFolderContract,
6+
} from '@/lib/api/contracts/workspace-file-folders'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import {
11+
archiveWorkspaceFileFolderRecursive,
12+
updateWorkspaceFileFolder,
13+
WorkspaceFileFolderConflictError,
14+
} from '@/lib/uploads/contexts/workspace'
15+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
16+
17+
const logger = createLogger('WorkspaceFileFolderAPI')
18+
19+
async function assertWritePermission(userId: string, workspaceId: string) {
20+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
21+
return permission === 'admin' || permission === 'write'
22+
}
23+
24+
export const PATCH = withRouteHandler(
25+
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
26+
const session = await getSession()
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context)
32+
if (!parsed.success) return parsed.response
33+
const { id: workspaceId, folderId } = parsed.data.params
34+
35+
if (!(await assertWritePermission(session.user.id, workspaceId))) {
36+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
37+
}
38+
39+
try {
40+
const folder = await updateWorkspaceFileFolder({
41+
workspaceId,
42+
folderId,
43+
...parsed.data.body,
44+
})
45+
return NextResponse.json({ success: true, folder })
46+
} catch (error) {
47+
logger.error('Failed to update workspace file folder:', error)
48+
const message = error instanceof Error ? error.message : 'Failed to update folder'
49+
return NextResponse.json(
50+
{ success: false, error: message },
51+
{ status: error instanceof WorkspaceFileFolderConflictError ? 409 : 400 }
52+
)
53+
}
54+
}
55+
)
56+
57+
export const DELETE = withRouteHandler(
58+
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
59+
const session = await getSession()
60+
if (!session?.user?.id) {
61+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
62+
}
63+
64+
const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context)
65+
if (!parsed.success) return parsed.response
66+
const { id: workspaceId, folderId } = parsed.data.params
67+
68+
if (!(await assertWritePermission(session.user.id, workspaceId))) {
69+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
70+
}
71+
72+
try {
73+
const deletedItems = await archiveWorkspaceFileFolderRecursive(workspaceId, folderId)
74+
return NextResponse.json({ success: true, deletedItems })
75+
} catch (error) {
76+
logger.error('Failed to delete workspace file folder:', error)
77+
return NextResponse.json(
78+
{
79+
success: false,
80+
error: error instanceof Error ? error.message : 'Failed to delete folder',
81+
},
82+
{ status: 400 }
83+
)
84+
}
85+
}
86+
)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
createWorkspaceFileFolderContract,
5+
listWorkspaceFileFoldersContract,
6+
} from '@/lib/api/contracts/workspace-file-folders'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import {
11+
createWorkspaceFileFolder,
12+
listWorkspaceFileFolders,
13+
WorkspaceFileFolderConflictError,
14+
} from '@/lib/uploads/contexts/workspace'
15+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
16+
17+
const logger = createLogger('WorkspaceFileFoldersAPI')
18+
19+
async function getWorkspacePermission(userId: string, workspaceId: string) {
20+
return getUserEntityPermissions(userId, 'workspace', workspaceId)
21+
}
22+
23+
export const GET = withRouteHandler(
24+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
25+
const session = await getSession()
26+
if (!session?.user?.id) {
27+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
28+
}
29+
30+
const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context)
31+
if (!parsed.success) return parsed.response
32+
const { id: workspaceId } = parsed.data.params
33+
const { scope } = parsed.data.query
34+
35+
const permission = await getWorkspacePermission(session.user.id, workspaceId)
36+
if (!permission) {
37+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
38+
}
39+
40+
const folders = await listWorkspaceFileFolders(workspaceId, { scope })
41+
return NextResponse.json({ success: true, folders })
42+
}
43+
)
44+
45+
export const POST = withRouteHandler(
46+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
47+
const session = await getSession()
48+
if (!session?.user?.id) {
49+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
50+
}
51+
52+
const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context)
53+
if (!parsed.success) return parsed.response
54+
const { id: workspaceId } = parsed.data.params
55+
const { name, parentId } = parsed.data.body
56+
57+
const permission = await getWorkspacePermission(session.user.id, workspaceId)
58+
if (permission !== 'admin' && permission !== 'write') {
59+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
60+
}
61+
62+
try {
63+
const folder = await createWorkspaceFileFolder({
64+
workspaceId,
65+
userId: session.user.id,
66+
name,
67+
parentId,
68+
})
69+
return NextResponse.json({ success: true, folder })
70+
} catch (error) {
71+
logger.error('Failed to create workspace file folder:', error)
72+
const message = error instanceof Error ? error.message : 'Failed to create folder'
73+
return NextResponse.json(
74+
{ success: false, error: message },
75+
{ status: error instanceof WorkspaceFileFolderConflictError ? 409 : 400 }
76+
)
77+
}
78+
}
79+
)

0 commit comments

Comments
 (0)