feat(devices): add registration, heartbeat, linking, and device tokens
This commit is contained in:
@@ -6,6 +6,8 @@ import { auth } from './auth';
|
|||||||
import { buildOpenApiDocument } from './docs/openapi';
|
import { buildOpenApiDocument } from './docs/openapi';
|
||||||
import videosRoutes from './routes/videos';
|
import videosRoutes from './routes/videos';
|
||||||
import adminRoutes from './routes/admin';
|
import adminRoutes from './routes/admin';
|
||||||
|
import devicesRoutes from './routes/devices';
|
||||||
|
import deviceLinksRoutes from './routes/device-links';
|
||||||
import { ensureMinioBucket } from './utils/minio';
|
import { ensureMinioBucket } from './utils/minio';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -26,6 +28,8 @@ app.all('/api/auth/*splat', toNodeHandler(auth));
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/videos', videosRoutes);
|
app.use('/videos', videosRoutes);
|
||||||
app.use('/admin', adminRoutes);
|
app.use('/admin', adminRoutes);
|
||||||
|
app.use('/devices', devicesRoutes);
|
||||||
|
app.use('/device-links', deviceLinksRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
30
Backend/middleware/device-auth.ts
Normal file
30
Backend/middleware/device-auth.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { verifyDeviceToken } from '../utils/device-token';
|
||||||
|
|
||||||
|
const extractBearerToken = (authorizationHeader?: string): string | null => {
|
||||||
|
if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorizationHeader.slice('Bearer '.length).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireDeviceAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ message: 'Missing bearer device token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verifyDeviceToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ message: 'Invalid device token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.deviceAuth = payload;
|
||||||
|
next();
|
||||||
|
};
|
||||||
117
Backend/routes/device-links.ts
Normal file
117
Backend/routes/device-links.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { deviceLinks, devices } from '../db/schema';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const createLinkSchema = z.object({
|
||||||
|
cameraDeviceId: z.string().uuid(),
|
||||||
|
clientDeviceId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const parsed = createLinkSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.cameraDeviceId === parsed.data.clientDeviceId) {
|
||||||
|
res.status(400).json({ message: 'cameraDeviceId and clientDeviceId must be different' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [camera, client] = await Promise.all([
|
||||||
|
db.query.devices.findFirst({
|
||||||
|
where: and(eq(devices.id, parsed.data.cameraDeviceId), eq(devices.userId, authSession.user.id)),
|
||||||
|
}),
|
||||||
|
db.query.devices.findFirst({
|
||||||
|
where: and(eq(devices.id, parsed.data.clientDeviceId), eq(devices.userId, authSession.user.id)),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!camera || !client) {
|
||||||
|
res.status(400).json({ message: 'Both devices must exist and belong to the authenticated user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (camera.role !== 'camera') {
|
||||||
|
res.status(400).json({ message: 'cameraDeviceId must belong to a camera role device' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.role !== 'client') {
|
||||||
|
res.status(400).json({ message: 'clientDeviceId must belong to a client role device' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(deviceLinks)
|
||||||
|
.values({
|
||||||
|
ownerUserId: authSession.user.id,
|
||||||
|
cameraDeviceId: parsed.data.cameraDeviceId,
|
||||||
|
clientDeviceId: parsed.data.clientDeviceId,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json({ message: 'Device link created', link: created });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create device link', error);
|
||||||
|
res.status(409).json({ message: 'Device link already exists' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = await db.query.deviceLinks.findMany({
|
||||||
|
where: eq(deviceLinks.ownerUserId, authSession.user.id),
|
||||||
|
orderBy: [desc(deviceLinks.updatedAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ count: links.length, links });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:linkId', async (req, res) => {
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(deviceLinks)
|
||||||
|
.where(and(eq(deviceLinks.id, req.params.linkId), eq(deviceLinks.ownerUserId, authSession.user.id)))
|
||||||
|
.returning({ id: deviceLinks.id });
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ message: 'Link not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Device link removed', linkId: deleted.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
219
Backend/routes/devices.ts
Normal file
219
Backend/routes/devices.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { devices } from '../db/schema';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
|
import { createDeviceToken } from '../utils/device-token';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const roleSchema = z.enum(['camera', 'client']);
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(255).optional(),
|
||||||
|
role: roleSchema,
|
||||||
|
platform: z.string().trim().min(1).max(32).optional(),
|
||||||
|
appVersion: z.string().trim().min(1).max(64).optional(),
|
||||||
|
pushToken: z.string().trim().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(255).optional(),
|
||||||
|
role: roleSchema.optional(),
|
||||||
|
platform: z.string().trim().min(1).max(32).optional(),
|
||||||
|
appVersion: z.string().trim().min(1).max(64).optional(),
|
||||||
|
pushToken: z.string().trim().min(1).optional(),
|
||||||
|
status: z.enum(['online', 'offline']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeatSchema = z.object({
|
||||||
|
status: z.enum(['online', 'offline']).default('online'),
|
||||||
|
});
|
||||||
|
const deviceParamSchema = z.object({
|
||||||
|
deviceId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/register', requireAuth, async (req, res) => {
|
||||||
|
const parsed = registerSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [device] = await db
|
||||||
|
.insert(devices)
|
||||||
|
.values({
|
||||||
|
userId: authSession.user.id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
role: parsed.data.role,
|
||||||
|
isCamera: parsed.data.role === 'camera',
|
||||||
|
platform: parsed.data.platform,
|
||||||
|
appVersion: parsed.data.appVersion,
|
||||||
|
pushToken: parsed.data.pushToken,
|
||||||
|
status: 'online',
|
||||||
|
lastSeenAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: devices.id,
|
||||||
|
userId: devices.userId,
|
||||||
|
name: devices.name,
|
||||||
|
role: devices.role,
|
||||||
|
status: devices.status,
|
||||||
|
platform: devices.platform,
|
||||||
|
appVersion: devices.appVersion,
|
||||||
|
lastSeenAt: devices.lastSeenAt,
|
||||||
|
createdAt: devices.createdAt,
|
||||||
|
updatedAt: devices.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
res.status(500).json({ message: 'Unable to register device' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceToken = createDeviceToken({
|
||||||
|
userId: device.userId,
|
||||||
|
deviceId: device.id,
|
||||||
|
role: device.role as 'camera' | 'client',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Device registered',
|
||||||
|
device,
|
||||||
|
deviceToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.devices.findMany({
|
||||||
|
where: eq(devices.userId, authSession.user.id),
|
||||||
|
orderBy: [desc(devices.updatedAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ count: result.length, devices: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:deviceId', requireAuth, async (req, res) => {
|
||||||
|
const parsedParams = deviceParamSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authSession = req.auth;
|
||||||
|
|
||||||
|
if (!authSession?.user?.id) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.devices.findFirst({
|
||||||
|
where: and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, authSession.user.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ message: 'Device not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = parsed.data.role ?? existing.role;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(devices)
|
||||||
|
.set({
|
||||||
|
name: parsed.data.name ?? existing.name,
|
||||||
|
role,
|
||||||
|
isCamera: role === 'camera',
|
||||||
|
platform: parsed.data.platform ?? existing.platform,
|
||||||
|
appVersion: parsed.data.appVersion ?? existing.appVersion,
|
||||||
|
pushToken: parsed.data.pushToken ?? existing.pushToken,
|
||||||
|
status: parsed.data.status ?? existing.status,
|
||||||
|
updatedAt: now,
|
||||||
|
lastSeenAt: parsed.data.status === 'online' ? now : existing.lastSeenAt,
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.json({ message: 'Device updated', device: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:deviceId/heartbeat', requireDeviceAuth, async (req, res) => {
|
||||||
|
const parsedParams = deviceParamSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = heartbeatSchema.safeParse(req.body ?? {});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceAuth = req.deviceAuth;
|
||||||
|
|
||||||
|
if (!deviceAuth) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceAuth.deviceId !== parsedParams.data.deviceId) {
|
||||||
|
res.status(403).json({ message: 'Device token does not match target device' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(devices)
|
||||||
|
.set({
|
||||||
|
status: parsed.data.status,
|
||||||
|
lastSeenAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, deviceAuth.userId)))
|
||||||
|
.returning({
|
||||||
|
id: devices.id,
|
||||||
|
status: devices.status,
|
||||||
|
lastSeenAt: devices.lastSeenAt,
|
||||||
|
updatedAt: devices.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({ message: 'Device not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Heartbeat recorded', device: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
6
Backend/types/express.d.ts
vendored
6
Backend/types/express.d.ts
vendored
@@ -12,6 +12,12 @@ declare global {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deviceAuth?: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
role: 'camera' | 'client';
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
Backend/utils/device-token.ts
Normal file
86
Backend/utils/device-token.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
type DeviceRole = 'camera' | 'client';
|
||||||
|
|
||||||
|
export type DeviceTokenPayload = {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
role: DeviceRole;
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required for device token signing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64UrlEncode = (input: string): string => Buffer.from(input, 'utf8').toString('base64url');
|
||||||
|
const base64UrlDecode = (input: string): string => Buffer.from(input, 'base64url').toString('utf8');
|
||||||
|
|
||||||
|
const sign = (data: string): string => createHmac('sha256', secret).update(data).digest('base64url');
|
||||||
|
|
||||||
|
export const createDeviceToken = (
|
||||||
|
payload: Omit<DeviceTokenPayload, 'exp'>,
|
||||||
|
ttlSeconds = 60 * 60 * 24 * 30,
|
||||||
|
): string => {
|
||||||
|
const body: DeviceTokenPayload = {
|
||||||
|
...payload,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedPayload = base64UrlEncode(JSON.stringify(body));
|
||||||
|
const signature = sign(encodedPayload);
|
||||||
|
|
||||||
|
return `${encodedPayload}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyDeviceToken = (token: string): DeviceTokenPayload | null => {
|
||||||
|
const [encodedPayload, providedSignature] = token.split('.');
|
||||||
|
|
||||||
|
if (!encodedPayload || !providedSignature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSignature = sign(encodedPayload);
|
||||||
|
|
||||||
|
const providedBuffer = Buffer.from(providedSignature, 'utf8');
|
||||||
|
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
||||||
|
|
||||||
|
if (providedBuffer.length !== expectedBuffer.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedPayload: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedPayload = JSON.parse(base64UrlDecode(encodedPayload));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedPayload || typeof parsedPayload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsedPayload as Partial<DeviceTokenPayload>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof payload.userId !== 'string' ||
|
||||||
|
typeof payload.deviceId !== 'string' ||
|
||||||
|
(payload.role !== 'camera' && payload.role !== 'client') ||
|
||||||
|
typeof payload.exp !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.exp <= Math.floor(Date.now() / 1000)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as DeviceTokenPayload;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user