149 lines
11 KiB
TypeScript
149 lines
11 KiB
TypeScript
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<string, any>
|
||
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<unknown>) {
|
||
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<Row[]>('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<ReturnType<typeof open>> | undefined, moved = false, committed = false
|
||
try {
|
||
// Quota is checked before transfer as well as under the final transaction lock.
|
||
const initial = await db.$queryRawUnsafe<Row[]>('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<typeof setTimeout> | undefined
|
||
const chunk = await Promise.race([reader!.read(), new Promise<never>((_, 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<Row[]>('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<Row[]>('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<unknown>) {
|
||
const expected = sourceHash(source)
|
||
await reauthorize(); await assertAlive(ownerId, projectId)
|
||
const rows = await db.$queryRawUnsafe<Row[]>('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}` } : {}),
|
||
} })
|
||
}
|