Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1055 lines
37 KiB
TypeScript

import { Buffer, isUtf8 } from 'buffer'
import type { Readable } from 'stream'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import JSZip from 'jszip'
import { type NextRequest, NextResponse } from 'next/server'
import { fileManageContract } from '@/lib/api/contracts/tools/file'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateRequestId } from '@/lib/core/utils/request'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
import {
getShareForResource,
getSharesForResources,
ShareValidationError,
upsertFileShare,
} from '@/lib/public-shares/share-manager'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
resolveWorkspaceFileReference,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import {
downloadFileFromStorage,
downloadServableFileFromStorage,
} from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
PublicFileSharingNotAllowedError,
validatePublicFileSharing,
} from '@/ee/access-control/utils/permission-check'
import type { UserFile } from '@/executor/types'
export const dynamic = 'force-dynamic'
const logger = createLogger('FileManageAPI')
const workspaceFileToUserFile = (file: Awaited<ReturnType<typeof getWorkspaceFile>>) => {
if (!file) return null
return {
id: file.id,
name: file.name,
url: ensureAbsoluteUrl(file.path),
size: file.size,
type: file.type,
key: file.key,
context: 'workspace',
}
}
const fileInputToUserFile = (fileInput: unknown) => {
if (!fileInput || typeof fileInput !== 'object' || Array.isArray(fileInput)) return null
const record = fileInput as Record<string, unknown>
const id =
typeof record.id === 'string'
? record.id.trim()
: typeof record.fileId === 'string'
? record.fileId.trim()
: ''
// Objects with ids are resolved through workspace metadata. This fallback is for
// picker/upload values that only carry storage fields.
if (id) return null
const key = typeof record.key === 'string' ? record.key.trim() : ''
const path = typeof record.path === 'string' ? record.path.trim() : ''
const url = typeof record.url === 'string' ? record.url.trim() : ''
const fileUrl =
url || path || (key ? `/api/files/serve/${encodeURIComponent(key)}?context=workspace` : '')
if (!fileUrl && !key) return null
return {
id: key || fileUrl,
name:
typeof record.name === 'string' && record.name.trim() ? record.name.trim() : 'workspace-file',
url: fileUrl ? ensureAbsoluteUrl(fileUrl) : '',
size: typeof record.size === 'number' ? record.size : 0,
type:
typeof record.type === 'string' && record.type.trim()
? record.type.trim()
: 'application/octet-stream',
key,
context: 'workspace',
}
}
const normalizeFileIdList = (value: unknown): string[] => {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
try {
return normalizeFileIdList(JSON.parse(trimmed))
} catch {
return [trimmed]
}
}
if (!Array.isArray(value)) return []
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((id) => id.length > 0)
}
const extractUserFilesFromInput = (fileInput: unknown) => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []
return inputs
.map((input) => fileInputToUserFile(input))
.filter((file): file is NonNullable<ReturnType<typeof fileInputToUserFile>> => Boolean(file))
}
const extractFileIdsFromInput = (fileInput: unknown): string[] => {
const inputs = Array.isArray(fileInput) ? fileInput : fileInput ? [fileInput] : []
return inputs
.flatMap((input) => {
if (typeof input === 'string') return normalizeFileIdList(input)
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>
if (typeof record.id === 'string') return normalizeFileIdList(record.id)
if (typeof record.fileId === 'string') return normalizeFileIdList(record.fileId)
}
return []
})
.filter((id) => id.length > 0)
}
/** Per-file download cap for the content operation. Aligned with the durable large-value ceiling. */
const MAX_GET_CONTENT_FILE_BYTES = 64 * 1024 * 1024
/** Combined extracted-text cap so the content array stays within the large-value-ref ceiling. */
const MAX_GET_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024
/** Per-file download cap for the compress operation. */
const MAX_COMPRESS_FILE_BYTES = 100 * 1024 * 1024
/** Combined input cap for the compress operation to bound in-memory archiving. */
const MAX_COMPRESS_TOTAL_BYTES = 100 * 1024 * 1024
/** Ensure an archive name ends with a single `.zip` extension. */
const ensureZipExtension = (name: string): string =>
name.toLowerCase().endsWith('.zip') ? name : `${name}.zip`
/** Strip the trailing extension from a file name (e.g., "report.pdf" -> "report"). */
const stripExtension = (name: string): string => {
const dot = name.lastIndexOf('.')
return dot > 0 ? name.slice(0, dot) : name
}
/**
* Reduce an arbitrary name to a safe, flat file name: takes the final path
* segment, drops directory and traversal components, and falls back when the
* result would be empty or a dot segment. Used for zip entry names and the
* compress archive name so untrusted input cannot introduce nested or
* zip-slip-style paths.
*/
const toFlatFileName = (name: string, fallback: string): string => {
const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim()
if (!leaf || leaf === '.' || leaf === '..') return fallback
return leaf
}
/**
* Return a zip entry name unique within `usedNames`, appending a numeric suffix
* before the extension on collision (e.g., "data.csv" -> "data (1).csv").
*/
const uniqueZipEntryName = (name: string, usedNames: Set<string>): string => {
if (!usedNames.has(name)) {
usedNames.add(name)
return name
}
const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let counter = 1
let candidate = `${base} (${counter})${ext}`
while (usedNames.has(candidate)) {
counter += 1
candidate = `${base} (${counter})${ext}`
}
usedNames.add(candidate)
return candidate
}
/** Input archive download cap for the decompress operation. */
const MAX_DECOMPRESS_ARCHIVE_BYTES = 100 * 1024 * 1024
/** Maximum number of entries extracted from a single archive. */
const MAX_DECOMPRESS_ENTRIES = 1000
/** Maximum uncompressed size for any single archive entry. */
const MAX_DECOMPRESS_ENTRY_BYTES = 100 * 1024 * 1024
/** Maximum total uncompressed size across all entries, to bound zip-bomb expansion. */
const MAX_DECOMPRESS_TOTAL_BYTES = 200 * 1024 * 1024
const S_IFMT = 0o170000
const S_IFLNK = 0o120000
/**
* Read a zip entry's declared uncompressed size without materializing it. This
* value comes straight from the (attacker-controlled) ZIP metadata, so it is only
* usable as a cheap fast-reject for honestly-declared archives — never as the
* authoritative cap. {@link inflateEntryWithinCaps} enforces the real limit on the
* inflated byte stream.
*/
const readEntryUncompressedSize = (entry: JSZip.JSZipObject): number | undefined => {
const data = (entry as JSZip.JSZipObject & { _data?: { uncompressedSize?: number } })._data
const size = data?.uncompressedSize
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
}
type InflateResult = { ok: true; buffer: Buffer } | { ok: false; reason: 'entry' | 'total' }
/**
* Inflate a single zip entry through a streaming counting sink, tearing the
* stream down the moment cumulative output would exceed the per-entry cap or the
* remaining total budget. The declared uncompressed size in the ZIP header is
* attacker-controlled and is NOT trusted here: a forged-small or absent size
* cannot cause the full (potentially gigabyte-scale) entry to be materialized in
* memory, because enforcement happens on the actual inflated bytes as they
* arrive. Peak memory is bounded by the cap plus one DEFLATE chunk.
*/
const inflateEntryWithinCaps = (
entry: JSZip.JSZipObject,
remainingTotalBudget: number
): Promise<InflateResult> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
let settled = false
const stream = entry.nodeStream() as Readable
const settle = (result: InflateResult) => {
if (settled) return
settled = true
stream.destroy()
resolve(result)
}
stream.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > MAX_DECOMPRESS_ENTRY_BYTES) {
settle({ ok: false, reason: 'entry' })
return
}
if (size > remainingTotalBudget) {
settle({ ok: false, reason: 'total' })
return
}
chunks.push(chunk)
})
stream.on('end', () => settle({ ok: true, buffer: Buffer.concat(chunks, size) }))
stream.on('error', (error) => {
if (settled) return
settled = true
stream.destroy()
reject(error)
})
})
/** True when a zip entry's unix mode marks it as a symlink (never extracted). */
const isSymlinkEntry = (entry: JSZip.JSZipObject): boolean => {
const mode = (entry as JSZip.JSZipObject & { unixPermissions?: number | null }).unixPermissions
return typeof mode === 'number' && (mode & S_IFMT) === S_IFLNK
}
/**
* Normalize a zip entry path into safe workspace folder segments, guarding against
* zip-slip. Returns null for traversal (`..`), so the entry is skipped rather than
* written outside its intended location.
*/
const sanitizeArchiveEntryPath = (rawPath: string): string[] | null => {
const segments = rawPath
.replace(/\\/g, '/')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0 && segment !== '.')
if (segments.length === 0 || segments.includes('..')) return null
return segments
}
const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0)
/**
* Download a stored file and extract its text content. Parseable types (PDF, DOCX,
* CSV, etc.) go through the shared file-parsers; other UTF-8 files are returned as
* raw text; binary files yield a short placeholder rather than corrupt bytes.
*/
const extractUserFileTextContent = async (
userFile: UserFile,
requestId: string
): Promise<string> => {
const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_GET_CONTENT_FILE_BYTES,
})
const extension = getFileExtension(userFile.name)
if (extension && isSupportedFileType(extension)) {
try {
const result = await parseBuffer(buffer, extension)
return result.content ?? ''
} catch (error) {
logger.warn('Falling back to raw text after parser failure', {
name: userFile.name,
error: getErrorMessage(error, 'Unknown error'),
})
}
}
if (isLikelyTextBuffer(buffer)) {
return buffer.toString('utf-8')
}
return `[Binary file: ${userFile.name} (${userFile.type || 'application/octet-stream'}, ${buffer.length} bytes). Cannot extract text content.]`
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}
const parsed = await parseRequest(fileManageContract, request, {})
if (!parsed.success) return parsed.response
const { query, body } = parsed.data
const userId = auth.userId || query.userId
if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}
const workspaceId = body.workspaceId || query.workspaceId
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}
try {
await assertActiveWorkspaceAccess(workspaceId, userId)
switch (body.operation) {
case 'get': {
const { fileId, fileInput } = body
const selectedFileId =
fileId ||
(fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput)
? (() => {
const obj = fileInput as Record<string, unknown>
return typeof obj.id === 'string'
? obj.id
: typeof obj.fileId === 'string'
? obj.fileId
: ''
})()
: '')
if (!selectedFileId) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const file = await getWorkspaceFile(workspaceId, selectedFileId)
if (!file) {
return NextResponse.json(
{ success: false, error: `File not found: "${selectedFileId}"` },
{ status: 404 }
)
}
logger.info('File retrieved', {
fileId: file.id,
name: file.name,
})
return NextResponse.json({
success: true,
data: {
file: workspaceFileToUserFile(file),
},
})
}
case 'read': {
const { fileId, fileInput } = body
const selectedFileIds = Array.isArray(fileId)
? fileId.map((id) => id.trim()).filter(Boolean)
: fileId
? normalizeFileIdList(fileId)
: extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const files = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !files[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}
const shares = await getSharesForResources('file', selectedFileIds)
const privateReadShare = () => ({
visibility: 'private' as const,
url: null,
allowedEmails: [] as string[],
})
const toReadShare = (fileId: string) => {
const share = shares.get(fileId)
if (!share || !share.isActive) return privateReadShare()
return {
visibility: share.authType,
url: share.url,
allowedEmails: share.allowedEmails,
}
}
const userFiles = files
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.map((file) => ({ ...file, share: toReadShare(file.id) }))
// Picker/upload entries have only a synthetic id (storage key/URL), so they
// never carry a canonical share — mark them private without a lookup.
.concat(selectedInputFiles.map((file) => ({ ...file, share: privateReadShare() })))
logger.info('Files retrieved', {
count: userFiles.length,
fileIds: userFiles.map((file) => file.id),
})
return NextResponse.json({
success: true,
data: {
file: userFiles[0],
files: userFiles,
},
})
}
case 'content': {
const { fileId, fileInput } = body
const requestId = generateRequestId()
const selectedFileIds = Array.isArray(fileId)
? fileId.map((id) => id.trim()).filter(Boolean)
: fileId
? normalizeFileIdList(fileId)
: extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const workspaceFiles = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}
const userFiles: UserFile[] = workspaceFiles
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)
const contents: string[] = []
let totalBytes = 0
for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
const content = await extractUserFileTextContent(userFile, requestId)
totalBytes += Buffer.byteLength(content, 'utf8')
if (totalBytes > MAX_GET_CONTENT_TOTAL_BYTES) {
return NextResponse.json(
{
success: false,
error: `Combined file content is too large to return safely. Maximum is ${
MAX_GET_CONTENT_TOTAL_BYTES / (1024 * 1024)
} MB.`,
},
{ status: 413 }
)
}
contents.push(content)
}
logger.info('File content extracted', { count: contents.length })
return NextResponse.json({
success: true,
data: { contents },
})
}
case 'write': {
const { fileName, content, contentType } = body
const { folderSegments, leafName } = splitWorkspaceFilePath(fileName)
const folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: folderSegments,
})
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
leafName,
mimeType,
{ folderId }
)
logger.info('File created', {
fileId: result.id,
name: fileName,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: result.id,
name: result.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(result.url),
},
})
}
case 'move': {
const { fileId, targetFolder } = body
const pathSegments = targetFolder.trim()
? targetFolder
.trim()
.split('/')
.map((s) => s.trim())
.filter(Boolean)
: []
const targetFolderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments,
})
const moveResult = await performMoveWorkspaceFileItems({
workspaceId,
userId,
fileIds: [fileId],
targetFolderId,
})
if (!moveResult.success) {
return NextResponse.json(
{ success: false, error: moveResult.error },
{
status:
moveResult.errorCode === 'conflict'
? 409
: moveResult.errorCode === 'not_found'
? 404
: 400,
}
)
}
logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' })
return NextResponse.json({
success: true,
data: { fileId, targetFolder: targetFolder || '(root)' },
})
}
case 'manage_sharing': {
const { fileId, fileInput, isActive, authType, password, allowedEmails } = body
// Check permission before probing file existence so a read-only caller
// can't distinguish 404 from 403 as a file-existence side channel.
// Publishing is more sensitive than the other mutating ops, so it
// requires write/admin (not just workspace access) like the share route.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json(
{ success: false, error: 'Insufficient permissions' },
{ status: 403 }
)
}
// Resolve the canonical file id. The basic file picker provides an object
// with a storage `key` but no id, so map the key to the workspace file row.
let resolvedFileId = typeof fileId === 'string' ? fileId : undefined
if (!resolvedFileId && fileInput) {
const single = Array.isArray(fileInput) ? fileInput[0] : fileInput
if (single && typeof single === 'object') {
const record = single as Record<string, unknown>
if (typeof record.id === 'string' && record.id) resolvedFileId = record.id
else if (typeof record.fileId === 'string' && record.fileId)
resolvedFileId = record.fileId
else if (typeof record.key === 'string' && record.key) {
const meta = await getFileMetadataByKey(record.key, 'workspace')
resolvedFileId = meta?.id
}
}
}
if (!resolvedFileId) {
return NextResponse.json(
{ success: false, error: 'A valid file is required to manage sharing' },
{ status: 400 }
)
}
const file = await getWorkspaceFile(workspaceId, resolvedFileId)
if (!file) {
return NextResponse.json(
{ success: false, error: `File not found: "${resolvedFileId}"` },
{ status: 404 }
)
}
// Enabling a share is gated by the org's access-control policy; disabling
// is always allowed so users can un-share after the policy is turned on.
if (isActive) {
// Resolve the auth type the same way upsertFileShare will (falling back
// to the existing share's type) so the policy gate can't be bypassed by
// re-enabling a pre-existing restricted share without an explicit authType.
const existingShare = await getShareForResource('file', resolvedFileId)
const resolvedAuthType = authType ?? existingShare?.authType ?? 'public'
try {
await validatePublicFileSharing(userId, workspaceId, resolvedAuthType)
} catch (error) {
if (error instanceof PublicFileSharingNotAllowedError) {
return NextResponse.json({ success: false, error: error.message }, { status: 403 })
}
throw error
}
}
const share = await upsertFileShare({
workspaceId,
fileId: resolvedFileId,
userId,
isActive,
authType,
password,
allowedEmails,
})
recordAudit({
workspaceId,
actorId: userId,
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
resourceType: AuditResourceType.FILE,
resourceId: resolvedFileId,
resourceName: file.name,
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
request,
})
logger.info('File sharing updated', {
fileId: resolvedFileId,
isActive,
authType: share.authType,
})
// A disabled link doesn't resolve, so don't hand back a dead URL.
const responseShare = share.isActive ? share : { ...share, url: '' }
return NextResponse.json({ success: true, data: { share: responseShare } })
}
case 'append': {
const { fileName, content } = body
const existing = await resolveWorkspaceFileReference(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
{ status: 404 }
)
}
const lockKey = `file-append:${workspaceId}:${existing.id}`
const lockValue = `${Date.now()}-${generateShortId()}`
const acquired = await acquireLock(lockKey, lockValue, 30)
if (!acquired) {
return NextResponse.json(
{ success: false, error: 'File is busy, please retry' },
{ status: 409 }
)
}
try {
const existingBuffer = await fetchWorkspaceFileBuffer(existing)
const finalContent = existingBuffer.toString('utf-8') + content
const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
logger.info('File appended', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: existing.id,
name: existing.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(existing.path),
},
})
} finally {
await releaseLock(lockKey, lockValue)
}
}
case 'compress': {
const { fileId, fileInput, archiveName } = body
const requestId = generateRequestId()
const selectedFileIds = Array.isArray(fileId)
? fileId.map((id) => id.trim()).filter(Boolean)
: fileId
? normalizeFileIdList(fileId)
: extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const workspaceFiles = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}
const userFiles: UserFile[] = workspaceFiles
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)
const zip = new JSZip()
const usedNames = new Set<string>()
let totalBytes = 0
for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_COMPRESS_FILE_BYTES,
})
totalBytes += buffer.length
if (totalBytes > MAX_COMPRESS_TOTAL_BYTES) {
return NextResponse.json(
{
success: false,
error: `Combined input is too large to compress. Maximum is ${
MAX_COMPRESS_TOTAL_BYTES / (1024 * 1024)
} MB.`,
},
{ status: 413 }
)
}
zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer)
}
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
})
const requestedName = typeof archiveName === 'string' ? archiveName.trim() : ''
const baseName = requestedName
? toFlatFileName(requestedName, 'archive')
: userFiles.length === 1
? stripExtension(toFlatFileName(userFiles[0].name, 'archive'))
: 'archive'
const leafName = ensureZipExtension(baseName)
const folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: [],
})
const result = await uploadWorkspaceFile(
workspaceId,
userId,
zipBuffer,
leafName,
'application/zip',
{ folderId }
)
const compressedFile: UserFile = {
...result,
url: ensureAbsoluteUrl(result.url),
size: zipBuffer.length,
}
logger.info('Files compressed', {
fileId: result.id,
name: result.name,
fileCount: userFiles.length,
size: zipBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: compressedFile.id,
name: compressedFile.name,
size: compressedFile.size,
url: compressedFile.url,
files: [compressedFile],
},
})
}
case 'decompress': {
const { fileId, fileInput } = body
const requestId = generateRequestId()
const selectedFileIds = fileId ? [fileId] : extractFileIdsFromInput(fileInput)
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
if (selectedFileIds.length + selectedInputFiles.length > 1) {
return NextResponse.json(
{ success: false, error: 'Decompress accepts a single .zip archive at a time' },
{ status: 400 }
)
}
const workspaceFiles = await Promise.all(
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
)
const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index])
if (missingFileId) {
return NextResponse.json(
{ success: false, error: `File not found: "${missingFileId}"` },
{ status: 404 }
)
}
const archive = workspaceFiles
.map((file) => workspaceFileToUserFile(file))
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
Boolean(file)
)
.concat(selectedInputFiles)[0]
if (!archive) {
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
}
const denied = await assertToolFileAccess(archive.key, userId, requestId, logger)
if (denied) return denied
const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, {
maxBytes: MAX_DECOMPRESS_ARCHIVE_BYTES,
})
let zip: JSZip
try {
zip = await JSZip.loadAsync(archiveBuffer)
} catch {
return NextResponse.json(
{ success: false, error: `"${archive.name}" is not a valid .zip archive` },
{ status: 400 }
)
}
const entries = Object.values(zip.files).filter(
(entry) => !entry.dir && !isSymlinkEntry(entry)
)
if (entries.length > MAX_DECOMPRESS_ENTRIES) {
return NextResponse.json(
{
success: false,
error: `Archive has too many entries to extract. Maximum is ${MAX_DECOMPRESS_ENTRIES}.`,
},
{ status: 413 }
)
}
const entryTooLargeResponse = (name: string) =>
NextResponse.json(
{
success: false,
error: `Archive entry "${name}" is too large to extract. Maximum is ${
MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024)
} MB per file.`,
},
{ status: 413 }
)
const totalTooLargeResponse = () =>
NextResponse.json(
{
success: false,
error: `Archive expands to more than the ${
MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024)
} MB extraction limit.`,
},
{ status: 413 }
)
// Resolve which entries are safe to extract first, so unsafe entries
// (skipped below) never count toward the size caps.
const safeEntries: Array<{ entry: JSZip.JSZipObject; segments: string[] }> = []
let skippedCount = 0
for (const entry of entries) {
const segments = sanitizeArchiveEntryPath(entry.name)
if (!segments) {
skippedCount += 1
logger.warn('Skipping unsafe archive entry', { name: entry.name })
continue
}
safeEntries.push({ entry, segments })
}
let declaredTotal = 0
for (const { entry } of safeEntries) {
const declaredSize = readEntryUncompressedSize(entry)
if (declaredSize === undefined) continue
if (declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name)
declaredTotal += declaredSize
if (declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse()
}
const pending: Array<{ segments: string[]; buffer: Buffer }> = []
let totalBytes = 0
for (const { entry, segments } of safeEntries) {
const result = await inflateEntryWithinCaps(
entry,
MAX_DECOMPRESS_TOTAL_BYTES - totalBytes
)
if (!result.ok) {
return result.reason === 'entry'
? entryTooLargeResponse(entry.name)
: totalTooLargeResponse()
}
totalBytes += result.buffer.length
pending.push({ segments, buffer: result.buffer })
}
if (pending.length === 0) {
return NextResponse.json(
{
success: false,
error: `No files could be extracted from "${archive.name}".`,
},
{ status: 422 }
)
}
const folderIdCache = new Map<string, string | null>()
const extractedFiles: UserFile[] = []
for (const { segments, buffer } of pending) {
const leafName = segments[segments.length - 1]
const folderSegments = segments.slice(0, -1)
const folderKey = folderSegments.join('/')
let folderId = folderIdCache.get(folderKey)
if (folderId === undefined) {
folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: folderSegments,
})
folderIdCache.set(folderKey, folderId)
}
const mimeType = getMimeTypeFromExtension(getFileExtension(leafName))
const uploaded = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
leafName,
mimeType,
{ folderId }
)
extractedFiles.push({ ...uploaded, url: ensureAbsoluteUrl(uploaded.url) })
}
logger.info('Archive decompressed', {
fileId: archive.id,
name: archive.name,
extractedCount: extractedFiles.length,
skippedCount,
})
return NextResponse.json({
success: true,
data: {
files: extractedFiles,
},
})
}
}
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return NextResponse.json(
{ success: false, error: 'Workspace access denied' },
{ status: 403 }
)
}
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
if (error instanceof ShareValidationError) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
}
const message = getErrorMessage(error, 'Unknown error')
logger.error('File operation failed', { operation: body.operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
})