import { createPrivateKey, randomUUID } from 'node:crypto' import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' import { APNsProvider, type APNsEnvironment, type APNsSendResult } from './apns.js' import type { APNsProviderConfig } from './config.js' import { HttpError } from './errors.js' export type APNsConfigurationSource = 'none' | 'file' | 'environment' export const DEFAULT_APNS_TOPIC = 'cn.samien.yaoyao.hermes' export interface APNsConfigurationInput { keyFile: string keyId: string teamId: string topic: string environments: APNsEnvironment[] } export interface APNsConfigurationWarning { code: 'apns_key_permissions' message: string actualMode: string recommendedMode: '0600' } export interface APNsConfigurationSnapshot { source: APNsConfigurationSource editable: boolean input?: APNsConfigurationInput config?: APNsProviderConfig warnings: APNsConfigurationWarning[] configurationError?: string } export interface APNsConfigurationManagerOptions { probe?: APNsConfigurationProbe } export type APNsConfigurationProbe = ( config: APNsProviderConfig, environments: readonly APNsEnvironment[], ) => Promise interface StoredAPNsConfiguration extends APNsConfigurationInput { schemaVersion: 1 enabled: true updatedAt: string } const MAX_KEY_FILE_BYTES = 64 * 1_024 const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024 const DEFAULT_ENVIRONMENTS: APNsEnvironment[] = ['development', 'production'] const ACCEPTED_PROBE_REASON = 'BadDeviceToken' function clean(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function canonicalEnvironments(value: unknown): APNsEnvironment[] { const requested = Array.isArray(value) ? value : DEFAULT_ENVIRONMENTS const environments: APNsEnvironment[] = [] for (const candidate of requested) { if (candidate !== 'development' && candidate !== 'production') { throw new HttpError(400, 'APNs environment must be development or production', 'invalid_apns_configuration') } if (!environments.includes(candidate)) environments.push(candidate) } if (environments.length === 0) { throw new HttpError(400, 'At least one APNs environment is required', 'invalid_apns_configuration') } return environments } function canonicalInput(value: Partial): APNsConfigurationInput { const keyFile = clean(value.keyFile) const keyId = clean(value.keyId) const teamId = clean(value.teamId) const topic = clean(value.topic) if (!keyFile || !isAbsolute(keyFile) || keyFile.length > 4_096 || /[\u0000-\u001f\u007f]/.test(keyFile)) { throw new HttpError(400, 'APNs key file must be an absolute local path', 'invalid_apns_key_file') } if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) { throw new HttpError(400, 'APNs Key ID is invalid', 'invalid_apns_key_id') } if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) { throw new HttpError(400, 'APNs Team ID is invalid', 'invalid_apns_team_id') } if (!topic || topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) { throw new HttpError(400, 'APNs topic is invalid', 'invalid_apns_topic') } if (topic !== DEFAULT_APNS_TOPIC) { throw new HttpError(400, `APNs topic must be ${DEFAULT_APNS_TOPIC}`, 'invalid_apns_topic') } return { keyFile, keyId, teamId, topic, environments: canonicalEnvironments(value.environments), } } export function validateAPNsConfiguration( value: Partial, ): APNsConfigurationSnapshot { const requested = canonicalInput(value) let keyFile: string let encoded: Buffer let mode: number let descriptor: number | undefined try { keyFile = realpathSync(resolve(requested.keyFile)) descriptor = openSync(keyFile, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) const details = fstatSync(descriptor) if (!details.isFile() || details.size < 1 || details.size > MAX_KEY_FILE_BYTES) { throw new HttpError(400, 'APNs key path must reference a readable private key file', 'invalid_apns_key_file') } encoded = readFileSync(descriptor) mode = details.mode & 0o777 closeSync(descriptor) descriptor = undefined } catch (cause) { if (descriptor !== undefined) closeSync(descriptor) if (cause instanceof HttpError) throw cause throw new HttpError( 400, '15300 cannot read the APNs key file; check the local path and service account permissions', 'invalid_apns_key_file', ) } let key try { key = createPrivateKey(encoded) } catch { throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key') } if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') { throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key') } const actualMode = mode.toString(8).padStart(4, '0') const warnings: APNsConfigurationWarning[] = (mode & ~0o600) !== 0 ? [{ code: 'apns_key_permissions', message: `APNs 已启用,但密钥文件权限为 ${actualMode},建议调整为 0600;此提示不会影响当前推送。`, actualMode, recommendedMode: '0600', }] : [] const input = { ...requested, keyFile } return { source: 'file', editable: true, input, config: { ...input }, warnings, } } function environmentInput(env: NodeJS.ProcessEnv): { present: boolean; input: APNsConfigurationInput } { const raw = { keyFile: clean(env.HERMES_YAOYAO_APNS_KEY_FILE), keyId: clean(env.HERMES_YAOYAO_APNS_KEY_ID), teamId: clean(env.HERMES_YAOYAO_APNS_TEAM_ID), topic: clean(env.HERMES_YAOYAO_APNS_TOPIC) || DEFAULT_APNS_TOPIC, environments: DEFAULT_ENVIRONMENTS, } return { present: Boolean( env.HERMES_YAOYAO_APNS_KEY_FILE?.trim() || env.HERMES_YAOYAO_APNS_KEY_ID?.trim() || env.HERMES_YAOYAO_APNS_TEAM_ID?.trim() || env.HERMES_YAOYAO_APNS_TOPIC?.trim(), ), input: raw, } } function storedInput(value: unknown): APNsConfigurationInput { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('APNs configuration file must contain an object') } const record = value as Record if (record.schemaVersion !== 1 || record.enabled !== true) { throw new Error('Unsupported APNs configuration file') } return { keyFile: clean(record.keyFile), keyId: clean(record.keyId), teamId: clean(record.teamId), topic: clean(record.topic), environments: canonicalEnvironments(record.environments), } } export function apnsConfigurationPath(home: string): string { return join(home, 'push', 'apns-config.json') } function readConfigurationFile(path: string): Buffer { const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) try { const details = fstatSync(descriptor) if (!details.isFile() || details.size < 1 || details.size > MAX_CONFIGURATION_FILE_BYTES) { throw new Error('APNs configuration path is not a regular file') } return readFileSync(descriptor) } finally { closeSync(descriptor) } } export function loadAPNsConfiguration( home: string, env: NodeJS.ProcessEnv = process.env, ): APNsConfigurationSnapshot { const fromEnvironment = environmentInput(env) if (fromEnvironment.present) { try { const validated = validateAPNsConfiguration(fromEnvironment.input) return { ...validated, source: 'environment', editable: false } } catch (cause) { return { source: 'environment', editable: false, input: fromEnvironment.input, warnings: [], configurationError: cause instanceof Error ? cause.message : 'APNs environment configuration is invalid', } } } const path = apnsConfigurationPath(home) if (!existsSync(path)) return { source: 'none', editable: true, warnings: [] } try { const input = storedInput(JSON.parse(readConfigurationFile(path).toString('utf8'))) const validated = validateAPNsConfiguration(input) chmodSync(path, 0o600) return { ...validated, source: 'file', editable: true } } catch (cause) { return { source: 'file', editable: true, warnings: [], configurationError: cause instanceof Error ? cause.message : 'APNs configuration file is invalid', } } } function writeAtomic(path: string, value: Buffer | string): void { const directory = dirname(path) mkdirSync(directory, { recursive: true, mode: 0o700 }) chmodSync(directory, 0o700) const temporary = `${path}.${process.pid}.${randomUUID()}.tmp` let descriptor: number | undefined try { descriptor = openSync(temporary, 'wx', 0o600) writeFileSync(descriptor, value) fsyncSync(descriptor) closeSync(descriptor) descriptor = undefined renameSync(temporary, path) chmodSync(path, 0o600) } catch (cause) { if (descriptor !== undefined) closeSync(descriptor) try { unlinkSync(temporary) } catch { /* already absent */ } throw cause } } async function defaultProbe( config: APNsProviderConfig, environments: readonly APNsEnvironment[], ): Promise { const provider = new APNsProvider(config) try { for (const environment of environments) { const result: APNsSendResult = await provider.send({ deviceToken: '00'.repeat(32), environment, payload: { aps: { 'content-available': 1 } }, priority: 5, pushType: 'background', }) if (result.reason === ACCEPTED_PROBE_REASON) continue if (result.disposition === 'retry') { throw new HttpError(503, 'Unable to connect to APNs; check outbound network access and retry', 'apns_probe_unavailable') } throw new HttpError(422, 'APNs rejected the provider credentials', 'apns_credentials_rejected') } } finally { provider.close() } } function cloneSnapshot(value: APNsConfigurationSnapshot): APNsConfigurationSnapshot { return { ...value, ...(value.input ? { input: { ...value.input, environments: [...value.input.environments] } } : {}), ...(value.config ? { config: { ...value.config, environments: [...(value.config.environments ?? DEFAULT_ENVIRONMENTS)] } } : {}), warnings: value.warnings.map(warning => ({ ...warning })), } } export class APNsConfigurationManager { readonly path: string readonly #probe: APNsConfigurationProbe #snapshot: APNsConfigurationSnapshot #operation: Promise = Promise.resolve() constructor( home: string, initial: APNsConfigurationSnapshot, options: APNsConfigurationManagerOptions = {}, ) { this.path = apnsConfigurationPath(home) this.#snapshot = cloneSnapshot(initial) this.#probe = options.probe ?? defaultProbe } snapshot(): APNsConfigurationSnapshot { return cloneSnapshot(this.#snapshot) } update( input: Partial, apply: (config: APNsProviderConfig) => void | Promise, ): Promise { const operation = this.#operation.then(() => this.#update(input, apply)) this.#operation = operation.then(() => undefined, () => undefined) return operation } async #update( input: Partial, apply: (config: APNsProviderConfig) => void | Promise, ): Promise { if (this.#snapshot.source === 'environment') { throw new HttpError(409, 'APNs configuration is managed by service environment variables', 'apns_environment_managed') } const validated = validateAPNsConfiguration(input) const config = validated.config! await this.#probe(config, validated.input!.environments) const previous = existsSync(this.path) ? readConfigurationFile(this.path) : undefined const stored: StoredAPNsConfiguration = { schemaVersion: 1, enabled: true, ...validated.input!, updatedAt: new Date().toISOString(), } try { writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`) await apply(config) } catch (cause) { try { if (previous) writeAtomic(this.path, previous) else if (existsSync(this.path)) unlinkSync(this.path) } catch { /* preserve the primary configuration error */ } throw cause } this.#snapshot = { ...validated, source: 'file', editable: true } return this.snapshot() } }