From 3cf7c7d1aee5aa0a432f0988d08fe55b8b4614b7 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Wed, 18 Feb 2026 09:56:10 +0000 Subject: [PATCH] refactor: implement push notification relay and enhance Expo integration --- App/FeatureSet/Notification/API/PushRelay.ts | 137 ++++++++++++++++++ App/FeatureSet/Notification/Index.ts | 2 + Common/Server/API/UserPushAPI.ts | 46 +++--- Common/Server/EnvironmentConfig.ts | 7 + .../Services/PushNotificationService.ts | 93 ++++++++++-- .../Public/oneuptime/templates/_helpers.tpl | 6 + HelmChart/Public/oneuptime/values.schema.json | 18 +++ HelmChart/Public/oneuptime/values.yaml | 9 ++ config.example.env | 8 + docker-compose.base.yml | 3 + 10 files changed, 296 insertions(+), 33 deletions(-) create mode 100644 App/FeatureSet/Notification/API/PushRelay.ts diff --git a/App/FeatureSet/Notification/API/PushRelay.ts b/App/FeatureSet/Notification/API/PushRelay.ts new file mode 100644 index 0000000000..d63e2afbf8 --- /dev/null +++ b/App/FeatureSet/Notification/API/PushRelay.ts @@ -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 = + 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; diff --git a/App/FeatureSet/Notification/Index.ts b/App/FeatureSet/Notification/Index.ts index a13bc135fc..c78ebdaebb 100644 --- a/App/FeatureSet/Notification/Index.ts +++ b/App/FeatureSet/Notification/Index.ts @@ -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); diff --git a/Common/Server/API/UserPushAPI.ts b/Common/Server/API/UserPushAPI.ts index 15e4d423a7..d2adbfad1c 100644 --- a/Common/Server/API/UserPushAPI.ts +++ b/Common/Server/API/UserPushAPI.ts @@ -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, diff --git a/Common/Server/EnvironmentConfig.ts b/Common/Server/EnvironmentConfig.ts index 91dc48b530..ef5f62fd5b 100644 --- a/Common/Server/EnvironmentConfig.ts +++ b/Common/Server/EnvironmentConfig.ts @@ -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", ); diff --git a/Common/Server/Services/PushNotificationService.ts b/Common/Server/Services/PushNotificationService.ts index e1170e3ce0..5671d16110 100644 --- a/Common/Server/Services/PushNotificationService.ts +++ b/Common/Server/Services/PushNotificationService.ts @@ -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 { + logger.info( + `Sending ${deviceType} push notification via relay: ${PushNotificationRelayUrl}`, + ); + + try { + const response: HTTPErrorResponse | HTTPResponse = + await API.post({ + 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, diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index 86c42e2fc4..e6c71e49e0 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -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 }} diff --git a/HelmChart/Public/oneuptime/values.schema.json b/HelmChart/Public/oneuptime/values.schema.json index 9e0858dd9c..fcc40fb6e7 100644 --- a/HelmChart/Public/oneuptime/values.schema.json +++ b/HelmChart/Public/oneuptime/values.schema.json @@ -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": { diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index 225880a91f..26de1ad81b 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -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 diff --git a/config.example.env b/config.example.env index bf44916dd5..b7bee145e7 100644 --- a/config.example.env +++ b/config.example.env @@ -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 diff --git a/docker-compose.base.yml b/docker-compose.base.yml index c032e280f0..b227a62c0c 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -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}