2026-07-27 00:39:26 +08:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import type { XuqmRemoteConfig } from './config'
|
|
|
|
|
|
2026-07-28 20:29:06 +08:00
|
|
|
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')
|
|
|
|
|
|
2026-07-27 00:39:26 +08:00
|
|
|
const flatRemoteConfigSchema = z.object({
|
2026-07-28 20:29:06 +08:00
|
|
|
apiUrl: endpointSchema.optional(),
|
|
|
|
|
imApiUrl: endpointSchema.optional(),
|
|
|
|
|
imWsUrl: endpointSchema.optional(),
|
|
|
|
|
fileServiceUrl: endpointSchema.optional(),
|
2026-07-27 00:39:26 +08:00
|
|
|
imEnabled: z.boolean().optional(),
|
|
|
|
|
pushEnabled: z.boolean().optional(),
|
2026-07-28 20:29:06 +08:00
|
|
|
bugCollectApiUrl: endpointSchema.optional(),
|
2026-07-27 00:39:26 +08:00
|
|
|
bugCollectEnabled: z.boolean().optional(),
|
|
|
|
|
updateEnabled: z.boolean().optional(),
|
|
|
|
|
updateRequiresLogin: z.boolean().optional(),
|
|
|
|
|
})
|
2026-07-28 20:29:06 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-27 00:39:26 +08:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 20:29:06 +08:00
|
|
|
/** 缓存只接受归一化后的非敏感字段,并拒绝所有未知字段。 */
|
2026-07-27 00:39:26 +08:00
|
|
|
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
|
2026-07-28 20:29:06 +08:00
|
|
|
const parsed = strictCachedRemoteConfigSchema.safeParse(value)
|
2026-07-27 00:39:26 +08:00
|
|
|
return parsed.success ? parsed.data : null
|
|
|
|
|
}
|
2026-07-28 20:29:06 +08:00
|
|
|
|
|
|
|
|
/** 非敏感 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
|
|
|
|
|
}
|
|
|
|
|
}
|