Files

369 lines
11 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,
minioPresignClient,
minioPresignedExpirySeconds,
minioPublicOrigin,
} 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 uploadProxyParamsSchema = z.object({
recordingId: z.string().uuid(),
});
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.put('/upload/:recordingId', async (req, res) => {
const parsedParams = uploadProxyParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
return;
}
const authSession = req.auth;
if (!authSession?.user) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const recording = await db.query.recordings.findFirst({
where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)),
});
if (!recording) {
res.status(404).json({ message: 'Recording not found' });
return;
}
if (!recording.bucket || !recording.objectKey) {
res.status(409).json({ message: 'Recording does not have a storage target yet' });
return;
}
if (recording.status !== 'awaiting_upload') {
res.status(409).json({ message: `Recording is not awaiting upload (current status: ${recording.status})` });
return;
}
const contentType = typeof req.headers['content-type'] === 'string' && req.headers['content-type'].trim()
? req.headers['content-type'].trim()
: 'application/octet-stream';
const rawContentLength = Array.isArray(req.headers['content-length'])
? req.headers['content-length'][0]
: req.headers['content-length'];
const parsedSize = rawContentLength ? Number(rawContentLength) : undefined;
if (parsedSize !== undefined && (!Number.isFinite(parsedSize) || parsedSize < 0)) {
res.status(400).json({ message: 'Invalid Content-Length header' });
return;
}
try {
await ensureMinioBucket();
console.info('[recording.proxy-upload] streaming upload via backend', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
deviceId: recording.cameraDeviceId,
bucket: recording.bucket,
objectKey: recording.objectKey,
contentType,
sizeBytes: parsedSize ?? null,
});
const uploadResult = await minioClient.putObject(
recording.bucket,
recording.objectKey,
req,
parsedSize,
{ 'Content-Type': contentType },
);
console.info('[recording.proxy-upload] upload complete', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
etag: uploadResult.etag,
versionId: uploadResult.versionId ?? null,
sizeBytes: parsedSize ?? null,
});
res.status(201).json({
message: 'Recording uploaded via backend proxy',
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
etag: uploadResult.etag,
versionId: uploadResult.versionId ?? null,
sizeBytes: parsedSize ?? null,
});
} catch (error) {
console.error('[recording.proxy-upload] failed', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
});
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;
}
try {
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 minioPresignClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
const now = new Date();
const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000);
console.info('[recording.upload-url]', {
ownerUserId: authSession.user.id,
deviceId: parsed.data.deviceId,
recordingId: parsed.data.recordingId ?? null,
eventId: parsed.data.eventId ?? null,
objectKey,
bucket: minioBucket,
expiresInSeconds: minioPresignedExpirySeconds,
minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost',
minioPort: Number(process.env.MINIO_PORT ?? 9000),
minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true',
minioPublicOrigin,
});
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,
},
});
} catch (error) {
console.error('[recording.upload-url] failed', {
ownerUserId: authSession.user.id,
deviceId: parsed.data.deviceId,
recordingId: parsed.data.recordingId ?? null,
eventId: parsed.data.eventId ?? null,
fileName: parsed.data.fileName,
prefix: parsed.data.prefix ?? null,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
});
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 minioPresignClient.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;