import { createHash, randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises' import path from 'node:path' import { Readable } from 'node:stream' import { prisma } from '@/lib/prisma' import { isPathFreeOfSymbolicLinks } from '@/lib/path-security' import { getDetailStorageRoot } from '@/lib/detail-generator/storage' import { desktopAssert, desktopId, DesktopClientError } from './contract.mjs' export const NATIVE_FILE_LIMIT = 512 * 1024 * 1024 export const NATIVE_PROJECT_LIMIT = 1024 * 1024 * 1024 export const NATIVE_MIME = 'application/x-photocraft-resource' const db = prisma type Row = Record const sourceHash = (source: string) => { desktopAssert(/^native:[a-f0-9]{64}$/.test(source), '工程文件编号无效'); return source.slice(7) } const ownerHash = (ownerId: string) => createHash('sha256').update(ownerId).digest('hex') const keyFor = (ownerId: string, projectId: string, source: string) => `desktop-native/${ownerHash(ownerId)}/${desktopId(projectId)}/${sourceHash(source)}.bin` async function privatePath(key: string, create = false) { desktopAssert(/^desktop-native\/[a-f0-9]{64}\/[a-zA-Z0-9_-]{1,80}\/[a-f0-9]{64}\.bin$/.test(key), '工程文件路径无效') const root = getDetailStorageRoot(), file = path.join(root, ...key.split('/')) desktopAssert(await isPathFreeOfSymbolicLinks(path.parse(root).root, path.dirname(file), { allowMissingDescendants: create }), '工程文件目录不可用', 503) if (create) await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }) desktopAssert(await isPathFreeOfSymbolicLinks(path.parse(root).root, path.dirname(file)), '工程文件目录不可用', 503) return file } async function assertAlive(ownerId: string, projectId: string, tx: any = db) { const rows = await tx.$queryRawUnsafe('SELECT id FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL', desktopId(projectId), ownerId) desktopAssert(rows[0], '项目不存在或已删除', 404, 'not_found') } /** Every request is bounded; the archive itself never resides in a JS Buffer. */ export async function putNativeFile(ownerId: string, projectId: string, source: string, request: Request, reauthorize: () => Promise) { const expected = sourceHash(source), size = Number(request.headers.get('content-length')) desktopAssert(Number.isSafeInteger(size) && size > 0 && size <= NATIVE_FILE_LIMIT, '工程文件需提供有效长度,最大512MiB', 413) desktopAssert(request.headers.get('x-content-sha256') === expected, '工程文件校验值与编号不一致') desktopAssert(['application/octet-stream', NATIVE_MIME].includes((request.headers.get('content-type') || '').split(';')[0]), '工程文件类型无效', 415) await reauthorize(); await assertAlive(ownerId, projectId) const existing = await db.$queryRawUnsafe('SELECT sha256,byte_size AS bytes FROM desktop_workspace_assets WHERE owner_id=$1 AND project_id=$2 AND source=$3', ownerId, projectId, source) if (existing[0]) { desktopAssert(existing[0].sha256 === expected && existing[0].bytes === size, '工程文件内容冲突', 409) const retainedFile = await privatePath(keyFor(ownerId, projectId, source)), stat = await lstat(retainedFile) desktopAssert(stat.isFile() && stat.size === size, '已备份工程文件不可用,请保留本机文件联系管理员', 503, 'storage_unavailable') await request.body?.cancel(); await reauthorize(); await assertAlive(ownerId, projectId) return { source, sha256: expected, bytes: size, existing: true } } const key = keyFor(ownerId, projectId, source), destination = await privatePath(key, true) const lockPath = path.join(path.dirname(destination), '.upload.lock'), temporary = destination + '.' + randomUUID() + '.part' const lock = await db.$transaction(async tx => { // Serializing acquisition also makes stale-lock recovery safe across workers. await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:' + projectId) await assertAlive(ownerId, projectId, tx) try { return await open(lockPath, 'wx', 0o600) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error const stat = await lstat(lockPath) // Transfers have a ten-minute total deadline. A twenty-minute lock is a crashed worker. desktopAssert(stat.isFile() && !stat.isSymbolicLink() && Date.now() - stat.mtimeMs > 20 * 60_000, '此项目正在同步工程文件,请稍后继续', 409, 'upload_busy') await unlink(lockPath) for (const name of await readdir(path.dirname(destination))) if (/^[a-f0-9]{64}\.bin\.[a-f0-9-]{36}\.part$/.test(name)) { const partial = path.join(path.dirname(destination), name), old = await lstat(partial) if (old.isFile() && Date.now() - old.mtimeMs > 20 * 60_000) await unlink(partial) } return open(lockPath, 'wx', 0o600) } }) const deadline = Date.now() + 10 * 60_000 let handle: Awaited> | undefined, moved = false, committed = false try { // Quota is checked before transfer as well as under the final transaction lock. const initial = await db.$queryRawUnsafe('SELECT COALESCE(SUM(byte_size),0)::bigint AS bytes,COUNT(*)::int AS count FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', projectId, ownerId) desktopAssert(Number(initial[0].bytes) + size <= NATIVE_PROJECT_LIMIT && initial[0].count < 160, '项目云备份已达1GiB或160个文件上限', 413, 'project_quota') handle = await open(temporary, 'wx', 0o600) const hash = createHash('sha256'), reader = request.body?.getReader(); let received = 0 desktopAssert(reader, '工程文件为空') try { while (true) { const remaining = Math.min(60_000, deadline - Date.now()) desktopAssert(remaining > 0, '工程文件同步超时,请保留本机文件继续同步', 408, 'upload_timeout') let timer: ReturnType | undefined const chunk = await Promise.race([reader!.read(), new Promise((_, reject) => { timer = setTimeout(() => reject(new DesktopClientError('工程文件传输超时', 408, 'upload_timeout')), remaining) })]).finally(() => clearTimeout(timer)) if (chunk.done) break received += chunk.value.byteLength desktopAssert(received <= size, '工程文件长度超过声明', 413) hash.update(chunk.value); await handle.writeFile(chunk.value) } } catch (error) { await reader!.cancel().catch(() => undefined); throw error } desktopAssert(received === size && hash.digest('hex') === expected, '工程文件校验失败,请保留本机文件重新同步', 422, 'checksum_mismatch') await handle.sync(); await handle.close(); handle = undefined await reauthorize(); await assertAlive(ownerId, projectId) await db.$transaction(async tx => { await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:' + projectId) await assertAlive(ownerId, projectId, tx) const total = await tx.$queryRawUnsafe('SELECT COALESCE(SUM(byte_size),0)::bigint AS bytes,COUNT(*)::int AS count FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', projectId, ownerId) desktopAssert(Number(total[0].bytes) + size <= NATIVE_PROJECT_LIMIT && total[0].count < 160, '项目云备份已达1GiB或160个文件上限', 413, 'project_quota') // Hash-addressed file names are immutable. Only this project's upload lock can install one. await rename(temporary, destination); moved = true await tx.$executeRawUnsafe('INSERT INTO desktop_workspace_assets(id,project_id,owner_id,source,sha256,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5,$6,$7,$8)', randomUUID(), projectId, ownerId, source, expected, key, NATIVE_MIME, size) }) committed = true return { source, sha256: expected, bytes: size, existing: false } } catch (error) { // A connection can fail after COMMIT. Never delete a file that the DB may reference. if (moved && !committed) { const retained = await db.$queryRawUnsafe('SELECT id FROM desktop_workspace_assets WHERE owner_id=$1 AND project_id=$2 AND source=$3', ownerId, projectId, source).catch(() => null) if (retained && !retained.length) await unlink(destination).catch(() => undefined) } throw error } finally { await handle?.close().catch(() => undefined) await unlink(temporary).catch(() => undefined) await lock.close(); await unlink(lockPath).catch(() => undefined) } } export function nativeRange(value: string | null, size: number) { if (!value) return { start: 0, end: size - 1, partial: false } const match = /^bytes=(\d*)-(\d*)$/.exec(value) desktopAssert(match && (match[1] || match[2]), '文件范围无效', 416, 'invalid_range') let start = match![1] ? Number(match![1]) : Math.max(0, size - Number(match![2])) let end = match![1] ? match![2] ? Number(match![2]) : size - 1 : size - 1 desktopAssert(Number.isSafeInteger(start) && Number.isSafeInteger(end) && start >= 0 && start < size && end >= start, '文件范围无效', 416, 'invalid_range') end = Math.min(end, size - 1) return { start, end, partial: true } } export async function getNativeFile(ownerId: string, projectId: string, source: string, request: Request, reauthorize: () => Promise) { const expected = sourceHash(source) await reauthorize(); await assertAlive(ownerId, projectId) const rows = await db.$queryRawUnsafe('SELECT storage_key AS key,sha256,byte_size AS bytes,mime_type AS mime FROM desktop_workspace_assets WHERE owner_id=$1 AND project_id=$2 AND source=$3', ownerId, projectId, source) const row = rows[0] desktopAssert(row && row.mime === NATIVE_MIME && row.sha256 === expected && row.key === keyFor(ownerId, projectId, source), '工程文件不存在', 404) const file = await privatePath(row.key) desktopAssert(await isPathFreeOfSymbolicLinks(path.parse(file).root, file), '工程文件不可用', 503) const stat = await lstat(file) desktopAssert(stat.isFile() && stat.size === row.bytes, '工程文件校验失败', 503) const range = nativeRange(request.headers.get('range'), stat.size) // Revocation/deletion is rechecked immediately before the first byte leaves the server. await reauthorize(); await assertAlive(ownerId, projectId) const stream = createReadStream(file, { start: range.start, end: range.end, highWaterMark: 64 * 1024 }) request.signal.addEventListener('abort', () => stream.destroy(), { once: true }) return new Response(Readable.toWeb(stream) as ReadableStream, { status: range.partial ? 206 : 200, headers: { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="project-resource.bin"', 'Accept-Ranges': 'bytes', 'Content-Length': String(range.end - range.start + 1), 'X-Content-SHA256': expected, ...(range.partial ? { 'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}` } : {}), } }) }