refactor: implement push notification relay and enhance Expo integration

This commit is contained in:
Nawaz Dhandala
2026-02-18 09:56:10 +00:00
parent 76cfa7186e
commit 3cf7c7d1ae
10 changed files with 296 additions and 33 deletions

View File

@@ -0,0 +1,137 @@
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
import { Expo, ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";
import { ExpoAccessToken } from "Common/Server/EnvironmentConfig";
import logger from "Common/Server/Utils/Logger";
const router: ExpressRouter = Express.getRouter();
// Simple in-memory rate limiter by IP
const rateLimitMap: Map<string, { count: number; resetTime: number }> =
new Map();
const RATE_LIMIT_WINDOW_MS: number = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS: number = 60; // 60 requests per minute per IP
function isRateLimited(ip: string): boolean {
const now: number = Date.now();
const entry: { count: number; resetTime: number } | undefined =
rateLimitMap.get(ip);
if (!entry || now > entry.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });
return false;
}
entry.count++;
return entry.count > RATE_LIMIT_MAX_REQUESTS;
}
// Clean up stale rate limit entries every 5 minutes
setInterval(() => {
const now: number = Date.now();
for (const [ip, entry] of rateLimitMap.entries()) {
if (now > entry.resetTime) {
rateLimitMap.delete(ip);
}
}
}, 5 * 60 * 1000);
// Expo client for sending push notifications (only available when EXPO_ACCESS_TOKEN is set)
const expoClient: Expo | null = ExpoAccessToken
? new Expo({ accessToken: ExpoAccessToken })
: null;
router.post(
"/send",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const clientIp: string =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
req.socket.remoteAddress ||
"unknown";
if (isRateLimited(clientIp)) {
res.status(429).json({
message: "Rate limit exceeded. Please try again later.",
});
return;
}
if (!expoClient) {
throw new BadDataException(
"Push relay is not configured. EXPO_ACCESS_TOKEN is not set on this server.",
);
}
const body: JSONObject = req.body as JSONObject;
const to: string | undefined = body["to"] as string | undefined;
if (!to || !Expo.isExpoPushToken(to)) {
throw new BadDataException(
"Invalid or missing push token. Must be a valid Expo push token.",
);
}
const title: string | undefined = body["title"] as string | undefined;
const messageBody: string | undefined = body["body"] as
| string
| undefined;
if (!title && !messageBody) {
throw new BadDataException(
"At least one of 'title' or 'body' must be provided.",
);
}
const expoPushMessage: ExpoPushMessage = {
to: to,
title: title,
body: messageBody,
data: (body["data"] as { [key: string]: string }) || {},
sound: (body["sound"] as "default" | null) || "default",
priority: (body["priority"] as "default" | "normal" | "high") || "high",
channelId: (body["channelId"] as string) || "default",
};
const tickets: ExpoPushTicket[] =
await expoClient.sendPushNotificationsAsync([expoPushMessage]);
const ticket: ExpoPushTicket | undefined = tickets[0];
if (ticket && ticket.status === "error") {
const errorTicket: ExpoPushTicket & {
message?: string;
details?: { error?: string };
} = ticket as ExpoPushTicket & {
message?: string;
details?: { error?: string };
};
logger.error(
`Push relay: Expo push notification error: ${errorTicket.message}`,
);
throw new BadDataException(
`Failed to send push notification: ${errorTicket.message}`,
);
}
logger.info(`Push relay: notification sent successfully to ${to}`);
return Response.sendJsonObjectResponse(req, res, { success: true });
} catch (err) {
return next(err);
}
},
);
export default router;

View File

@@ -4,6 +4,7 @@ import MailAPI from "./API/Mail";
import SmsAPI from "./API/SMS";
import WhatsAppAPI from "./API/WhatsApp";
import PushNotificationAPI from "./API/PushNotification";
import PushRelayAPI from "./API/PushRelay";
import SMTPConfigAPI from "./API/SMTPConfig";
import PhoneNumberAPI from "./API/PhoneNumber";
import IncomingCallAPI from "./API/IncomingCall";
@@ -21,6 +22,7 @@ const NotificationFeatureSet: FeatureSet = {
app.use([`/${APP_NAME}/sms`, "/sms"], SmsAPI);
app.use([`/${APP_NAME}/whatsapp`, "/whatsapp"], WhatsAppAPI);
app.use([`/${APP_NAME}/push`, "/push"], PushNotificationAPI);
app.use([`/${APP_NAME}/push-relay`, "/push-relay"], PushRelayAPI);
app.use([`/${APP_NAME}/call`, "/call"], CallAPI);
app.use([`/${APP_NAME}/smtp-config`, "/smtp-config"], SMTPConfigAPI);
app.use([`/${APP_NAME}/phone-number`, "/phone-number"], PhoneNumberAPI);

View File

@@ -13,11 +13,23 @@ import {
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import BadDataException from "../../Types/Exception/BadDataException";
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
import ObjectID from "../../Types/ObjectID";
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
import UserPush from "../../Models/DatabaseModels/UserPush";
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
function getAuthenticatedUserId(req: ExpressRequest): ObjectID {
const userId: ObjectID | undefined = (req as OneUptimeRequest)
.userAuthorization?.userId;
if (!userId) {
throw new NotAuthenticatedException(
"You must be logged in to perform this action.",
);
}
return userId;
}
export default class UserPushAPI extends BaseAPI<
UserPush,
UserPushServiceType
@@ -32,6 +44,8 @@ export default class UserPushAPI extends BaseAPI<
try {
req = req as OneUptimeRequest;
const userId: ObjectID = getAuthenticatedUserId(req);
if (!req.body.deviceToken) {
return Response.sendErrorResponse(
req,
@@ -65,7 +79,7 @@ export default class UserPushAPI extends BaseAPI<
// Check if device is already registered
const existingDevice: UserPush | null = await this.service.findOneBy({
query: {
userId: (req as OneUptimeRequest).userAuthorization!.userId!,
userId: userId,
projectId: new ObjectID(req.body.projectId),
deviceToken: req.body.deviceToken,
},
@@ -89,9 +103,7 @@ export default class UserPushAPI extends BaseAPI<
// Create new device registration
const userPush: UserPush = new UserPush();
userPush.userId = (
req as OneUptimeRequest
).userAuthorization!.userId!;
userPush.userId = userId;
userPush.projectId = new ObjectID(req.body.projectId);
userPush.deviceToken = req.body.deviceToken;
userPush.deviceType = req.body.deviceType;
@@ -122,6 +134,8 @@ export default class UserPushAPI extends BaseAPI<
try {
req = req as OneUptimeRequest;
const userId: ObjectID = getAuthenticatedUserId(req);
if (!req.body.deviceToken) {
return Response.sendErrorResponse(
req,
@@ -130,9 +144,6 @@ export default class UserPushAPI extends BaseAPI<
);
}
const userId: ObjectID = (req as OneUptimeRequest).userAuthorization!
.userId!;
await this.service.deleteBy({
query: {
userId: userId,
@@ -162,6 +173,8 @@ export default class UserPushAPI extends BaseAPI<
try {
req = req as OneUptimeRequest;
const userId: ObjectID = getAuthenticatedUserId(req);
if (!req.params["deviceId"]) {
return Response.sendErrorResponse(
req,
@@ -195,10 +208,7 @@ export default class UserPushAPI extends BaseAPI<
}
// Check if the device belongs to the current user
if (
device.userId?.toString() !==
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
) {
if (device.userId?.toString() !== userId.toString()) {
return Response.sendErrorResponse(
req,
res,
@@ -267,6 +277,8 @@ export default class UserPushAPI extends BaseAPI<
try {
req = req as OneUptimeRequest;
const userId: ObjectID = getAuthenticatedUserId(req);
if (!req.params["deviceId"]) {
return Response.sendErrorResponse(
req,
@@ -294,10 +306,7 @@ export default class UserPushAPI extends BaseAPI<
}
// Check if the device belongs to the current user
if (
device.userId?.toString() !==
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
) {
if (device.userId?.toString() !== userId.toString()) {
return Response.sendErrorResponse(
req,
res,
@@ -321,6 +330,8 @@ export default class UserPushAPI extends BaseAPI<
try {
req = req as OneUptimeRequest;
const userId: ObjectID = getAuthenticatedUserId(req);
if (!req.params["deviceId"]) {
return Response.sendErrorResponse(
req,
@@ -348,10 +359,7 @@ export default class UserPushAPI extends BaseAPI<
}
// Check if the device belongs to the current user
if (
device.userId?.toString() !==
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
) {
if (device.userId?.toString() !== userId.toString()) {
return Response.sendErrorResponse(
req,
res,

View File

@@ -529,6 +529,13 @@ export const VapidPrivateKey: string | undefined =
export const VapidSubject: string =
process.env["VAPID_SUBJECT"] || "mailto:support@oneuptime.com";
export const ExpoAccessToken: string | undefined =
process.env["EXPO_ACCESS_TOKEN"] || undefined;
export const PushNotificationRelayUrl: string =
process.env["PUSH_NOTIFICATION_RELAY_URL"] ||
"https://oneuptime.com/api/notification/push-relay/send";
export const EnterpriseLicenseValidationUrl: URL = URL.fromString(
"https://oneuptime.com/api/enterprise-license/validate",
);

View File

@@ -10,9 +10,16 @@ import {
VapidPublicKey,
VapidPrivateKey,
VapidSubject,
ExpoAccessToken,
PushNotificationRelayUrl,
} from "../EnvironmentConfig";
import webpush from "web-push";
import { Expo, ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";
import API from "../../Utils/API";
import URL from "../../Types/API/URL";
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
import HTTPResponse from "../../Types/API/HTTPResponse";
import { JSONObject } from "../../Types/JSON";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import UserPush from "../../Models/DatabaseModels/UserPush";
@@ -43,7 +50,9 @@ export interface PushNotificationOptions {
export default class PushNotificationService {
public static isWebPushInitialized = false;
private static expoClient: Expo = new Expo();
private static expoClient: Expo = new Expo(
ExpoAccessToken ? { accessToken: ExpoAccessToken } : undefined,
);
public static initializeWebPush(): void {
if (this.isWebPushInitialized) {
@@ -340,20 +349,33 @@ export default class PushNotificationService {
);
}
const dataPayload: { [key: string]: string } = {};
if (message.data) {
for (const key of Object.keys(message.data)) {
dataPayload[key] = String(message.data[key]);
}
}
if (message.url || message.clickAction) {
dataPayload["url"] = message.url || message.clickAction || "";
}
const channelId: string =
deviceType === PushDeviceType.Android ? "oncall_high" : "default";
// If EXPO_ACCESS_TOKEN is not set, relay through the push notification gateway
if (!ExpoAccessToken) {
await this.sendViaRelay(
expoPushToken,
message,
dataPayload,
channelId,
deviceType,
);
return;
}
// Send directly via Expo SDK
try {
const dataPayload: { [key: string]: string } = {};
if (message.data) {
for (const key of Object.keys(message.data)) {
dataPayload[key] = String(message.data[key]);
}
}
if (message.url || message.clickAction) {
dataPayload["url"] = message.url || message.clickAction || "";
}
const channelId: string =
deviceType === PushDeviceType.Android ? "oncall_high" : "default";
const expoPushMessage: ExpoPushMessage = {
to: expoPushToken,
title: message.title,
@@ -403,6 +425,49 @@ export default class PushNotificationService {
}
}
private static async sendViaRelay(
expoPushToken: string,
message: PushNotificationMessage,
dataPayload: { [key: string]: string },
channelId: string,
deviceType: PushDeviceType,
): Promise<void> {
logger.info(
`Sending ${deviceType} push notification via relay: ${PushNotificationRelayUrl}`,
);
try {
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>({
url: URL.fromString(PushNotificationRelayUrl),
data: {
to: expoPushToken,
title: message.title || "",
body: message.body || "",
data: dataPayload,
sound: "default",
priority: "high",
channelId: channelId,
},
});
if (response instanceof HTTPErrorResponse) {
throw new Error(
`Push relay error: ${JSON.stringify(response.jsonData)}`,
);
}
logger.info(
`Push notification sent via relay successfully to ${deviceType} device`,
);
} catch (error: any) {
logger.error(
`Failed to send push notification via relay to ${deviceType} device: ${error.message}`,
);
throw error;
}
}
public static async sendPushNotificationToUser(
userId: ObjectID,
projectId: ObjectID,

View File

@@ -203,6 +203,12 @@ Usage:
- name: VAPID_PRIVATE_KEY
value: {{ $.Values.vapid.privateKey }}
- name: EXPO_ACCESS_TOKEN
value: {{ default "" $.Values.expo.accessToken | quote }}
- name: PUSH_NOTIFICATION_RELAY_URL
value: {{ default "https://oneuptime.com/api/notification/push-relay/send" $.Values.pushNotification.relayUrl | quote }}
- name: SLACK_APP_CLIENT_SECRET
value: {{ $.Values.slackApp.clientSecret }}

View File

@@ -727,6 +727,24 @@
},
"additionalProperties": false
},
"expo": {
"type": "object",
"properties": {
"accessToken": {
"type": ["string", "null"]
}
},
"additionalProperties": false
},
"pushNotification": {
"type": "object",
"properties": {
"relayUrl": {
"type": ["string", "null"]
}
},
"additionalProperties": false
},
"incidents": {
"type": "object",
"properties": {

View File

@@ -287,6 +287,15 @@ vapid:
privateKey:
subject: mailto:support@oneuptime.com
# Expo access token for sending mobile push notifications directly via Expo SDK.
# If not set, notifications are relayed through the push notification relay URL.
expo:
accessToken:
# Push notification relay URL for self-hosted instances without Expo credentials
pushNotification:
relayUrl: https://oneuptime.com/api/notification/push-relay/send
incidents:
disableAutomaticCreation: false

View File

@@ -297,6 +297,14 @@ VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@oneuptime.com
# Expo access token for sending mobile push notifications directly via Expo SDK.
# If not set, push notifications are relayed through the push notification relay URL below.
EXPO_ACCESS_TOKEN=
# Push notification relay URL for self-hosted instances without Expo credentials.
# Self-hosted servers relay push notifications through this gateway.
PUSH_NOTIFICATION_RELAY_URL=https://oneuptime.com/api/notification/push-relay/send
# LLM Environment Variables
# Hugging Face Token for LLM Server to downlod models from Hugging Face

View File

@@ -82,6 +82,9 @@ x-common-runtime-variables: &common-runtime-variables
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN}
PUSH_NOTIFICATION_RELAY_URL: ${PUSH_NOTIFICATION_RELAY_URL}
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_USERNAME: ${DATABASE_USERNAME}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}