108 lines
11 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'
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,updated_at AS "updatedAt",width,screen_count AS "screenCount",has_draft AS "hasDraft" FROM (
SELECT p.id,'desktop' AS source,p.document->>'name' AS name,p.status,p.revision,p.updated_at,(p.document->'screens'->0->>'width')::int AS width,jsonb_array_length(p.document->'screens') AS screen_count,(p.status='draft' OR p.composer<>'') AS has_draft FROM desktop_workspace_projects p WHERE p.owner_id=$1
UNION ALL SELECT p.id,'web',p.document->>'name',CASE WHEN p.document->>'approvedAt' IS NULL THEN 'draft' ELSE 'active' END,p.revision,p.updated_at,COALESCE((p.document->'screens'->0->>'width')::int,750),jsonb_array_length(p.document->'screens'),p.document->>'approvedAt' IS NULL FROM detail_generator_projects p WHERE p.owner_id=$1 AND p.deleted_at IS NULL AND 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 items = rows.slice(0,50), last = items.at(-1)
return { items, 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`, 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)
const rows = body.revision === 0 ? await db.$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 db.$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 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 putDesktopAsset(ownerId:string,projectId:string,source:string,bytes:Buffer,mime:string) {
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 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)
desktopAssert(total[0].bytes+bytes.length<=256*1024*1024 && total[0].count<128,'项目文件超过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);return {bytes:await readDetailFile(rows[0].key),mime:rows[0].mime}
}