gongxiangp2/lib/desktop-client/native-storage.ts

149 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}` } : {}),
} })
}