237 lines
6.3 KiB
TypeScript
237 lines
6.3 KiB
TypeScript
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;
|