58 lines
2.5 KiB
TypeScript

import { randomUUID } from 'crypto'
import { lstat, mkdir, readFile, unlink, writeFile } from 'fs/promises'
import path from 'path'
import { getUploadRootDir } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
const ID = /^[a-zA-Z0-9_-]{1,80}$/
const KEY = /^[a-zA-Z0-9_-]{1,80}\/[a-f0-9-]{36}\.[a-z0-9]{1,8}$/
// Keep files outside the generic /uploads serving roots, including on local dev.
export function getDetailStorageRoot(): string {
const root = path.resolve(process.env.DETAIL_GENERATOR_STORAGE_DIR || path.join(getUploadRootDir(), '..', 'detail-generator-private'))
const uploadRoot = path.resolve(getUploadRootDir())
const publicRoot = path.resolve(process.cwd(), 'public')
for (const unsafe of [uploadRoot, publicRoot]) {
const relative = path.relative(unsafe, root)
if (!relative || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) {
throw new Error('详情页私有目录必须位于 uploads 和 public 之外')
}
}
return root
}
async function resolvePrivateFile(key: string, create = false): Promise<string> {
if (!KEY.test(key)) throw new Error('无效的详情页文件标识')
const root = getDetailStorageRoot()
const file = path.join(root, ...key.split('/'))
if (create) {
if (!(await isPathFreeOfSymbolicLinks(path.parse(root).root, path.dirname(file), { allowMissingDescendants: true }))) {
throw new Error('详情页文件路径不可用')
}
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
}
if (!(await isPathFreeOfSymbolicLinks(path.parse(root).root, create ? path.dirname(file) : file))) {
throw new Error('详情页文件路径不可用')
}
return file
}
export async function saveDetailFile(projectId: string, buffer: Buffer, extension: string): Promise<string> {
if (!ID.test(projectId) || !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error('无效的详情页文件标识')
const key = `${projectId}/${randomUUID()}.${extension}`
await writeFile(await resolvePrivateFile(key, true), buffer, { flag: 'wx', mode: 0o600 })
return key
}
export async function readDetailFile(key: string): Promise<Buffer> {
const file = await resolvePrivateFile(key)
if (!(await lstat(file)).isFile()) throw new Error('详情页文件不可用')
return readFile(file)
}
export async function deleteDetailFile(key: string): Promise<void> {
const file = await resolvePrivateFile(key)
if (!(await lstat(file)).isFile()) throw new Error('详情页文件不可用')
await unlink(file)
}