| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- 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<void>
- 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>): 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<APNsConfigurationInput>,
- ): 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<string, unknown>
- 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<void> {
- 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<void> = 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<APNsConfigurationInput>,
- apply: (config: APNsProviderConfig) => void | Promise<void>,
- ): Promise<APNsConfigurationSnapshot> {
- const operation = this.#operation.then(() => this.#update(input, apply))
- this.#operation = operation.then(() => undefined, () => undefined)
- return operation
- }
- async #update(
- input: Partial<APNsConfigurationInput>,
- apply: (config: APNsProviderConfig) => void | Promise<void>,
- ): Promise<APNsConfigurationSnapshot> {
- 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()
- }
- }
|