import path from 'node:path' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import JSZip from 'jszip' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { verifyFileAccess } from '@/app/api/files/authorization' import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) function isMarkdown(originalName: string, contentType: string): boolean { if (MARKDOWN_MIME_TYPES.has(contentType)) return true const ext = originalName.split('.').pop()?.toLowerCase() ?? '' return MARKDOWN_EXTENSIONS.has(ext) } function safeFilename(name: string): string { return path .basename(name) .replace(/["\\]/g, '_') .replace(/[\r\n\t]/g, '') } function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) const short = `${base}_${imageId.slice(0, 8)}${ext}` if (!existing.has(short)) return short return `${base}_${imageId}${ext}` } export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const parsed = await parseRequest(fileExportContract, request, context) if (!parsed.success) return parsed.response const { id } = parsed.data.params const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = authResult.userId const record = await getFileMetadataById(id) if (!record) { logger.warn('File not found by ID', { id }) return NextResponse.json({ error: 'Not found' }, { status: 404 }) } const hasAccess = await verifyFileAccess(record.key, userId) if (!hasAccess) { logger.warn('Unauthorized file export attempt', { id, userId }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } /** * Records the egress only at a real success exit (serve redirect, plain * markdown, or bundled zip) so a mid-export failure never logs a download * that never happened. */ const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, actorId: userId, action: AuditAction.FILE_DOWNLOADED, resourceType: AuditResourceType.FILE, resourceId: record.id, resourceName: record.originalName, description: `Exported file "${record.originalName}"`, metadata: { fileId: record.id, fileName: record.originalName, bytes: record.size, format, assetCount, }, request, }) captureServerEvent( userId, 'file_downloaded', { ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), is_bulk: assetCount > 0, file_count: 1 + assetCount, }, record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined ) } if (!isMarkdown(record.originalName, record.contentType)) { const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` auditExport('file', 0) return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) } const mdBuffer = await downloadFile({ key: record.key, context: record.context as StorageContext, }) let mdContent = mdBuffer.toString('utf-8') const imageIds = extractEmbeddedImageIds(mdContent) logger.info('Exporting markdown', { id, imageCount: imageIds.length }) if (imageIds.length === 0) { const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') auditExport('markdown', 0) return new NextResponse(new Uint8Array(mdBytes), { status: 200, headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`, 'Content-Length': String(mdBytes.length), }, }) } const fetchResults = await Promise.allSettled( imageIds.map(async (imageId) => { const imgRecord = await getFileMetadataById(imageId) if (!imgRecord) return null const imgHasAccess = await verifyFileAccess(imgRecord.key, userId) if (!imgHasAccess) return null const imgBuffer = await downloadFile({ key: imgRecord.key, context: imgRecord.context as StorageContext, }) return { imageId, originalName: imgRecord.originalName, buffer: imgBuffer } }) ) const assetMap = new Map() const usedFilenames = new Set() for (let i = 0; i < fetchResults.length; i++) { const result = fetchResults[i] if (result.status === 'rejected') { logger.warn('Failed to fetch asset for export', { imageId: imageIds[i], error: toError(result.reason).message, }) continue } if (!result.value) continue const { imageId, originalName, buffer } = result.value const preferred = safeFilename(originalName) const filename = deduplicatedFilename(preferred, usedFilenames, imageId) usedFilenames.add(filename) assetMap.set(imageId, { filename, buffer }) } for (const [imageId, asset] of assetMap) { const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const replacement = `./assets/${asset.filename}` // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app // `/workspace//files/` path — so a bundled asset never leaves a broken link in the export. mdContent = mdContent .replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement) .replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement) } const zip = new JSZip() zip.file(safeFilename(record.originalName), mdContent) const assetsFolder = zip.folder('assets')! for (const { filename, buffer } of assetMap.values()) { assetsFolder.file(filename, buffer) } const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) auditExport('zip', assetMap.size) return new NextResponse(new Uint8Array(zipBuffer), { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`, 'Content-Length': String(zipBuffer.length), }, }) } )