gongxiangp2/lib/desktop-client/template-packs.ts
WuYongJie 93fc28f1bb feat(client): add native template slots and safe cloud version restore
Support explicit current-output A/B fill, protected edited drafts, scoped cloud update offers and atomic native restore with stale-open CAS. Preserve original unknown requests, improve Pi conversation continuity and native DPI/navigation reads, and publish the authenticated template metadata contract.

Validated: native backend 172 passed/5 environment tests ignored, UI 110, Pi 53, template/rule API 24 and native cloud contract 6; strict Clippy, layering and wasm. Real S01 selected B, edited/undo-redo, PCraft reopen and layered PSD roundtrip pass with exact pixels. Full nine-output generation and Photoshop acceptance remain open.
2026-10-23 21:27:06 +08:00

137 lines
8.1 KiB
TypeScript

import { createHash } from 'node:crypto'
import gujia from './template-packs/gj-desk-dnz0385-native-v1.json'
import yijiada from './template-packs/yjd-desk-dnz0385-native-v1.json'
import { DesktopClientError, desktopAssert } from './contract.mjs'
type ObjectValue = Record<string, unknown>
export type DesktopTemplatePack = ObjectValue & {
schemaVersion: 1
id: string
version: string
contentHash: string
brandId: string
categoryId: string
name: string
outputs: ObjectValue[]
}
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
const digest = /^[a-f0-9]{64}$/
const identifier = /^[a-zA-Z0-9_.-]{1,120}$/
const color = /^#[a-f0-9]{6}$/i
const deniedKeys = new Set(['authorization', 'apikey', 'api_key', 'access_token', 'accesstoken', 'refresh_token', 'refreshtoken', 'password', 'secretkey', 'credentials', 'privatekey'])
function invalid(): never {
// Internal pack diagnostics and private source locations must never become a
// client error payload. Invalid packages fail closed instead of being edited.
throw new DesktopClientError('模板包暂时不可用,请联系管理员核对版本', 500, 'template_pack_invalid')
}
function object(value: unknown): ObjectValue {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid()
return value as ObjectValue
}
function text(value: unknown, max: number): value is string {
return typeof value === 'string' && value.trim().length > 0 && Array.from(value).length <= max && !/[\u0000-\u001f]/.test(value)
}
function finite(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) }
function integer(value: unknown, min: number, max: number): value is number {
return finite(value) && Number.isInteger(value) && value >= min && value <= max
}
function canonical(value: unknown): string {
return JSON.stringify(value, (_key: string, item: unknown) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, (item as ObjectValue)[key]])) : item)
}
/** Matches the native compact serde_json object serialization. */
export function templatePackHash(pack: unknown): string {
const content = { ...object(pack) }
delete content.contentHash
return createHash('sha256').update(canonical(content)).digest('hex')
}
/** Published packs contain geometry and provenance, never local source files. */
function publicMetadata(value: unknown, depth = 0): void {
if (depth > 32) invalid()
if (typeof value === 'string') {
if (/(?:^|[^a-z0-9])(?:[a-z]:[\\/]|\\\\|file:\/\/|\/(?:Users|home|root|mnt|tmp|var|srv|opt|data|volume\d*|etc|private|workspace)(?:\/|\b))/i.test(value)
|| /(?:^|[\\/])\.private(?:[\\/]|$)/i.test(value)
|| /^\//.test(value)
|| /https?:\/\/(?:localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+)(?:[:/]|$)/i.test(value)) invalid()
} else if (Array.isArray(value)) {
value.forEach(item => publicMetadata(item, depth + 1))
} else if (value && typeof value === 'object') {
for (const [key, item] of Object.entries(value)) {
if (deniedKeys.has(key.toLowerCase()) || /^(?:filePath|localPath|sourcePath|rootPath)$/i.test(key)) invalid()
publicMetadata(item, depth + 1)
}
}
}
function frame(value: unknown, width: number, height: number, line = false): void {
const box = object(value)
if (!finite(box.x) || !finite(box.y) || !finite(box.width) || !finite(box.height)
|| box.x < 0 || box.y < 0 || box.width <= 0 || box.height < 0 || (!line && box.height === 0)
|| box.x + box.width > width + 0.01 || box.y + box.height > height + 0.01) invalid()
}
/** Full immutable package validation occurs before any part is returned. */
export function validateDesktopTemplatePack(value: unknown): DesktopTemplatePack {
const pack = object(value)
if (JSON.stringify(pack).length > 600_000 || pack.schemaVersion !== 1 || !text(pack.name, 160)
|| typeof pack.id !== 'string' || !identifier.test(pack.id) || typeof pack.version !== 'string' || !identifier.test(pack.version)
|| typeof pack.brandId !== 'string' || !uuid.test(pack.brandId) || typeof pack.categoryId !== 'string' || !uuid.test(pack.categoryId)
|| typeof pack.contentHash !== 'string' || !digest.test(pack.contentHash) || pack.contentHash !== templatePackHash(pack)
|| !Array.isArray(pack.outputs) || pack.outputs.length === 0 || pack.outputs.length > 40) invalid()
publicMetadata(pack)
const outputIds = new Set<string>(), fields = new Set<string>()
let detailCount = 0, detailHeight = 0
for (const item of pack.outputs) {
const output = object(item)
if (typeof output.id !== 'string' || !identifier.test(output.id) || outputIds.has(output.id) || !text(output.name, 160)
|| !['detail', 'main_images', 'sku_images'].includes(String(output.component))
|| !integer(output.width, 750, 1440) || ![750, 1440].includes(output.width)
|| !integer(output.height, 1, 30000) || !Array.isArray(output.slots) || output.slots.length === 0 || output.slots.length > 120) invalid()
outputIds.add(output.id)
if (output.background !== undefined && (typeof output.background !== 'string' || !color.test(output.background))) invalid()
if (output.component === 'detail') { detailCount++; detailHeight += output.height }
for (const item of output.slots) {
const slot = object(item)
if (typeof slot.fieldId !== 'string' || !identifier.test(slot.fieldId) || fields.has(slot.fieldId)) invalid()
fields.add(slot.fieldId)
frame(slot.frame, output.width, output.height)
if (slot.reuseGroup !== undefined && (typeof slot.reuseGroup !== 'string' || !identifier.test(slot.reuseGroup))) invalid()
if (slot.kind === 'image') {
if (!['cover', 'contain'].includes(String(slot.crop))) invalid()
} else if (slot.kind === 'text') {
const font = object(slot.font)
if (font.family !== 'Noto Sans SC' || !finite(font.size) || font.size < 8 || font.size > 600
|| !integer(font.weight, 100, 900) || typeof font.color !== 'string' || !color.test(font.color)
|| !integer(slot.maxLines, 1, 100) || !integer(slot.maxChars, 1, 6000)) invalid()
} else invalid()
}
if (output.decorations !== undefined) {
if (!Array.isArray(output.decorations) || output.decorations.length > 120) invalid()
const ids = new Set<string>()
for (const item of output.decorations) {
const decoration = object(item)
if (typeof decoration.id !== 'string' || !identifier.test(decoration.id) || ids.has(decoration.id)
|| !['rect', 'line'].includes(String(decoration.kind)) || typeof decoration.color !== 'string' || !color.test(decoration.color)
|| !finite(decoration.opacity) || decoration.opacity < 0 || decoration.opacity > 1
|| (decoration.aboveImages !== undefined && typeof decoration.aboveImages !== 'boolean') || decoration.path !== undefined || decoration.url !== undefined) invalid()
ids.add(decoration.id)
frame(decoration.frame, output.width, output.height, decoration.kind === 'line')
}
}
}
if (detailCount > 14 || detailHeight > 30000) invalid()
return pack as DesktopTemplatePack
}
/** Registry selection has no database writes and never substitutes another pair. */
export function selectDesktopTemplatePacks(brandId: string | null, categoryId: string | null, registry: readonly unknown[]) {
desktopAssert(typeof brandId === 'string' && uuid.test(brandId) && typeof categoryId === 'string' && uuid.test(categoryId), '请提供有效的品牌和品类编号', 400, 'invalid_request')
const packs = registry.map(validateDesktopTemplatePack).filter(pack => pack.brandId.toLowerCase() === brandId!.toLowerCase() && pack.categoryId.toLowerCase() === categoryId!.toLowerCase())
if (packs.length > 1) invalid()
return { items: packs.map(pack => ({ id: pack.id, version: pack.version, contentHash: pack.contentHash, pack: JSON.parse(JSON.stringify(pack)) as DesktopTemplatePack })) }
}
export function desktopTemplatePacks(brandId: string | null, categoryId: string | null) {
return selectDesktopTemplatePacks(brandId, categoryId, [gujia, yijiada])
}