import { z } from 'zod' import type { XuqmRemoteConfig } from './config' const endpointSchema = z.string().refine(value => { if (value === '') return true try { const protocol = new URL(value).protocol return ( protocol === 'http:' || protocol === 'https:' || protocol === 'ws:' || protocol === 'wss:' ) } catch { return false } }, 'endpoint must be an absolute HTTP(S) or WS(S) URL') const flatRemoteConfigSchema = z.object({ apiUrl: endpointSchema.optional(), imApiUrl: endpointSchema.optional(), imWsUrl: endpointSchema.optional(), fileServiceUrl: endpointSchema.optional(), imEnabled: z.boolean().optional(), pushEnabled: z.boolean().optional(), bugCollectApiUrl: endpointSchema.optional(), bugCollectEnabled: z.boolean().optional(), updateEnabled: z.boolean().optional(), updateRequiresLogin: z.boolean().optional(), }) const strictCachedRemoteConfigSchema = flatRemoteConfigSchema.strict() const REMOTE_CONFIG_CACHE_FORMAT = 'XUQM-REMOTE-CONFIG-LKG-V1' const remoteConfigCacheSchema = z .object({ cachedAt: z.number().int().nonnegative(), format: z.literal(REMOTE_CONFIG_CACHE_FORMAT), remote: strictCachedRemoteConfigSchema, }) .strict() export type CachedRemoteConfig = { cachedAt: number remote: XuqmRemoteConfig } const remoteConfigResponseSchema = flatRemoteConfigSchema .extend({ allowAnonymousUpdateCheck: z.boolean().optional(), features: z .object({ im: z.boolean().optional(), push: z.boolean().optional(), bugCollect: z.boolean().optional(), update: z.boolean().optional(), }) .optional(), }) .loose() /** 平台远程配置的唯一归一化入口;features 字段优先于历史 flat 字段。 */ export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig { const raw = remoteConfigResponseSchema.parse(value) const features = raw.features ?? {} return { apiUrl: raw.apiUrl, imApiUrl: raw.imApiUrl, imWsUrl: raw.imWsUrl, fileServiceUrl: raw.fileServiceUrl, imEnabled: features.im ?? raw.imEnabled, pushEnabled: features.push ?? raw.pushEnabled, bugCollectApiUrl: raw.bugCollectApiUrl, bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled, updateEnabled: features.update ?? raw.updateEnabled, updateRequiresLogin: raw.updateRequiresLogin ?? (raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck), } } /** 缓存只接受归一化后的非敏感字段,并拒绝所有未知字段。 */ export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null { const parsed = strictCachedRemoteConfigSchema.safeParse(value) return parsed.success ? parsed.data : null } /** 非敏感 LKG 的唯一序列化入口;不加密、不携带任何客户端共享秘密。 */ export function serializeRemoteConfigCache(remote: XuqmRemoteConfig, cachedAt: number): string { return JSON.stringify( remoteConfigCacheSchema.parse({ cachedAt, format: REMOTE_CONFIG_CACHE_FORMAT, remote, }), ) } /** 损坏、旧格式或含额外字段的缓存全部视为无效。 */ export function parseRemoteConfigCache(value: string): CachedRemoteConfig | null { try { const parsed = remoteConfigCacheSchema.safeParse(JSON.parse(value)) return parsed.success ? { cachedAt: parsed.data.cachedAt, remote: parsed.data.remote } : null } catch { return null } }