/** * This file is part of Threema Web. * * Threema Web is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or (at * your option) any later version. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero * General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Threema Web. If not, see . */ import {Logger} from 'ts-log'; import {PushError, TimeoutError} from '../exceptions'; import {randomString, sleep} from '../helpers'; import {sha256} from '../helpers/crypto'; import {LogService} from './log'; /** * A push session will send pushes continuously until an undefined goal has * been achieved which needs to call the `.done` method to stop pushes. * * The push session will stop and reject the returned promise in case the * push relay determined a client error (e.g. an invalid push token). In any * other case, it will continue sending pushes. Thus, it is crucial to call * `.done` eventually! * * With default settings, the push session will send a push in the following * intervals: 0s, 2s, 4s, 8s, 16s, 30s (maximum), 30s, ... * * The first push will use a TTL (time to live) of 0, the second push a TTL of * 15s, and all subsequent pushes will use a TTL of 90s. * * The default settings intend to wake up the app immediately by the first push * which uses a TTL of 0, indicating the push server to deliver *now or never*. * The mid TTL tries to work around issues with FCM clients interpreting the * TTL as *don't need to dispatch until expired*. And the TTL of 90s acts as a * last resort mechanism to wake up the app eventually. * * Furthermore, the collapse key ensures that only one push per session will be * stored on the push server. */ export class PushSession { private readonly service: PushService; private readonly session: Uint8Array; private readonly config: threema.PushSessionConfig; private readonly doneFuture: Future = new Future(); private readonly affiliation: string = randomString(6); private log: Logger; private running: boolean = false; private retryTimeoutMs: number; private tries: number = 0; /** * Return the default configuration. */ public static get defaultConfig(): threema.PushSessionConfig { return { retryTimeoutInitMs: 2000, retryTimeoutMaxMs: 30000, triesMax: 3, timeToLiveRange: [0, 15, 90], }; } /** * Return the expected maximum period until the session will be forcibly * rejected. * * Note: The actual maximum period will usually be larger since the HTTP * request itself can take an arbitrary amount of time. */ public static expectedPeriodMaxMs(config?: threema.PushSessionConfig): number { if (config === undefined) { config = PushSession.defaultConfig; } if (config.triesMax === Number.POSITIVE_INFINITY) { return Number.POSITIVE_INFINITY; } let retryTimeoutMs = config.retryTimeoutInitMs; let sumMs = 0; for (let i = 0; i < config.triesMax; ++i) { sumMs += retryTimeoutMs; retryTimeoutMs = Math.min(retryTimeoutMs * 2, config.retryTimeoutMaxMs); } return sumMs; } /** * Create a push session. * * @param service The associated `PushService` instance. * @param session Session identifier (public permanent key of the * initiator) * @param config Push session configuration. */ public constructor(service: PushService, session: Uint8Array, config?: threema.PushSessionConfig) { this.log = service.logService.getLogger(`Push.${this.affiliation}`, 'color: #fff; background-color: #9900cc'); this.service = service; this.session = session; this.config = config !== undefined ? config : PushSession.defaultConfig; this.retryTimeoutMs = this.config.retryTimeoutInitMs; // Sanity checks if (this.config.timeToLiveRange.length === 0) { throw new Error('timeToLiveRange must not be an empty array'); } if (this.config.triesMax < 1) { throw new Error('triesMax must be >= 1'); } } /** * The promise resolves once the session has been marked as *done*. * * It will reject in case the server indicated a bad request or the maximum * amount of retransmissions have been reached. * * @throws TimeoutError in case the maximum amount of retries has been * reached. * @throws PushError if the push was rejected by the push relay server. * @throws Error in case of an unrecoverable error which prevents further * pushes. */ public start(): Promise { // Start sending if (!this.running) { this.run().catch((error) => { this.log.error('Push runner failed:', error); this.doneFuture.reject(error); }); this.running = true; } return this.doneFuture; } /** * Mark as done and stop sending push messages. * * This will resolve all pending promises. */ public done(): void { this.log.info('Push done'); this.doneFuture.resolve(); } private async run(): Promise { // Calculate session hash const sessionHash = await sha256(this.session.buffer); // Prepare data const data = new URLSearchParams(); data.set(PushService.ARG_TYPE, this.service.pushType); data.set(PushService.ARG_SESSION, sessionHash); data.set(PushService.ARG_VERSION, `${this.service.version}`); data.set(PushService.ARG_AFFILIATION, this.affiliation); if (this.service.pushType === threema.PushTokenType.Apns) { // APNS token format: ";;" const parts = this.service.pushToken.split(';'); if (parts.length < 3) { throw new Error(`APNS push token contains ${parts.length} parts, but at least 3 are required`); } data.set(PushService.ARG_TOKEN, parts[0]); data.set(PushService.ARG_ENDPOINT, parts[1]); data.set(PushService.ARG_BUNDLE_ID, parts[2]); } else if (this.service.pushType === threema.PushTokenType.Gcm) { data.set(PushService.ARG_TOKEN, this.service.pushToken); } else { throw new Error(`Invalid push type: ${this.service.pushType}`); } // Push until done or unrecoverable error while (!this.doneFuture.done) { // Determine TTL let timeToLive = this.config.timeToLiveRange[this.tries]; if (timeToLive === undefined) { timeToLive = this.config.timeToLiveRange[this.config.timeToLiveRange.length - 1]; } // Set/Remove collapse key if (timeToLive === 0) { data.delete(PushService.ARG_COLLAPSE_KEY); } else { data.set(PushService.ARG_COLLAPSE_KEY, sessionHash.slice(0, 6)); } // Modify data data.set(PushService.ARG_TIME_TO_LIVE, `${timeToLive}`); ++this.tries; // Send push this.log.debug(`Sending push ${this.tries}/${this.config.triesMax} (ttl=${timeToLive})`); if (this.service.config.ARP_LOG_TRACE) { this.log.debug('Push data:', `${data}`); } try { const response = await fetch(this.service.url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: data, }); // Check if successful if (response.ok) { // Success: Retry this.log.debug('Push sent successfully'); } else if (response.status >= 400 && response.status < 500) { // Client error: Don't retry const error = `Push rejected (client error), status: ${response.status}`; this.log.warn(error); this.doneFuture.reject(new PushError(error, response.status)); } else { // Server error: Retry this.log.warn(`Push rejected (server error), status: ${response.status}`); } } catch (error) { this.log.warn('Sending push failed:', error); } // Retry after timeout await sleep(this.retryTimeoutMs); // Apply RTO backoff this.retryTimeoutMs = Math.min(this.retryTimeoutMs * 2, this.config.retryTimeoutMaxMs); // Maximum tries reached? if (!this.doneFuture.done && this.tries === this.config.triesMax) { const error = `Push session timeout after ${this.tries} tries`; this.log.warn(error); this.doneFuture.reject(new TimeoutError(error)); } } } } export class PushService { public static readonly $inject = ['CONFIG', 'PROTOCOL_VERSION', 'LogService']; public static readonly ARG_TYPE = 'type'; public static readonly ARG_TOKEN = 'token'; public static readonly ARG_SESSION = 'session'; public static readonly ARG_VERSION = 'version'; public static readonly ARG_AFFILIATION = 'affiliation'; public static readonly ARG_ENDPOINT = 'endpoint'; public static readonly ARG_BUNDLE_ID = 'bundleid'; public static readonly ARG_TIME_TO_LIVE = 'ttl'; public static readonly ARG_COLLAPSE_KEY = 'collapse_key'; public readonly config: threema.Config; public readonly url: string; public readonly version: number = null; public readonly logService: LogService; public readonly log: Logger; private _pushToken: string = null; private _pushType = threema.PushTokenType.Gcm; constructor(CONFIG: threema.Config, PROTOCOL_VERSION: number, logService: LogService) { this.config = CONFIG; this.url = CONFIG.PUSH_URL; this.version = PROTOCOL_VERSION; this.logService = logService; this.log = logService.getLogger(`Push-S`, 'color: #fff; background-color: #9900ff'); } public get pushToken(): string { return this._pushToken; } public get pushType(): string { return this._pushType; } /** * Initiate the push service with a push token. */ public init(pushToken: string, pushTokenType: threema.PushTokenType): void { this.log.info('Initialized with', pushTokenType, 'token'); this._pushToken = pushToken; this._pushType = pushTokenType; } /** * Reset the push service, remove stored push tokens. */ public reset(): void { this._pushToken = null; } /** * Return whether the service has been initialized with a push token. */ public isAvailable(): boolean { return this._pushToken != null; } /** * Create a push session for a specific session (public permanent key of * the initiator) which will repeatedly send push messages until the * session is marked as established. */ public createSession(session: Uint8Array, config?: threema.PushSessionConfig): PushSession { if (!this.isAvailable()) { throw new Error('Push service unavailable'); } // Create push instance return new PushSession(this, session, config); } }