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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+203
View File
@@ -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),
},
})
}
)