import { Router } from 'express'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/client'; import { devices, events, recordings } from '../db/schema'; import { requireAuth } from '../middleware/auth'; import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds, } from '../utils/minio'; const router = Router(); const uploadUrlSchema = z.object({ fileName: z.string().trim().min(1).max(255), deviceId: z.string().uuid(), prefix: z.string().trim().optional(), recordingId: z.string().uuid().optional(), eventId: z.string().uuid().optional(), }); const downloadUrlSchema = z.object({ objectKey: z.string().trim().min(1), }); const listSchema = z.object({ prefix: z.string().trim().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }); const sanitizeSegment = (value: string): string => value.replace(/[^a-zA-Z0-9._/-]/g, '_'); const buildObjectKey = (userId: string, fileName: string, prefix?: string): string => { const safePrefix = prefix ? sanitizeSegment(prefix).replace(/^\/+|\/+$/g, '') : 'uploads'; const safeFileName = sanitizeSegment(fileName); return `${safePrefix}/${userId}/${Date.now()}-${safeFileName}`; }; router.use(requireAuth); router.post('/upload-url', async (req, res) => { const parsed = uploadUrlSchema.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) { res.status(401).json({ message: 'Unauthorized' }); return; } await ensureMinioBucket(); const device = await db.query.devices.findFirst({ where: and(eq(devices.id, parsed.data.deviceId), eq(devices.userId, authSession.user.id)), columns: { id: true }, }); if (!device) { res.status(400).json({ message: 'Invalid deviceId for this user' }); return; } if (parsed.data.eventId) { const event = await db.query.events.findFirst({ where: and(eq(events.id, parsed.data.eventId), eq(events.userId, authSession.user.id)), columns: { id: true }, }); if (!event) { res.status(400).json({ message: 'Invalid eventId for this user' }); return; } } const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const now = new Date(); const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); let persistedRecording; if (parsed.data.recordingId) { const existingRecording = await db.query.recordings.findFirst({ where: and(eq(recordings.id, parsed.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)), }); if (!existingRecording) { res.status(404).json({ message: 'Recording not found' }); return; } [persistedRecording] = await db .update(recordings) .set({ objectKey, bucket: minioBucket, status: 'awaiting_upload', updatedAt: now, error: null, }) .where(eq(recordings.id, existingRecording.id)) .returning(); } else { [persistedRecording] = await db .insert(recordings) .values({ ownerUserId: authSession.user.id, cameraDeviceId: parsed.data.deviceId, requesterDeviceId: null, eventId: parsed.data.eventId ?? null, objectKey, bucket: minioBucket, status: 'awaiting_upload', updatedAt: now, }) .returning(); } if (!persistedRecording) { res.status(500).json({ message: 'Unable to persist recording metadata' }); return; } res.status(201).json({ message: 'Upload URL generated', bucket: minioBucket, objectKey, uploadUrl, expiresInSeconds: minioPresignedExpirySeconds, video: { id: persistedRecording.id, objectKey: persistedRecording.objectKey, bucket: persistedRecording.bucket, status: persistedRecording.status, createdAt: persistedRecording.createdAt, expiresAt, }, }); }); router.get('/download-url', async (req, res) => { const parsed = downloadUrlSchema.safeParse(req.query); if (!parsed.success) { res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() }); return; } await ensureMinioBucket(); const downloadUrl = await minioClient.presignedGetObject( minioBucket, parsed.data.objectKey, minioPresignedExpirySeconds, ); res.json({ message: 'Dummy download URL generated', bucket: minioBucket, objectKey: parsed.data.objectKey, downloadUrl, expiresInSeconds: minioPresignedExpirySeconds, }); }); router.get('/', async (req, res) => { const parsed = listSchema.safeParse(req.query); if (!parsed.success) { res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() }); return; } await ensureMinioBucket(); const objects = await new Promise< { objectKey: string | undefined; size: number; etag: string | undefined; lastModified: Date | undefined }[] >((resolve, reject) => { const results: { objectKey: string | undefined; size: number; etag: string | undefined; lastModified: Date | undefined; }[] = []; const stream = minioClient.listObjectsV2(minioBucket, parsed.data.prefix, true); stream.on('data', (item) => { if (results.length >= parsed.data.limit) { stream.destroy(); return; } results.push({ objectKey: item.name, size: item.size, etag: item.etag, lastModified: item.lastModified, }); }); stream.on('error', (error) => reject(error)); stream.on('end', () => resolve(results)); stream.on('close', () => resolve(results)); }); res.json({ bucket: minioBucket, count: objects.length, objects, }); }); router.delete('/', async (req, res) => { const parsed = downloadUrlSchema.safeParse(req.query); if (!parsed.success) { res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() }); return; } await ensureMinioBucket(); await minioClient.removeObject(minioBucket, parsed.data.objectKey); res.json({ message: 'Object deleted', bucket: minioBucket, objectKey: parsed.data.objectKey }); }); export default router;