import { Router } from 'express'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/client'; import { devices, videos } 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(), }); 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; } 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); const [videoRecord] = await db .insert(videos) .values({ userId: authSession.user.id, deviceId: parsed.data.deviceId, objectKey, bucket: minioBucket, uploadUrl, status: 'upload_link_sent', expiresAt, updatedAt: now, }) .returning({ id: videos.id, objectKey: videos.objectKey, bucket: videos.bucket, status: videos.status, createdAt: videos.createdAt, expiresAt: videos.expiresAt, }); if (!videoRecord) { res.status(500).json({ message: 'Unable to persist video metadata' }); return; } res.status(201).json({ message: 'Dummy upload URL generated', bucket: minioBucket, objectKey, uploadUrl, expiresInSeconds: minioPresignedExpirySeconds, video: videoRecord, }); }); 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;