Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

291 lines
8.5 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FilesUtils')
export interface ApiSuccessResponse {
success: true
[key: string]: any
}
interface ApiErrorResponse {
error: string
message?: string
}
export interface FileResponse {
buffer: Buffer
contentType: string
filename: string
cacheControl?: string
}
export class FileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'FileNotFoundError'
}
}
export class InvalidRequestError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidRequestError'
}
}
export const contentTypeMap: Record<string, string> = {
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
md: 'text/markdown',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'application/typescript',
pdf: 'application/pdf',
googleDoc: 'application/vnd.google-apps.document',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
googleSheet: 'application/vnd.google-apps.spreadsheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
opus: 'audio/opus',
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
webm: 'video/webm',
zip: 'application/zip',
googleFolder: 'application/vnd.google-apps.folder',
}
export const binaryExtensions = [
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'zip',
'png',
'jpg',
'jpeg',
'gif',
'webp',
'pdf',
]
export function getContentType(filename: string): string {
const extension = filename.split('.').pop()?.toLowerCase() || ''
return contentTypeMap[extension] || 'application/octet-stream'
}
export function extractFilename(path: string): string {
let filename: string
if (path.startsWith('/api/files/serve/')) {
filename = path.substring('/api/files/serve/'.length)
} else {
filename = path.split('/').pop() || path
}
filename = filename
.replace(/\.\./g, '')
.replace(/\/\.\./g, '')
.replace(/\.\.\//g, '')
if (filename.startsWith('s3/') || filename.startsWith('blob/') || filename.startsWith('gcs/')) {
const parts = filename.split('/')
const prefix = parts[0] // 's3', 'blob', or 'gcs'
const keyParts = parts.slice(1)
const sanitizedKeyParts = keyParts
.map((part) => part.replace(/\.\./g, '').replace(/^\./g, '').trim())
.filter((part) => part.length > 0)
filename = `${prefix}/${sanitizedKeyParts.join('/')}`
} else {
filename = filename.replace(/[/\\]/g, '')
}
if (!filename || filename.trim().length === 0) {
throw new Error('Invalid or empty filename after sanitization')
}
return filename
}
export async function findLocalFile(filename: string): Promise<string | null> {
try {
const sanitizedFilename = sanitizeFileKey(filename)
if (!sanitizedFilename || !sanitizedFilename.trim() || /^[/\\.\s]+$/.test(sanitizedFilename)) {
return null
}
const { existsSync } = await import('fs')
const path = await import('path')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server')
const resolvedPath = path.join(UPLOAD_DIR_SERVER, sanitizedFilename)
if (
!resolvedPath.startsWith(UPLOAD_DIR_SERVER + path.sep) ||
resolvedPath === UPLOAD_DIR_SERVER
) {
return null
}
if (existsSync(resolvedPath)) {
return resolvedPath
}
return null
} catch (error) {
logger.error('Error in findLocalFile:', error)
return null
}
}
const SAFE_INLINE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/svg+xml',
'image/webp',
'image/avif',
'image/bmp',
'image/x-icon',
'application/pdf',
'text/plain',
'text/csv',
'application/json',
])
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml'])
function getSecureFileHeaders(filename: string, originalContentType: string) {
const extension = filename.split('.').pop()?.toLowerCase() || ''
if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) {
return {
contentType: 'application/octet-stream',
disposition: 'attachment',
}
}
let safeContentType = originalContentType
if (originalContentType === 'text/html') {
safeContentType = 'text/plain'
}
const disposition = SAFE_INLINE_TYPES.has(safeContentType) ? 'inline' : 'attachment'
return {
contentType: safeContentType,
disposition,
}
}
/**
* Percent-encode a filename as an RFC 8187 `ext-value`.
*
* `encodeURIComponent` alone is not enough: it leaves `'`, `(`, `)` and `*` raw, and
* none of those are `attr-char`. The apostrophe is the specific hazard — it is the
* delimiter in `UTF-8''name`, so a filename like `it's.pdf` would emit a third `'`
* and desync the parser.
*/
function encodeExtValue(filename: string): string {
return encodeURIComponent(filename).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
)
}
/**
* Build the `filename` parameters for a Content-Disposition header.
*
* The name is attacker-controlled (it is the user's `originalName`), so it can never
* be interpolated raw: a `"` closes the quoted-string early and everything after it
* is parsed as further parameters. An injected `filename*` is the payload that
* matters, because RFC 6266 tells clients to prefer `filename*` over `filename` —
* so the attacker's value wins and the download lands under a name the product UI
* never showed. Both parameters are therefore always emitted from sanitized input:
* the quoted form keeps only printable ASCII minus `"` and `\`, and the `filename*`
* form is fully percent-encoded.
*
* `;` is neutralized too, even though a quoted string may legally contain one: the
* quoted parameter exists as the fallback for clients that do not implement
* `filename*`, and those are the same clients liable to split parameters on a bare
* `;` without honouring the quoting. The exact name still survives in `filename*`.
*/
export function encodeFilenameForHeader(storageKey: string): string {
const filename = storageKey.split('/').pop() || storageKey
const asciiSafe = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\;]/g, '_')
// Unchanged input proves the name is printable ASCII with no `"` or `\`, so the
// quoted form alone is both safe and sufficient — `filename*` buys nothing here.
if (asciiSafe === filename) {
return `filename="${filename}"`
}
return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}`
}
export function createFileResponse(file: FileResponse): NextResponse {
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
// Default to PRIVATE: this response is served only after access verification, so it must never be
// stored by a shared cache/CDN and re-served cross-user. Genuinely public assets (avatars, OG images,
// workspace logos) pass an explicit `cacheControl` (see PUBLIC_ASSET_CACHE_CONTROL in the serve route).
'Cache-Control': file.cacheControl || 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}
if (contentType === 'image/svg+xml') {
headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;"
}
return new NextResponse(file.buffer as BodyInit, { status: 200, headers })
}
export function createErrorResponse(error: Error, status = 500): NextResponse {
const statusCode =
error instanceof FileNotFoundError ? 404 : error instanceof InvalidRequestError ? 400 : status
return NextResponse.json(
{
error: error.name,
message: error.message,
},
{ status: statusCode }
)
}
export function createSuccessResponse(data: ApiSuccessResponse): NextResponse {
return NextResponse.json(data)
}