256 lines
7.4 KiB
TypeScript
256 lines
7.4 KiB
TypeScript
import { and, desc, eq } from 'drizzle-orm';
|
|
import { Router } from 'express';
|
|
import { z } from 'zod';
|
|
|
|
import { db } from '../db/client';
|
|
import { recordings } from '../db/schema';
|
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
|
import { writeAuditLog } from '../services/audit';
|
|
import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';
|
|
|
|
const router = Router();
|
|
|
|
const listSchema = z.object({
|
|
status: z.string().optional(),
|
|
limit: z.coerce.number().int().min(1).max(100).default(25),
|
|
});
|
|
|
|
const finalizeSchema = z.object({
|
|
objectKey: z.string().trim().min(1),
|
|
bucket: z.string().trim().min(1).default(minioBucket),
|
|
durationSeconds: z.coerce.number().int().nonnegative().optional(),
|
|
sizeBytes: z.coerce.number().int().nonnegative().optional(),
|
|
});
|
|
|
|
const recordingParamSchema = z.object({
|
|
recordingId: z.string().uuid(),
|
|
});
|
|
|
|
const isMissingStorageObjectError = (error: unknown): boolean => {
|
|
if (!error || typeof error !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const code = 'code' in error ? String((error as { code?: unknown }).code) : '';
|
|
return code === 'NoSuchKey' || code === 'NotFound';
|
|
};
|
|
|
|
router.get('/me/list', requireDeviceAuth, 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;
|
|
}
|
|
|
|
const deviceAuth = req.deviceAuth;
|
|
|
|
if (!deviceAuth) {
|
|
res.status(401).json({ message: 'Unauthorized' });
|
|
return;
|
|
}
|
|
|
|
const result = await db.query.recordings.findMany({
|
|
where: eq(recordings.ownerUserId, deviceAuth.userId),
|
|
orderBy: [desc(recordings.createdAt)],
|
|
limit: parsed.data.limit,
|
|
});
|
|
|
|
const filtered = parsed.data.status ? result.filter((recording) => recording.status === parsed.data.status) : result;
|
|
|
|
res.json({ count: filtered.length, recordings: filtered });
|
|
});
|
|
|
|
router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => {
|
|
const parsedParams = recordingParamSchema.safeParse(req.params);
|
|
|
|
if (!parsedParams.success) {
|
|
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
const parsed = finalizeSchema.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;
|
|
}
|
|
|
|
const recording = await db.query.recordings.findFirst({
|
|
where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)),
|
|
});
|
|
|
|
if (!recording) {
|
|
res.status(404).json({ message: 'Recording not found' });
|
|
return;
|
|
}
|
|
|
|
if (recording.cameraDeviceId !== deviceAuth.deviceId) {
|
|
res.status(403).json({ message: 'Only camera device can finalize this recording' });
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const bucket = parsed.data.bucket;
|
|
const objectKey = parsed.data.objectKey;
|
|
|
|
try {
|
|
await ensureMinioBucket();
|
|
console.info('[recording.finalize] checking storage object', {
|
|
recordingId: recording.id,
|
|
streamSessionId: recording.streamSessionId,
|
|
bucket,
|
|
objectKey,
|
|
durationSeconds: parsed.data.durationSeconds ?? null,
|
|
sizeBytes: parsed.data.sizeBytes ?? null,
|
|
});
|
|
|
|
try {
|
|
await minioClient.statObject(bucket, objectKey);
|
|
console.info('[recording.finalize] storage object found', {
|
|
recordingId: recording.id,
|
|
bucket,
|
|
objectKey,
|
|
});
|
|
} catch (error) {
|
|
if (isMissingStorageObjectError(error)) {
|
|
console.warn('[recording.finalize] storage object missing', {
|
|
recordingId: recording.id,
|
|
streamSessionId: recording.streamSessionId,
|
|
bucket,
|
|
objectKey,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
res.status(409).json({ message: 'Recording object does not exist in storage yet' });
|
|
return;
|
|
}
|
|
|
|
console.error('[recording.finalize] storage verification failed', {
|
|
recordingId: recording.id,
|
|
bucket,
|
|
objectKey,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(recordings)
|
|
.set({
|
|
objectKey,
|
|
bucket,
|
|
durationSeconds: parsed.data.durationSeconds,
|
|
sizeBytes: parsed.data.sizeBytes,
|
|
status: 'ready',
|
|
availableAt: now,
|
|
updatedAt: now,
|
|
error: null,
|
|
})
|
|
.where(eq(recordings.id, recording.id))
|
|
.returning();
|
|
|
|
console.info('[recording.finalize] recording marked ready', {
|
|
recordingId: recording.id,
|
|
streamSessionId: recording.streamSessionId,
|
|
bucket,
|
|
objectKey,
|
|
durationSeconds: parsed.data.durationSeconds ?? null,
|
|
sizeBytes: parsed.data.sizeBytes ?? null,
|
|
});
|
|
res.json({ message: 'Recording finalized', recording: updated });
|
|
|
|
await writeAuditLog({
|
|
ownerUserId: recording.ownerUserId,
|
|
actorDeviceId: recording.cameraDeviceId,
|
|
action: 'recording.finalized',
|
|
targetType: 'recording',
|
|
targetId: recording.id,
|
|
metadata: { objectKey: parsed.data.objectKey, bucket: parsed.data.bucket },
|
|
ipAddress: req.ip,
|
|
});
|
|
} catch (error) {
|
|
console.error('[recording.finalize] failed', {
|
|
recordingId: recording.id,
|
|
streamSessionId: recording.streamSessionId,
|
|
bucket,
|
|
objectKey,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => {
|
|
const parsedParams = recordingParamSchema.safeParse(req.params);
|
|
|
|
if (!parsedParams.success) {
|
|
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
const deviceAuth = req.deviceAuth;
|
|
|
|
if (!deviceAuth) {
|
|
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, deviceAuth.userId)),
|
|
});
|
|
|
|
if (!recording) {
|
|
res.status(404).json({ message: 'Recording not found' });
|
|
return;
|
|
}
|
|
|
|
if (recording.status !== 'ready' || !recording.objectKey || !recording.bucket) {
|
|
res.status(409).json({ message: 'Recording is not available yet' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await minioClient.statObject(recording.bucket, recording.objectKey);
|
|
} catch (error) {
|
|
if (isMissingStorageObjectError(error)) {
|
|
res.status(409).json({ message: 'Recording file is missing from storage' });
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const downloadUrl = await minioClient.presignedGetObject(
|
|
recording.bucket,
|
|
recording.objectKey,
|
|
minioPresignedExpirySeconds,
|
|
);
|
|
|
|
res.json({
|
|
recordingId: recording.id,
|
|
objectKey: recording.objectKey,
|
|
bucket: recording.bucket,
|
|
downloadUrl,
|
|
expiresInSeconds: minioPresignedExpirySeconds,
|
|
});
|
|
|
|
await writeAuditLog({
|
|
ownerUserId: recording.ownerUserId,
|
|
actorDeviceId: deviceAuth.deviceId,
|
|
action: 'recording.download_url_issued',
|
|
targetType: 'recording',
|
|
targetId: recording.id,
|
|
metadata: { objectKey: recording.objectKey, bucket: recording.bucket },
|
|
ipAddress: req.ip,
|
|
});
|
|
});
|
|
|
|
export default router;
|