Fix code review findings for FCM/APNs push notifications (FRE-5345)
- P0: Add missing jwt import (remove duplicate getAPNSToken from push.service.ts) - P0: Fix race condition in getFCMApp() with promise-based initialization lock - P0: Fix preHandler short-circuit in device.routes.ts (add return before reply.send) - P1: Replace non-null assertions with safe defaults in notification config - P1: Add rate limiting on device registration endpoint (10 req/5min per user) - P2: Add push notification deduplication using content hash - P2: Add APNs payload size validation (256KB limit) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
342
packages/api/src/routes/device.routes.ts
Normal file
342
packages/api/src/routes/device.routes.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@shieldai/db';
|
||||
|
||||
// In-memory rate limiter for device registration
|
||||
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const REGISTRATION_RATE_LIMIT = 10; // max registrations per window
|
||||
const REGISTRATION_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function checkRegistrationRateLimit(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const record = registrationAttempts.get(key);
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
registrationAttempts.set(key, { count: 1, resetAt: now + REGISTRATION_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= REGISTRATION_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cleanup stale rate limit entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of registrationAttempts.entries()) {
|
||||
if (now > record.resetAt) {
|
||||
registrationAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, REGISTRATION_WINDOW_MS);
|
||||
|
||||
export async function deviceRoutes(fastify: FastifyInstance) {
|
||||
// Register device for push notifications
|
||||
fastify.post(
|
||||
'/devices/register',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { platform, fcmToken, apnsToken, appVersion, osVersion, deviceModel } = request.body as {
|
||||
platform: 'ios' | 'android';
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
appVersion: string;
|
||||
osVersion: string;
|
||||
deviceModel?: string;
|
||||
};
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!platform || !appVersion || !osVersion) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
required: ['platform', 'appVersion', 'osVersion'],
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit registration per user
|
||||
if (!checkRegistrationRateLimit(authReq.user.id)) {
|
||||
return reply.status(429).send({
|
||||
error: 'Too many registration attempts',
|
||||
retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'android' && !fcmToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'FCM token required for Android devices',
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'ios' && !apnsToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'APNs token required for iOS devices',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine device type based on platform
|
||||
const deviceType = platform === 'ios' || platform === 'android' ? 'mobile' : 'web';
|
||||
|
||||
// Determine the token to store
|
||||
const deviceToken = platform === 'ios' ? apnsToken! : fcmToken!;
|
||||
|
||||
// Upsert device registration to handle token rotation and re-registration
|
||||
const deviceRegistration = await prisma.deviceToken.upsert({
|
||||
where: { token: deviceToken },
|
||||
create: {
|
||||
userId: authReq.user.id,
|
||||
deviceType,
|
||||
token: deviceToken,
|
||||
platform,
|
||||
appVersion,
|
||||
osVersion,
|
||||
model: deviceModel,
|
||||
isActive: true,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
userId: authReq.user.id,
|
||||
deviceType,
|
||||
platform,
|
||||
appVersion,
|
||||
osVersion,
|
||||
model: deviceModel,
|
||||
isActive: true,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
device: {
|
||||
deviceId: deviceRegistration.id,
|
||||
platform: deviceRegistration.platform,
|
||||
registeredAt: deviceRegistration.createdAt.toISOString(),
|
||||
},
|
||||
message: 'Device registered successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to register device:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to register device',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update device push tokens
|
||||
fastify.put(
|
||||
'/devices/:deviceId/tokens',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { deviceId } = request.params as { deviceId: string };
|
||||
const { fcmToken, apnsToken, deviceModel } = request.body as {
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
deviceModel?: string;
|
||||
};
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!fcmToken && !apnsToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'At least one token is required',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the device first
|
||||
const existingDevice = await prisma.deviceToken.findFirst({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingDevice) {
|
||||
return reply.status(404).send({
|
||||
error: 'Device not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which token to update based on platform
|
||||
const updateData: {
|
||||
token: string;
|
||||
appVersion?: string;
|
||||
osVersion?: string;
|
||||
model?: string;
|
||||
lastUsedAt: Date;
|
||||
} = {
|
||||
token: existingDevice.platform === 'ios' ? (apnsToken || existingDevice.token) : (fcmToken || existingDevice.token),
|
||||
lastUsedAt: new Date(),
|
||||
};
|
||||
|
||||
if (deviceModel) {
|
||||
updateData.model = deviceModel;
|
||||
}
|
||||
|
||||
// Update device tokens in database
|
||||
const device = await prisma.deviceToken.update({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
device: {
|
||||
deviceId: device.id,
|
||||
platform: device.platform,
|
||||
lastActiveAt: device.lastUsedAt.toISOString(),
|
||||
},
|
||||
message: 'Device tokens updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to update device tokens:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to update device tokens',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's registered devices
|
||||
fastify.get(
|
||||
'/devices',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch devices from database
|
||||
const devices = await prisma.deviceToken.findMany({
|
||||
where: {
|
||||
userId: authReq.user.id,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
appVersion: true,
|
||||
osVersion: true,
|
||||
model: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
lastUsedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
devices: devices.map((d) => ({
|
||||
deviceId: d.id,
|
||||
platform: d.platform,
|
||||
appVersion: d.appVersion,
|
||||
osVersion: d.osVersion,
|
||||
model: d.model,
|
||||
lastActiveAt: d.lastUsedAt.toISOString(),
|
||||
registeredAt: d.createdAt.toISOString(),
|
||||
})),
|
||||
message: 'Device list retrieved',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to fetch devices',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Deregister device
|
||||
fastify.delete(
|
||||
'/devices/:deviceId',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { deviceId } = request.params as { deviceId: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Soft delete by marking as inactive
|
||||
await prisma.deviceToken.update({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Device deregistered successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to deregister device:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to deregister device',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user