123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- /**
- * 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 <http://www.gnu.org/licenses/>.
- */
- 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<any> = 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<void> {
- // 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<void> {
- // 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: "<hex-deviceid>;<endpoint>;<bundle-id>"
- 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);
- }
- }
|