mirror of
https://github.com/brendan-ch/project-inter-server.git
synced 2026-04-17 07:50:31 +00:00
This matches the behavior of `updatedTime` on shuttle objects. When returning API data, dates are converted into milliseconds since Epoch by the DateTime scalar implementation.
193 lines
5.4 KiB
TypeScript
193 lines
5.4 KiB
TypeScript
import jwt from "jsonwebtoken";
|
|
import http2 from "http2";
|
|
import { ClientHttp2Session } from "node:http2";
|
|
|
|
interface APNsUrl {
|
|
fullUrl: string;
|
|
path: string;
|
|
host: string;
|
|
}
|
|
|
|
export interface NotificationAlertArguments {
|
|
title: string;
|
|
body: string;
|
|
customKeys?: any,
|
|
}
|
|
|
|
export class AppleNotificationSender {
|
|
private apnsToken: string | undefined = undefined;
|
|
private _lastRefreshedTimeMs: Date | undefined = undefined;
|
|
|
|
constructor(
|
|
private shouldActuallySendNotifications = true,
|
|
private client: ClientHttp2Session | undefined = undefined,
|
|
) {
|
|
this.sendNotificationImmediately = this.sendNotificationImmediately.bind(this);
|
|
this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this);
|
|
this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this);
|
|
this.openConnectionIfNoneExists = this.openConnectionIfNoneExists.bind(this);
|
|
this.closeConnectionIfExists = this.closeConnectionIfExists.bind(this);
|
|
this.registerClosureEventsForClient = this.registerClosureEventsForClient.bind(this);
|
|
|
|
if (this.client !== undefined) {
|
|
this.registerClosureEventsForClient();
|
|
}
|
|
}
|
|
|
|
get lastRefreshedTimeMs(): Date | undefined {
|
|
return this._lastRefreshedTimeMs;
|
|
}
|
|
|
|
private lastReloadedTimeForAPNsIsTooRecent() {
|
|
const thirtyMinutesMs = 1800000;
|
|
return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs.getTime() < thirtyMinutesMs;
|
|
}
|
|
|
|
public reloadAPNsTokenIfTimePassed() {
|
|
if (this.lastReloadedTimeForAPNsIsTooRecent()) {
|
|
return;
|
|
}
|
|
|
|
const keyId = process.env.APNS_KEY_ID;
|
|
const teamId = process.env.APNS_TEAM_ID;
|
|
|
|
const privateKeyBase64 = process.env.APNS_PRIVATE_KEY;
|
|
if (!privateKeyBase64) return;
|
|
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8');
|
|
|
|
const tokenHeader = {
|
|
alg: "ES256",
|
|
"kid": keyId,
|
|
};
|
|
|
|
const nowMs = Date.now();
|
|
const claimsPayload = {
|
|
"iss": teamId,
|
|
"iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch
|
|
};
|
|
|
|
this.apnsToken = jwt.sign(claimsPayload, privateKey, {
|
|
algorithm: "ES256",
|
|
header: tokenHeader
|
|
});
|
|
this._lastRefreshedTimeMs = new Date(nowMs);
|
|
}
|
|
|
|
/**
|
|
* Send a notification immediately.
|
|
* @param deviceId
|
|
* @param notificationAlertArguments
|
|
*
|
|
* @return Boolean promise indicating whether the
|
|
* notification was sent successfully.
|
|
*/
|
|
public async sendNotificationImmediately(deviceId: string, notificationAlertArguments: NotificationAlertArguments) {
|
|
if (!this.shouldActuallySendNotifications) {
|
|
// pretend that the notification sent
|
|
return true;
|
|
}
|
|
|
|
this.reloadAPNsTokenIfTimePassed();
|
|
|
|
const bundleId = process.env.APNS_BUNDLE_ID;
|
|
if (typeof bundleId !== "string") {
|
|
throw new Error("APNS_BUNDLE_ID environment variable is not set correctly");
|
|
}
|
|
|
|
this.openConnectionIfNoneExists();
|
|
|
|
const { path } = AppleNotificationSender.getAPNsFullUrlToUse(deviceId);
|
|
|
|
const headers = {
|
|
':method': 'POST',
|
|
':path': path,
|
|
'authorization': `bearer ${this.apnsToken}`,
|
|
"apns-push-type": "alert",
|
|
"apns-expiration": "0",
|
|
"apns-priority": "10",
|
|
"apns-topic": bundleId,
|
|
};
|
|
try {
|
|
if (!this.client) { return false }
|
|
const req = this.client.request(headers);
|
|
req.setEncoding('utf8');
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
req.on('response', (headers, _flags) => {
|
|
if (headers[":status"] !== 200) {
|
|
reject(`APNs request failed with status ${headers[":status"]}`);
|
|
}
|
|
resolve();
|
|
});
|
|
|
|
const customKeys = {
|
|
...notificationAlertArguments.customKeys,
|
|
}
|
|
delete notificationAlertArguments.customKeys;
|
|
|
|
// See https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification
|
|
// for notification payload examples
|
|
const payload = JSON.stringify({
|
|
aps: {
|
|
alert: notificationAlertArguments,
|
|
sound: "default"
|
|
},
|
|
...customKeys,
|
|
});
|
|
req.write(payload);
|
|
req.end();
|
|
});
|
|
return true;
|
|
} catch(e) {
|
|
console.error(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private openConnectionIfNoneExists() {
|
|
const host = AppleNotificationSender.getAPNsHostToUse();
|
|
|
|
if (!this.client) {
|
|
this.client = http2.connect(host);
|
|
this.registerClosureEventsForClient();
|
|
}
|
|
}
|
|
|
|
private registerClosureEventsForClient() {
|
|
this.client?.on('close', this.closeConnectionIfExists);
|
|
this.client?.on('error', this.closeConnectionIfExists);
|
|
this.client?.on('goaway', this.closeConnectionIfExists);
|
|
this.client?.on('timeout', this.closeConnectionIfExists);
|
|
}
|
|
|
|
private closeConnectionIfExists() {
|
|
this.client?.close();
|
|
this.client = undefined;
|
|
}
|
|
|
|
public static getAPNsFullUrlToUse(deviceId: string): APNsUrl {
|
|
let hostToUse = this.getAPNsHostToUse();
|
|
|
|
const path = "/3/device/" + deviceId;
|
|
const fullUrl = hostToUse + path;
|
|
|
|
return {
|
|
fullUrl,
|
|
host: hostToUse,
|
|
path,
|
|
};
|
|
}
|
|
|
|
public static getAPNsHostToUse() {
|
|
// Construct the fetch request
|
|
const devBaseUrl = "https://api.development.push.apple.com"
|
|
const prodBaseUrl = "https://api.push.apple.com"
|
|
|
|
let hostToUse = devBaseUrl;
|
|
if (process.env.APNS_IS_PRODUCTION === "1") {
|
|
hostToUse = prodBaseUrl;
|
|
}
|
|
return hostToUse;
|
|
}
|
|
}
|