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.
137 lines
8.1 KiB
TypeScript
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])
|
|
}
|