mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
refactor: implement push notification relay and enhance Expo integration
This commit is contained in:
137
App/FeatureSet/Notification/API/PushRelay.ts
Normal file
137
App/FeatureSet/Notification/API/PushRelay.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user