Files
oneuptime/App/FeatureSet/Notification/API/PushRelay.ts

109 lines
3.2 KiB
TypeScript

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 PushNotificationService from "Common/Server/Services/PushNotificationService";
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,
);
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 (!PushNotificationService.hasExpoAccessToken()) {
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 || !PushNotificationService.isValidExpoPushToken(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.",
);
}
await PushNotificationService.sendRelayPushNotification({
to: to,
...(title !== undefined ? { title } : {}),
...(messageBody !== undefined ? { body: messageBody } : {}),
data: (body["data"] as { [key: string]: string }) || {},
sound: (body["sound"] as string) || "default",
priority: (body["priority"] as string) || "high",
channelId: (body["channelId"] as string) || "default",
});
return Response.sendJsonObjectResponse(req, res, { success: true });
} catch (err) {
return next(err);
}
},
);
export default router;