Skip to content

Commit 363521c

Browse files
committed
feat(files): export markdown as zip with embedded images in assets/ folder
1 parent 31cfb74 commit 363521c

4 files changed

Lines changed: 148 additions & 8 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import JSZip from 'jszip'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { fileExportContract } from '@/lib/api/contracts/storage-transfer'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
11+
import { downloadFile } from '@/lib/uploads/core/storage-service'
12+
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
13+
import { verifyFileAccess } from '@/app/api/files/authorization'
14+
15+
const logger = createLogger('FilesExportAPI')
16+
17+
const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown'])
18+
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
19+
const VIEW_URL_RE =
20+
/\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi
21+
22+
function isMarkdown(originalName: string, contentType: string): boolean {
23+
if (MARKDOWN_MIME_TYPES.has(contentType)) return true
24+
const ext = originalName.split('.').pop()?.toLowerCase() ?? ''
25+
return MARKDOWN_EXTENSIONS.has(ext)
26+
}
27+
28+
export const GET = withRouteHandler(
29+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
30+
const parsed = await parseRequest(fileExportContract, request, context)
31+
if (!parsed.success) return parsed.response
32+
33+
const { id } = parsed.data.params
34+
35+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
36+
if (!authResult.success || !authResult.userId) {
37+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
38+
}
39+
40+
const record = await getFileMetadataById(id)
41+
if (!record) {
42+
logger.warn('File not found by ID', { id })
43+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
44+
}
45+
46+
const hasAccess = await verifyFileAccess(record.key, authResult.userId)
47+
if (!hasAccess) {
48+
logger.warn('Unauthorized file export attempt', { id, userId: authResult.userId })
49+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
50+
}
51+
52+
if (!isMarkdown(record.originalName, record.contentType)) {
53+
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
54+
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
55+
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
56+
}
57+
58+
const mdBuffer = await downloadFile({ key: record.key, context: record.context as 'workspace' })
59+
let mdContent = mdBuffer.toString('utf-8')
60+
61+
const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))]
62+
logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length })
63+
64+
const assetMap = new Map<string, { filename: string; buffer: Buffer }>()
65+
66+
await Promise.allSettled(
67+
imageIds.map(async (imageId) => {
68+
try {
69+
const imgRecord = await getFileMetadataById(imageId)
70+
if (!imgRecord) return
71+
const imgHasAccess = await verifyFileAccess(imgRecord.key, authResult.userId)
72+
if (!imgHasAccess) return
73+
const imgBuffer = await downloadFile({
74+
key: imgRecord.key,
75+
context: imgRecord.context as 'workspace',
76+
})
77+
assetMap.set(imageId, { filename: imgRecord.originalName, buffer: imgBuffer })
78+
} catch (err) {
79+
logger.warn('Failed to fetch asset for export', { imageId, error: toError(err).message })
80+
}
81+
})
82+
)
83+
84+
for (const [imageId, asset] of assetMap) {
85+
const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
86+
mdContent = mdContent.replace(
87+
new RegExp(`/api/files/view/${escapedId}`, 'g'),
88+
`./assets/${asset.filename}`
89+
)
90+
}
91+
92+
const zip = new JSZip()
93+
zip.file(record.originalName, mdContent)
94+
const assets = zip.folder('assets')!
95+
for (const { filename, buffer } of assetMap.values()) {
96+
assets.file(filename, buffer)
97+
}
98+
99+
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
100+
const zipName = `${record.originalName.replace(/\.[^.]+$/, '')}.zip`
101+
102+
return new NextResponse(zipBuffer, {
103+
status: 200,
104+
headers: {
105+
'Content-Type': 'application/zip',
106+
'Content-Disposition': `attachment; filename="${zipName}"`,
107+
'Content-Length': String(zipBuffer.length),
108+
},
109+
})
110+
}
111+
)

apps/sim/lib/api/contracts/storage-transfer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ export const fileViewParamsSchema = z.object({
454454
id: z.string().uuid('File ID must be a valid UUID'),
455455
})
456456

457+
export const fileExportParamsSchema = z.object({
458+
id: z.string().uuid('File ID must be a valid UUID'),
459+
})
460+
457461
export const boxUploadContract = defineRouteContract({
458462
method: 'POST',
459463
path: '/api/tools/box/upload',
@@ -707,6 +711,13 @@ export const fileViewContract = defineRouteContract({
707711
response: { mode: 'binary' },
708712
})
709713

714+
export const fileExportContract = defineRouteContract({
715+
method: 'GET',
716+
path: '/api/files/export/[id]',
717+
params: fileExportParamsSchema,
718+
response: { mode: 'binary' },
719+
})
720+
710721
export type BoxUploadBody = ContractBodyInput<typeof boxUploadContract>
711722
export type BoxUploadResponse = ContractJsonResponse<typeof boxUploadContract>
712723
export type DropboxUploadBody = ContractBodyInput<typeof dropboxUploadContract>
@@ -749,3 +760,4 @@ export type GetMultipartPartUrlsBody = z.output<typeof getMultipartPartUrlsBodyS
749760
export type FileServeParams = ContractParamsInput<typeof fileServeContract>
750761
export type FileServeQuery = ContractQueryInput<typeof fileServeContract>
751762
export type FileViewParams = ContractParamsInput<typeof fileViewContract>
763+
export type FileExportParams = ContractParamsInput<typeof fileExportContract>

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -797,20 +797,37 @@ export function getViewerUrl(fileKey: string, workspaceId?: string): string | nu
797797
}
798798

799799
/**
800-
* Downloads a workspace file to the user's device via the serve API.
801-
* Fetches the file as a blob and triggers a browser download.
800+
* Downloads a workspace file to the user's device.
801+
* Markdown files with embedded images are exported as a zip with an assets/ folder.
802+
* All other files are fetched directly from the serve API.
802803
*/
803-
export async function downloadWorkspaceFile(file: { key: string; name: string }): Promise<void> {
804-
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}`
805-
const response = await fetch(serveUrl, { cache: 'no-store' })
804+
export async function downloadWorkspaceFile(file: {
805+
id?: string
806+
key: string
807+
name: string
808+
type?: string
809+
}): Promise<void> {
810+
const isMarkdown =
811+
file.type === 'text/markdown' ||
812+
file.type === 'text/x-markdown' ||
813+
/\.(?:md|markdown)$/i.test(file.name)
814+
815+
const fetchUrl =
816+
isMarkdown && file.id
817+
? `/api/files/export/${encodeURIComponent(file.id)}`
818+
: `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${Date.now()}`
819+
820+
const response = await fetch(fetchUrl, { cache: 'no-store' })
806821
if (!response.ok) {
807822
throw new Error(`Failed to download file: ${response.statusText}`)
808823
}
809824
const blob = await response.blob()
810825
const url = URL.createObjectURL(blob)
811826
const a = document.createElement('a')
812827
a.href = url
813-
a.download = file.name
828+
const contentDisposition = response.headers.get('Content-Disposition')
829+
const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/)
830+
a.download = filenameMatch?.[1] ?? file.name
814831
document.body.appendChild(a)
815832
a.click()
816833
document.body.removeChild(a)

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 722,
13-
zodRoutes: 722,
12+
totalRoutes: 723,
13+
zodRoutes: 723,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)