Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type DecompressResult,
decompressArchiveBufferToWorkspaceFiles,
MAX_ARCHIVE_BYTES,
statusForArchiveError,
} from '@/lib/uploads/archive'
import type { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
Expand Down Expand Up @@ -1183,7 +1184,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (archiveError instanceof ArchiveError) {
// The error message is single-sourced in ArchiveError (caps included);
// only the HTTP status is mapped here.
const status = archiveError.reason === 'invalid' ? 400 : 413
const status = statusForArchiveError(archiveError)
return NextResponse.json(
{ success: false, error: `"${archive.name}": ${archiveError.message}` },
{ status }
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/webhooks/outbox/process/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move'
import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
Expand All @@ -25,6 +26,7 @@ const handlers = {
...enterpriseIssuanceOutboxHandlers,
...invitationMigrationOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
...workspaceFileStorageCleanupOutboxHandlers,
...workflowDeploymentOutboxHandlers,
} as const

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
extract: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))

vi.mock('@/lib/workspace-files/application/extract-workspace-file', () => ({
extractWorkspaceFile: {
operation: { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'deny' },
execute: mocks.extract,
},
}))

import { ArchiveError } from '@/lib/uploads/archive'
import { POST } from '@/app/api/workspaces/[id]/files/[fileId]/extract/route'

const WORKSPACE_ID = 'workspace-1'
const FILE_ID = 'wf_1'
const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) }

function callExtract() {
return POST(
new NextRequest(
`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/extract`,
{ method: 'POST' }
),
context
)
}

describe('POST /api/workspaces/[id]/files/[fileId]/extract', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.extract.mockResolvedValue({ folderName: 'bundle', extractedCount: 2, skippedCount: 0 })
})

it('passes a session principal and canonical assertion to the extraction use case', async () => {
const response = await callExtract()

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
folderName: 'bundle',
extractedCount: 2,
skippedCount: 0,
})
expect(mocks.extract).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID },
request: expect.anything(),
})
})

it('authenticates before invoking extraction', async () => {
mocks.getSession.mockResolvedValue(null)

const response = await callExtract()

expect(response.status).toBe(401)
expect(mocks.extract).not.toHaveBeenCalled()
})

it('returns a caller-safe error for an invalid zip', async () => {
mocks.extract.mockRejectedValue(new ArchiveError('invalid', 'Not a valid .zip archive.'))

const response = await callExtract()

expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Not a valid .zip archive.' })
})
})
27 changes: 27 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/[fileId]/extract/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { extractWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalFileErrorPolicies } from '@/lib/workspace-files/api'
import { extractWorkspaceFile } from '@/lib/workspace-files/application/extract-workspace-file'
import { fileOperations } from '@/lib/workspace-files/application/operations'

export const dynamic = 'force-dynamic'
export const maxDuration = 300
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* POST /api/workspaces/[id]/files/[fileId]/extract
* Unzip an archive file into a new folder beside it (requires write permission)
*/
export const POST = defineInternalJsonRoute({
contract: extractWorkspaceFileContract,
auth: internalSessionAuth,
operation: fileOperations.extractArchive,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file behavior' }),
errorPolicy: internalFileErrorPolicies.extractArchive,
mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }),
useCase: extractWorkspaceFile,
present: (result) => ({ success: true, ...result }),
})
56 changes: 54 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ import {
formatFileSize,
getFileExtension,
getMimeTypeFromExtension,
isArchiveFileName,
isAudioFileType,
isVideoFileType,
resolveEffectiveMimeType,
} from '@/lib/uploads/utils/file-utils'
import {
isSupportedExtension,
SUPPORTED_ARCHIVE_EXTENSIONS,
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_CODE_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
Expand Down Expand Up @@ -119,6 +121,7 @@ import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/
import {
useBulkArchiveWorkspaceFileItems,
useCreateWorkspaceFileFolder,
useExtractWorkspaceFile,
useMoveWorkspaceFileItems,
useUpdateWorkspaceFileFolder,
useWorkspaceFileFolders,
Expand Down Expand Up @@ -174,6 +177,7 @@ const SUPPORTED_EXTENSIONS = [
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
...SUPPORTED_IMAGE_EXTENSIONS,
...SUPPORTED_ARCHIVE_EXTENSIONS,
] as const

const ACCEPT_ATTR = SUPPORTED_EXTENSIONS.map((ext) => `.${ext}`).join(',')
Expand All @@ -189,6 +193,7 @@ const COLUMNS: ResourceColumn[] = [

const MIME_TYPE_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'application/zip': 'ZIP',
'application/msword': 'Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
'application/vnd.ms-excel': 'Excel',
Expand Down Expand Up @@ -277,6 +282,7 @@ export function Files() {
const deleteFile = useDeleteWorkspaceFile()
const renameFile = useRenameWorkspaceFile()
const createFolder = useCreateWorkspaceFileFolder()
const extractFile = useExtractWorkspaceFile()
const updateFolder = useUpdateWorkspaceFileFolder()
const moveItems = useMoveWorkspaceFileItems()
const bulkArchiveItems = useBulkArchiveWorkspaceFileItems()
Expand Down Expand Up @@ -386,6 +392,8 @@ export function Files() {
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [extractTargetId, setExtractTargetId] = useState<string | null>(null)
const extractTarget = extractTargetId ? (fileById.get(extractTargetId) ?? null) : null
const contextMenuItemRef = useRef<FileResourceItem | null>(null)
const [deleteTarget, setDeleteTarget] = useState<{
fileIds: string[]
Expand Down Expand Up @@ -1430,6 +1438,11 @@ export function Files() {
void setFilesParams({ folderId: parsed.id, new: null })
return
}
const file = fileByIdRef.current.get(parsed.id)
if (file && isArchiveFileName(file.name)) {
setExtractTargetId(file.id)
return
}
Comment thread
waleedlatif1 marked this conversation as resolved.
router.push(
currentFolderId
? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}`
Expand All @@ -1440,6 +1453,21 @@ export function Files() {
[router, workspaceId, currentFolderId, setFilesParams]
)

const handleExtract = async () => {
if (!extractTarget || !canEdit) return
try {
await extractFile.mutateAsync({
workspaceId,
fileId: extractTarget.id,
fileName: extractTarget.name,
})
} catch (error) {
logger.error('Failed to unzip archive:', error)
} finally {
setExtractTargetId(null)
}
}

const handleUploadClick = useCallback(() => {
if (!canEdit || uploading) return
fileInputRef.current?.click()
Expand Down Expand Up @@ -1895,9 +1923,14 @@ export function Files() {
}
/>
{isDraggingOver ? (
<div className='pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 border border-[var(--brand-secondary)] border-dashed bg-[var(--bg)]/80 transition-colors'>
<div className='pointer-events-none absolute inset-0 z-[var(--z-dropdown)] flex flex-col items-center justify-center gap-2 border border-[var(--brand-secondary)] border-dashed bg-[var(--white)] transition-colors dark:bg-[var(--surface-4)]'>
<Upload className='size-5 text-[var(--brand-secondary)]' />
<p className='text-[var(--brand-secondary)] text-sm'>Drop to upload</p>
<div className='flex flex-col gap-0.5 text-center'>
<p className='text-[var(--brand-secondary)] text-sm'>Drop to upload</p>
<p className='text-[var(--text-tertiary)] text-xs'>
Release files here to add them to this workspace
</p>
</div>
</div>
) : null}
</>
Expand Down Expand Up @@ -1944,6 +1977,25 @@ export function Files() {
isPending={deleteFile.isPending || bulkArchiveItems.isPending}
/>

<ChipConfirmModal
open={Boolean(extractTarget)}
onOpenChange={(open) => !open && setExtractTargetId(null)}
title='Unzip archive?'
text={[
'This will unzip ',
{ text: extractTarget?.name ?? 'this archive', bold: true },
' into a new folder beside it.',
]}
confirm={{
label: 'Unzip',
onClick: () => void handleExtract(),
variant: 'primary',
pending: extractFile.isPending,
pendingLabel: 'Unzipping...',
disabled: !canEdit,
}}
/>

{shareModal}

<input
Expand Down
55 changes: 55 additions & 0 deletions apps/sim/hooks/queries/workspace-file-folders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiClientError } from '@/lib/api/client/errors'
import { useExtractWorkspaceFile } from '@/hooks/queries/workspace-file-folders'

const { queryClient } = vi.hoisted(() => ({
queryClient: {
invalidateQueries: vi.fn(),
},
}))

vi.mock('@sim/emcn', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}))

vi.mock('@tanstack/react-query', () => ({
keepPreviousData: {},
useMutation: vi.fn((options) => options),
useQuery: vi.fn(),
useQueryClient: vi.fn(() => queryClient),
}))

vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))

const variables = { workspaceId: 'workspace-1', fileId: 'file-1', fileName: 'archive.zip' }

describe('useExtractWorkspaceFile reconciliation', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('invalidates file browsers after success', () => {
const mutation = useExtractWorkspaceFile()

mutation.onSuccess(
{ success: true, folderName: 'archive', extractedCount: 2, skippedCount: 0 },
variables
)
mutation.onSettled(undefined, undefined, variables)

expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(3)
})

it('invalidates file browsers after an API error response', () => {
const mutation = useExtractWorkspaceFile()
const error = new ApiClientError({ status: 409, message: 'Folder exists', body: {} })

mutation.onError(error, variables)
mutation.onSettled(undefined, error, variables)

expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(3)
})
})
20 changes: 20 additions & 0 deletions apps/sim/hooks/queries/workspace-file-folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
updateWorkspaceFileFolderContract,
type WorkspaceFileFolderApi,
} from '@/lib/api/contracts/workspace-file-folders'
import { extractWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
import {
buildWorkspaceFileFolderDisplayPath,
parseWorkspaceFileFolderDisplayPath,
Expand Down Expand Up @@ -87,6 +88,25 @@ export function useCreateWorkspaceFileFolder() {
})
}

export function useExtractWorkspaceFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: { workspaceId: string; fileId: string; fileName: string }) =>
requestJson(extractWorkspaceFileContract, {
params: { id: variables.workspaceId, fileId: variables.fileId },
}),
onSuccess: (data, variables) => {
toast.success(`Unzipped "${variables.fileName}" into "${data.folderName}"`)
},
onError: (error) => {
toast.error(toError(error).message)
},
onSettled: (_data, _error, variables) => {
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
},
})
}

export function useUpdateWorkspaceFileFolder() {
const queryClient = useQueryClient()
return useMutation({
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/lib/api/contracts/workspace-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ const listWorkspaceFilesResponseSchema = workspaceFileSuccessSchema.extend({

export type ListWorkspaceFilesResponse = z.output<typeof listWorkspaceFilesResponseSchema>

export const extractWorkspaceFileResponseSchema = workspaceFileSuccessSchema.extend({
folderName: z.string(),
extractedCount: z.number().int().nonnegative(),
skippedCount: z.number().int().nonnegative(),
})

export type ExtractWorkspaceFileResponse = z.output<typeof extractWorkspaceFileResponseSchema>

export const listWorkspaceFilesContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/files',
Expand Down Expand Up @@ -184,6 +192,16 @@ export const renameWorkspaceFileContract = defineRouteContract({
error: renameWorkspaceFileErrorSchema,
})

export const extractWorkspaceFileContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/files/[fileId]/extract',
params: workspaceFileParamsSchema,
response: {
mode: 'json',
schema: extractWorkspaceFileResponseSchema,
},
})

export const updateWorkspaceFileDimensionsContract = defineRouteContract({
method: 'PATCH',
path: '/api/workspaces/[id]/files/[fileId]/dimensions',
Expand Down
Loading
Loading