chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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<string>, 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<string, { filename: string; buffer: Buffer }>()
|
||||
const usedFilenames = new Set<string>()
|
||||
|
||||
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/<ws>/files/<id>` 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user