140 lines
15 KiB
TypeScript

import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import { prisma } from '@/lib/prisma'
import { assertDetailGeneratorUser } from '@/lib/detail-generator/access'
import { findDetailProject, listDetailAssets, findDetailAsset } from '@/lib/detail-generator/repository'
import { readDetailFile, saveDetailFile, deleteDetailFile } from '@/lib/detail-generator/storage'
import { desktopAssert, desktopAuthorization, desktopId, desktopProjectBody, DesktopClientError, DESKTOP_CLIENT_ID, DESKTOP_SCOPES } from './contract.mjs'
import { deleteLinkedProject, projectDeletionSummaries } from './project-lifecycle'
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
const db = prisma
type Row = Record<string, any>
const columns = 'id,owner_id AS "ownerId",source_web_id AS "sourceWebId",document,conversation,composer,status,revision,updated_at AS "updatedAt"'
export async function desktopUser(id: string) {
await assertDetailGeneratorUser(id)
const user = await prisma.user.findUnique({ where: { id }, select: { id: true, username: true, realName: true } })
desktopAssert(user, '账户不存在', 401)
return { id, name: user!.realName || user!.username }
}
export async function approveDesktop(ownerId: string, input: Record<string, unknown>) {
await desktopUser(ownerId)
const auth = desktopAuthorization(input)
const code = 'dca_' + randomBytes(32).toString('base64url')
await db.$executeRawUnsafe('INSERT INTO desktop_client_grants(id,code_hash,owner_id,challenge,redirect_uri,expires_at) VALUES($1,$2,$3,$4,$5,$6)', randomUUID(), hash(code), ownerId, auth.challenge, auth.redirectUri, new Date(Date.now() + 60000))
const callback = new URL(auth.redirectUri); callback.searchParams.set('code', code); callback.searchParams.set('state', auth.state)
return { redirect: callback.href }
}
export async function exchangeDesktop(input: Record<string, any>) {
desktopAssert(input.client_id === DESKTOP_CLIENT_ID && input.grant_type === 'authorization_code' && /^dca_[\w-]{43}$/.test(input.code || '') && /^[a-zA-Z0-9._~-]{43,128}$/.test(input.code_verifier || ''), '授权码或校验参数无效', 400, 'invalid_grant')
const rows = await db.$queryRawUnsafe<Row[]>('SELECT id,owner_id AS "ownerId",challenge,redirect_uri AS "redirectUri" FROM desktop_client_grants WHERE code_hash=$1 AND used_at IS NULL AND expires_at>NOW()', hash(input.code))
const grant = rows[0]
desktopAssert(grant && grant.redirectUri === input.redirect_uri, '授权已失效,请重新登录', 400, 'invalid_grant')
const challenge = createHash('sha256').update(input.code_verifier).digest('base64url')
desktopAssert(challenge.length === grant.challenge.length && timingSafeEqual(Buffer.from(challenge), Buffer.from(grant.challenge)), '授权校验未通过,请重新登录', 400, 'invalid_grant')
const account = await desktopUser(grant.ownerId)
const token = 'dcs_' + randomBytes(48).toString('base64url'), id = randomUUID(), expiresAt = new Date(Date.now() + 7 * 86400000)
await db.$transaction(async tx => {
const consumed = await tx.$executeRawUnsafe('UPDATE desktop_client_grants SET used_at=NOW() WHERE id=$1 AND used_at IS NULL AND expires_at>NOW()', grant.id)
desktopAssert(consumed === 1, '授权码已使用,请重新登录', 400, 'invalid_grant')
await tx.$executeRawUnsafe('INSERT INTO desktop_client_sessions(id,token_hash,owner_id,device_name,expires_at) VALUES($1,$2,$3,$4,$5)', id, hash(token), account.id, '详情页工作台', expiresAt)
})
return { access_token: token, token_type: 'Bearer', expires_at: expiresAt.toISOString(), account, scope: DESKTOP_SCOPES.join(' ') }
}
export async function requireDesktop(request: Request) {
const token = request.headers.get('authorization')?.match(/^Bearer (dcs_[\w-]{64})$/)?.[1]
desktopAssert(token, '请在网页上登录并授权客户端', 401, 'unauthenticated')
const rows = await db.$queryRawUnsafe<Row[]>('SELECT id,owner_id AS "ownerId",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>NOW()', hash(token!))
desktopAssert(rows[0], '客户端授权已过期或被撤销,请重新登录', 401, 'session_expired')
return { id:String(rows[0].id),expiresAt:rows[0].expiresAt,account: await desktopUser(rows[0].ownerId) }
}
export async function revokeDesktop(id: string, ownerId: string) { await db.$executeRawUnsafe('UPDATE desktop_client_sessions SET revoked_at=NOW() WHERE id=$1 AND owner_id=$2', id, ownerId) }
export async function listDesktopDevices(ownerId: string) {
return db.$queryRawUnsafe<Row[]>('SELECT id,device_name AS "deviceName",created_at AS "createdAt",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE owner_id=$1 AND revoked_at IS NULL AND expires_at>NOW() ORDER BY created_at DESC', ownerId)
}
export async function listDesktopProjects(ownerId: string, cursor: string | null) {
let point = { date: '9999-01-01T00:00:00.000Z', id: 'zzzzzzzz', source: 'web' }
if (cursor) {
try { point = JSON.parse(Buffer.from(cursor, 'base64url').toString()) } catch { throw new DesktopClientError('分页参数无效') }
desktopAssert(typeof point.date === 'string' && Number.isFinite(Date.parse(point.date)) && typeof point.id === 'string' && /^[\w-]{1,80}$/.test(point.id) && ['web','desktop'].includes(point.source), '分页参数无效')
}
const rows = await db.$queryRawUnsafe<Row[]>(`SELECT id,source,name,status,revision,source_web_id AS "sourceWebId",updated_at AS "updatedAt",width,screen_count AS "screenCount",has_draft AS "hasDraft",deleted_at AS "deletedAt",preview_source AS "previewSource",backup_state AS "backupState" FROM (
SELECT p.id,'desktop' AS source,p.document->>'name' AS name,p.status,p.revision,p.source_web_id,p.updated_at,(p.document->'screens'->0->>'width')::int AS width,jsonb_array_length(p.document->'screens') AS screen_count,(p.status<>'completed' OR p.composer<>'') AS has_draft,p.deleted_at,p.document->'screens'->0->>'previewSource' AS preview_source,CASE WHEN p.document->>'version'='3' AND (p.document->'native'->>'documentSource' IS NULL OR EXISTS(SELECT 1 FROM jsonb_array_elements(p.document->'screens') screen WHERE screen->>'previewSource' IS NULL)) THEN 'pending' ELSE 'ready' END AS backup_state FROM desktop_workspace_projects p WHERE p.owner_id=$1
UNION ALL SELECT p.id,'web',p.document->>'name','draft',p.revision,NULL::text,p.updated_at,COALESCE((p.document->'screens'->0->>'width')::int,750),jsonb_array_length(p.document->'screens'),true,p.deleted_at,NULL::text,'ready' FROM detail_generator_projects p WHERE p.owner_id=$1 AND (p.deleted_at IS NOT NULL OR NOT EXISTS(SELECT 1 FROM desktop_workspace_projects d WHERE d.owner_id=$1 AND d.source_web_id=p.id))
) AS projects WHERE (updated_at,id,source)<($2::timestamptz,$3::text,$4::text) ORDER BY updated_at DESC,id DESC,source DESC LIMIT 51`, ownerId, point.date, point.id, point.source)
const page = rows.slice(0,50), last = page.at(-1), items = page.filter(item=>!item.deletedAt)
const deletion = await projectDeletionSummaries(db, ownerId, items.map(item=>({ id:item.id, source:item.source })))
const summaries = new Map(deletion.map(item=>[item.source+':'+item.id,item]))
return { items:items.map(item=>({...item,...summaries.get(item.source+':'+item.id)})), deleted:page.filter(item=>item.deletedAt).map(item=>({id:item.id,source:item.source})), nextCursor: rows.length > 50 && last ? Buffer.from(JSON.stringify({date:new Date(last.updatedAt).toISOString(),id:last.id,source:last.source})).toString('base64url') : null }
}
export async function getDesktopProject(ownerId: string, source: string, id: string) {
desktopId(id)
if (source === 'web') return { source, project: await findDetailProject(id,ownerId), assets: (await listDetailAssets(id,ownerId)).filter(asset => ['image/png','image/jpeg','image/webp'].includes(asset.mimeType)).map(asset => ({ id:asset.id,source:'web:'+asset.id,mime:asset.mimeType,bytes:asset.byteSize })) }
desktopAssert(source === 'desktop', '项目来源无效')
const rows = await db.$queryRawUnsafe<Row[]>(`SELECT ${columns} FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL`, id, ownerId)
desktopAssert(rows[0], '项目不存在或无权访问', 404, 'not_found')
const assets = await db.$queryRawUnsafe<Row[]>('SELECT id,source,sha256,mime_type AS mime,byte_size AS bytes FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', id, ownerId)
return { source, project:rows[0], assets }
}
export async function putDesktopProject(ownerId: string, id: string, input: unknown) {
desktopId(id); const body = desktopProjectBody(input)
desktopAssert(body.document.id === id, '项目编号不一致')
if (body.sourceWebId) await findDetailProject(body.sourceWebId,ownerId)
return db.$transaction(async tx => {
// Match task admission/deletion lock order so no device can dispatch between
// the authoritative unfinished-task check and publishing a completed snapshot.
if (body.status === 'completed') await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop-tasks:' + ownerId)
await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:' + id)
let previousConversation: { pending?: boolean; unknown?: boolean }[] = []
if (body.revision > 0) {
const previous = await tx.$queryRawUnsafe<Row[]>('SELECT document,conversation FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2 AND revision=$3 AND deleted_at IS NULL FOR UPDATE', id, ownerId, body.revision)
desktopAssert(previous[0], '项目已有新版本或已删除,请保留本机草稿并刷新项目列表', 409, 'revision_conflict')
desktopAssert(previous[0].document.version !== 3 || body.document.version === 3, '原生工程不能被旧客户端格式覆盖,请更新客户端', 409, 'native_client_required')
previousConversation = previous[0].conversation || []
}
if (body.status === 'completed') {
desktopAssert(![...previousConversation, ...body.conversation].some(item => item.pending || item.unknown), '此项目有运行中或结果待核查的任务,请先处理原任务再完结', 409, 'task_busy')
const unfinished = await tx.$queryRawUnsafe<Row[]>("SELECT id FROM desktop_creation_tasks WHERE project_id=$1 AND owner_id=$2 AND (status IN ('queued','running','received','unknown') OR lease_until>NOW()) LIMIT 1", id, ownerId)
desktopAssert(!unfinished.length, '此项目有运行中或结果待核查的任务,请先处理原任务再完结', 409, 'task_busy')
}
if (body.document.version === 3) {
const assets = await tx.$queryRawUnsafe<Row[]>('SELECT source,sha256,byte_size AS bytes,mime_type AS mime FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', id, ownerId)
for (const file of body.document.native.files) desktopAssert(assets.some(asset => asset.source === file.source && asset.sha256 === file.sha256 && asset.bytes === file.size && asset.mime === 'application/x-photocraft-resource'), '工程文件尚未完整上传,请保留本机草稿继续同步', 409, 'assets_incomplete')
for (const screen of body.document.screens) if (screen.previewSource) desktopAssert(assets.some(asset => asset.source === screen.previewSource && ['image/png','image/jpeg','image/webp'].includes(asset.mime)), '分屏预览尚未上传', 409, 'assets_incomplete')
}
const rows = body.revision === 0 ? await tx.$queryRawUnsafe<Row[]>(`INSERT INTO desktop_workspace_projects(id,owner_id,source_web_id,document,conversation,status,composer) VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7) ON CONFLICT DO NOTHING RETURNING ${columns}`,id,ownerId,body.sourceWebId,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer)
: await tx.$queryRawUnsafe<Row[]>(`UPDATE desktop_workspace_projects SET document=$4::jsonb,conversation=$5::jsonb,status=$6,composer=$7,revision=revision+1,updated_at=NOW() WHERE id=$1 AND owner_id=$2 AND revision=$3 AND deleted_at IS NULL RETURNING ${columns}`,id,ownerId,body.revision,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer)
desktopAssert(rows[0], '项目已有新版本或已删除,请保留本机草稿并刷新项目列表', 409, 'revision_conflict')
return { project:rows[0] }
})
}
export async function deleteDesktopProject(ownerId:string,source:string,id:string,input:unknown) {
return deleteLinkedProject(db, ownerId, source, id, input)
}
export async function putDesktopAsset(ownerId:string,projectId:string,source:string,bytes:Buffer,mime:string) {
const current=await getDesktopProject(ownerId,'desktop',projectId)
desktopAssert(/^local:[a-zA-Z0-9-]{1,100}$/.test(source) && bytes.length>0 && bytes.length<=20*1024*1024, '图片编号或体积无效')
const extension = mime==='image/png'?'png':mime==='image/jpeg'?'jpg':mime==='image/webp'?'webp':''
desktopAssert(extension && (extension==='png'?bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])):extension==='jpg'?bytes[0]===255&&bytes[1]===216:bytes.toString('ascii',0,4)==='RIFF'&&bytes.toString('ascii',8,12)==='WEBP'), '仅支持 PNG、JPG、WEBP 图片')
const sha = hash(bytes)
return db.$transaction(async tx => {
await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:'+projectId)
const alive=await tx.$queryRawUnsafe<Row[]>('SELECT id FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL',projectId,ownerId)
desktopAssert(alive[0],'项目不存在或无权访问',404,'not_found')
const existing = await tx.$queryRawUnsafe<Row[]>('SELECT sha256 FROM desktop_workspace_assets WHERE project_id=$1 AND source=$2 AND owner_id=$3',projectId,source,ownerId)
if(existing[0]) { desktopAssert(existing[0].sha256===sha,'图片标识已关联其他内容',409); return {sha256:sha} }
const total = await tx.$queryRawUnsafe<Row[]>('SELECT COALESCE(SUM(byte_size),0)::int AS bytes,COUNT(*)::int AS count FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2',projectId,ownerId)
const native=current.project.document.version===3
desktopAssert(total[0].bytes+bytes.length<=(native?1024:256)*1024*1024 && total[0].count<(native?160:128),native?'项目云备份超过1GiB或160个文件限制':'项目文件超过256MiB或128张限制')
const key=await saveDetailFile(projectId,bytes,extension)
try { 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,sha,key,mime,bytes.length) }
catch(error) {await deleteDetailFile(key);throw error}
return {sha256:sha}
})
}
export async function getDesktopAsset(ownerId:string,source:string,projectId:string,assetId:string) {
await getDesktopProject(ownerId,source,projectId)
if(source==='web') { const asset=await findDetailAsset(desktopId(assetId),ownerId);desktopAssert(asset.projectId===projectId, '文件不属于此项目',404);return {bytes:await readDetailFile(asset.storageKey),mime:asset.mimeType} }
const rows=await db.$queryRawUnsafe<Row[]>('SELECT storage_key AS key,mime_type AS mime FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2 AND source=$3',projectId,ownerId,assetId)
desktopAssert(rows[0],'文件不存在',404);desktopAssert(['image/png','image/jpeg','image/webp'].includes(rows[0].mime),'请使用原生工程下载接口',415);return {bytes:await readDetailFile(rows[0].key),mime:rows[0].mime}
}