push.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. import {Logger} from 'ts-log';
  18. import {PushError, TimeoutError} from '../exceptions';
  19. import {randomString, sleep} from '../helpers';
  20. import {sha256} from '../helpers/crypto';
  21. import {LogService} from './log';
  22. /**
  23. * A push session will send pushes continuously until an undefined goal has
  24. * been achieved which needs to call the `.done` method to stop pushes.
  25. *
  26. * The push session will stop and reject the returned promise in case the
  27. * push relay determined a client error (e.g. an invalid push token). In any
  28. * other case, it will continue sending pushes. Thus, it is crucial to call
  29. * `.done` eventually!
  30. *
  31. * With default settings, the push session will send a push in the following
  32. * intervals: 0s, 2s, 4s, 8s, 16s, 30s (maximum), 30s, ...
  33. *
  34. * The first push will use a TTL (time to live) of 0, the second push a TTL of
  35. * 15s, and all subsequent pushes will use a TTL of 90s.
  36. *
  37. * The default settings intend to wake up the app immediately by the first push
  38. * which uses a TTL of 0, indicating the push server to deliver *now or never*.
  39. * The mid TTL tries to work around issues with FCM clients interpreting the
  40. * TTL as *don't need to dispatch until expired*. And the TTL of 90s acts as a
  41. * last resort mechanism to wake up the app eventually.
  42. *
  43. * Furthermore, the collapse key ensures that only one push per session will be
  44. * stored on the push server.
  45. */
  46. export class PushSession {
  47. private readonly service: PushService;
  48. private readonly session: Uint8Array;
  49. private readonly config: threema.PushSessionConfig;
  50. private readonly doneFuture: Future<any> = new Future();
  51. private readonly affiliation: string = randomString(6);
  52. private log: Logger;
  53. private running: boolean = false;
  54. private retryTimeoutMs: number;
  55. private tries: number = 0;
  56. /**
  57. * Return the default configuration.
  58. */
  59. public static get defaultConfig(): threema.PushSessionConfig {
  60. return {
  61. retryTimeoutInitMs: 2000,
  62. retryTimeoutMaxMs: 30000,
  63. triesMax: 3,
  64. timeToLiveRange: [0, 15, 90],
  65. };
  66. }
  67. /**
  68. * Return the expected maximum period until the session will be forcibly
  69. * rejected.
  70. *
  71. * Note: The actual maximum period will usually be larger since the HTTP
  72. * request itself can take an arbitrary amount of time.
  73. */
  74. public static expectedPeriodMaxMs(config?: threema.PushSessionConfig): number {
  75. if (config === undefined) {
  76. config = PushSession.defaultConfig;
  77. }
  78. if (config.triesMax === Number.POSITIVE_INFINITY) {
  79. return Number.POSITIVE_INFINITY;
  80. }
  81. let retryTimeoutMs = config.retryTimeoutInitMs;
  82. let sumMs = 0;
  83. for (let i = 0; i < config.triesMax; ++i) {
  84. sumMs += retryTimeoutMs;
  85. retryTimeoutMs = Math.min(retryTimeoutMs * 2, config.retryTimeoutMaxMs);
  86. }
  87. return sumMs;
  88. }
  89. /**
  90. * Create a push session.
  91. *
  92. * @param service The associated `PushService` instance.
  93. * @param session Session identifier (public permanent key of the
  94. * initiator)
  95. * @param config Push session configuration.
  96. */
  97. public constructor(service: PushService, session: Uint8Array, config?: threema.PushSessionConfig) {
  98. this.log = service.logService.getLogger(`Push.${this.affiliation}`, 'color: #fff; background-color: #9900cc');
  99. this.service = service;
  100. this.session = session;
  101. this.config = config !== undefined ? config : PushSession.defaultConfig;
  102. this.retryTimeoutMs = this.config.retryTimeoutInitMs;
  103. // Sanity checks
  104. if (this.config.timeToLiveRange.length === 0) {
  105. throw new Error('timeToLiveRange must not be an empty array');
  106. }
  107. if (this.config.triesMax < 1) {
  108. throw new Error('triesMax must be >= 1');
  109. }
  110. }
  111. /**
  112. * The promise resolves once the session has been marked as *done*.
  113. *
  114. * It will reject in case the server indicated a bad request or the maximum
  115. * amount of retransmissions have been reached.
  116. *
  117. * @throws TimeoutError in case the maximum amount of retries has been
  118. * reached.
  119. * @throws PushError if the push was rejected by the push relay server.
  120. * @throws Error in case of an unrecoverable error which prevents further
  121. * pushes.
  122. */
  123. public start(): Promise<void> {
  124. // Start sending
  125. if (!this.running) {
  126. this.run().catch((error) => {
  127. this.log.error('Push runner failed:', error);
  128. this.doneFuture.reject(error);
  129. });
  130. this.running = true;
  131. }
  132. return this.doneFuture;
  133. }
  134. /**
  135. * Mark as done and stop sending push messages.
  136. *
  137. * This will resolve all pending promises.
  138. */
  139. public done(): void {
  140. this.log.info('Push done');
  141. this.doneFuture.resolve();
  142. }
  143. private async run(): Promise<void> {
  144. // Calculate session hash
  145. const sessionHash = await sha256(this.session.buffer);
  146. // Prepare data
  147. const data = new URLSearchParams();
  148. data.set(PushService.ARG_TYPE, this.service.pushType);
  149. data.set(PushService.ARG_SESSION, sessionHash);
  150. data.set(PushService.ARG_VERSION, `${this.service.version}`);
  151. data.set(PushService.ARG_AFFILIATION, this.affiliation);
  152. if (this.service.pushType === threema.PushTokenType.Apns) {
  153. // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"
  154. const parts = this.service.pushToken.split(';');
  155. if (parts.length < 3) {
  156. throw new Error(`APNS push token contains ${parts.length} parts, but at least 3 are required`);
  157. }
  158. data.set(PushService.ARG_TOKEN, parts[0]);
  159. data.set(PushService.ARG_ENDPOINT, parts[1]);
  160. data.set(PushService.ARG_BUNDLE_ID, parts[2]);
  161. } else if (this.service.pushType === threema.PushTokenType.Gcm) {
  162. data.set(PushService.ARG_TOKEN, this.service.pushToken);
  163. } else {
  164. throw new Error(`Invalid push type: ${this.service.pushType}`);
  165. }
  166. // Push until done or unrecoverable error
  167. while (!this.doneFuture.done) {
  168. // Determine TTL
  169. let timeToLive = this.config.timeToLiveRange[this.tries];
  170. if (timeToLive === undefined) {
  171. timeToLive = this.config.timeToLiveRange[this.config.timeToLiveRange.length - 1];
  172. }
  173. // Set/Remove collapse key
  174. if (timeToLive === 0) {
  175. data.delete(PushService.ARG_COLLAPSE_KEY);
  176. } else {
  177. data.set(PushService.ARG_COLLAPSE_KEY, sessionHash.slice(0, 6));
  178. }
  179. // Modify data
  180. data.set(PushService.ARG_TIME_TO_LIVE, `${timeToLive}`);
  181. ++this.tries;
  182. // Send push
  183. this.log.debug(`Sending push ${this.tries}/${this.config.triesMax} (ttl=${timeToLive})`);
  184. if (this.service.config.ARP_LOG_TRACE) {
  185. this.log.debug('Push data:', `${data}`);
  186. }
  187. try {
  188. const response = await fetch(this.service.url, {
  189. method: 'POST',
  190. headers: {
  191. 'Content-Type': 'application/x-www-form-urlencoded',
  192. },
  193. body: data,
  194. });
  195. // Check if successful
  196. if (response.ok) {
  197. // Success: Retry
  198. this.log.debug('Push sent successfully');
  199. } else if (response.status >= 400 && response.status < 500) {
  200. // Client error: Don't retry
  201. const error = `Push rejected (client error), status: ${response.status}`;
  202. this.log.warn(error);
  203. this.doneFuture.reject(new PushError(error, response.status));
  204. } else {
  205. // Server error: Retry
  206. this.log.warn(`Push rejected (server error), status: ${response.status}`);
  207. }
  208. } catch (error) {
  209. this.log.warn('Sending push failed:', error);
  210. }
  211. // Retry after timeout
  212. await sleep(this.retryTimeoutMs);
  213. // Apply RTO backoff
  214. this.retryTimeoutMs = Math.min(this.retryTimeoutMs * 2, this.config.retryTimeoutMaxMs);
  215. // Maximum tries reached?
  216. if (!this.doneFuture.done && this.tries === this.config.triesMax) {
  217. const error = `Push session timeout after ${this.tries} tries`;
  218. this.log.warn(error);
  219. this.doneFuture.reject(new TimeoutError(error));
  220. }
  221. }
  222. }
  223. }
  224. export class PushService {
  225. public static readonly $inject = ['CONFIG', 'PROTOCOL_VERSION', 'LogService'];
  226. public static readonly ARG_TYPE = 'type';
  227. public static readonly ARG_TOKEN = 'token';
  228. public static readonly ARG_SESSION = 'session';
  229. public static readonly ARG_VERSION = 'version';
  230. public static readonly ARG_AFFILIATION = 'affiliation';
  231. public static readonly ARG_ENDPOINT = 'endpoint';
  232. public static readonly ARG_BUNDLE_ID = 'bundleid';
  233. public static readonly ARG_TIME_TO_LIVE = 'ttl';
  234. public static readonly ARG_COLLAPSE_KEY = 'collapse_key';
  235. public readonly config: threema.Config;
  236. public readonly url: string;
  237. public readonly version: number = null;
  238. public readonly logService: LogService;
  239. public readonly log: Logger;
  240. private _pushToken: string = null;
  241. private _pushType = threema.PushTokenType.Gcm;
  242. constructor(CONFIG: threema.Config, PROTOCOL_VERSION: number, logService: LogService) {
  243. this.config = CONFIG;
  244. this.url = CONFIG.PUSH_URL;
  245. this.version = PROTOCOL_VERSION;
  246. this.logService = logService;
  247. this.log = logService.getLogger(`Push-S`, 'color: #fff; background-color: #9900ff');
  248. }
  249. public get pushToken(): string {
  250. return this._pushToken;
  251. }
  252. public get pushType(): string {
  253. return this._pushType;
  254. }
  255. /**
  256. * Initiate the push service with a push token.
  257. */
  258. public init(pushToken: string, pushTokenType: threema.PushTokenType): void {
  259. this.log.info('Initialized with', pushTokenType, 'token');
  260. this._pushToken = pushToken;
  261. this._pushType = pushTokenType;
  262. }
  263. /**
  264. * Reset the push service, remove stored push tokens.
  265. */
  266. public reset(): void {
  267. this._pushToken = null;
  268. }
  269. /**
  270. * Return whether the service has been initialized with a push token.
  271. */
  272. public isAvailable(): boolean {
  273. return this._pushToken != null;
  274. }
  275. /**
  276. * Create a push session for a specific session (public permanent key of
  277. * the initiator) which will repeatedly send push messages until the
  278. * session is marked as established.
  279. */
  280. public createSession(session: Uint8Array, config?: threema.PushSessionConfig): PushSession {
  281. if (!this.isAvailable()) {
  282. throw new Error('Push service unavailable');
  283. }
  284. // Create push instance
  285. return new PushSession(this, session, config);
  286. }
  287. }