P1-2: Add onRequest auth hook to reject anonymous requests on all 7 VoicePrint endpoints. Previously, the auth middleware always attached a placeholder user (id='anonymous'), so per-route userId checks passed for unauthenticated clients. P1-3: Replace JSON body parsing with @fastify/multipart for POST /endpoints (/enroll, /analyze, /batch). Fastify JSON parser cannot produce Buffer from request.body; multipart/form-data is required for audio file uploads. Added 50MB file size limit.
301 lines
9.1 KiB
TypeScript
301 lines
9.1 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
import {
|
|
voiceEnrollmentService,
|
|
analysisService,
|
|
batchAnalysisService,
|
|
voicePrintEnv,
|
|
} from '../services/voiceprint';
|
|
|
|
interface AuthenticatedRequest extends FastifyRequest {
|
|
user?: { id: string; email: string; role: string };
|
|
authType?: 'jwt' | 'api-key' | 'anonymous';
|
|
}
|
|
|
|
export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|
// P1-2 fix: Require authentication on all VoicePrint routes
|
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
if (authReq.authType === 'anonymous' || !authReq.user?.id || authReq.user.id === 'anonymous') {
|
|
return reply.code(401).send({ error: 'Authentication required' });
|
|
}
|
|
});
|
|
|
|
// P1-3 fix: Register multipart for audio file uploads
|
|
await fastify.register(fastifyMultipart, {
|
|
limits: {
|
|
fileSize: voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC > 0
|
|
? 50 * 1024 * 1024 // 50MB max file size for audio
|
|
: 50 * 1024 * 1024,
|
|
},
|
|
});
|
|
// Enroll a new voice profile
|
|
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
// P1-3 fix: Parse multipart form-data for audio upload
|
|
let name: string | undefined;
|
|
let audioBuffer: Buffer | undefined;
|
|
|
|
for await (const part of request.files()) {
|
|
if (part.type === 'file') {
|
|
audioBuffer = await part.toBuffer();
|
|
name = name || part.filename || 'voice_enrollment';
|
|
} else if (part.fieldname === 'name') {
|
|
name = part.value;
|
|
}
|
|
}
|
|
|
|
if (!audioBuffer || audioBuffer.length === 0) {
|
|
return reply.code(400).send({ error: 'audio file is required' });
|
|
}
|
|
|
|
try {
|
|
const enrollment = await voiceEnrollmentService.enroll(
|
|
userId,
|
|
name || 'voice_enrollment',
|
|
audioBuffer
|
|
);
|
|
return reply.code(201).send({
|
|
enrollment: {
|
|
id: enrollment.id,
|
|
name: enrollment.name,
|
|
isActive: enrollment.isActive,
|
|
createdAt: enrollment.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Enrollment failed';
|
|
return reply.code(422).send({ error: message });
|
|
}
|
|
});
|
|
|
|
// List user's voice enrollments
|
|
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
const isActive = request.query as { isActive?: string };
|
|
const limit = request.query as { limit?: string };
|
|
const offset = request.query as { offset?: string };
|
|
|
|
const enrollments = await voiceEnrollmentService.listEnrollments(userId, {
|
|
isActive: isActive.isActive !== undefined
|
|
? isActive.isActive === 'true'
|
|
: undefined,
|
|
limit: limit.limit ? parseInt(limit.limit, 10) : undefined,
|
|
offset: offset.offset ? parseInt(offset.offset, 10) : undefined,
|
|
});
|
|
|
|
return reply.send({
|
|
enrollments: enrollments.map((e) => ({
|
|
id: e.id,
|
|
name: e.name,
|
|
isActive: e.isActive,
|
|
createdAt: e.createdAt,
|
|
})),
|
|
});
|
|
});
|
|
|
|
// Remove an enrollment
|
|
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
const enrollmentId = (request.params as { id: string }).id;
|
|
|
|
try {
|
|
const enrollment = await voiceEnrollmentService.removeEnrollment(
|
|
enrollmentId,
|
|
userId
|
|
);
|
|
return reply.send({
|
|
enrollment: {
|
|
id: enrollment.id,
|
|
name: enrollment.name,
|
|
isActive: enrollment.isActive,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Removal failed';
|
|
return reply.code(404).send({ error: message });
|
|
}
|
|
});
|
|
|
|
// Analyze a single audio file
|
|
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
// P1-3 fix: Parse multipart form-data for audio upload
|
|
let audioBuffer: Buffer | undefined;
|
|
let enrollmentId: string | undefined;
|
|
let audioUrl: string | undefined;
|
|
|
|
for await (const part of request.files()) {
|
|
if (part.type === 'file') {
|
|
audioBuffer = await part.toBuffer();
|
|
} else if (part.fieldname === 'enrollmentId') {
|
|
enrollmentId = part.value;
|
|
} else if (part.fieldname === 'audioUrl') {
|
|
audioUrl = part.value;
|
|
}
|
|
}
|
|
|
|
if (!audioBuffer || audioBuffer.length === 0) {
|
|
return reply.code(400).send({ error: 'audio file is required' });
|
|
}
|
|
|
|
try {
|
|
const result = await analysisService.analyze(userId, audioBuffer, {
|
|
enrollmentId,
|
|
audioUrl,
|
|
});
|
|
return reply.code(201).send({
|
|
analysis: {
|
|
id: result.id,
|
|
isSynthetic: result.isSynthetic,
|
|
confidence: result.confidence,
|
|
analysisResult: result.analysisResult,
|
|
createdAt: result.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Analysis failed';
|
|
return reply.code(422).send({ error: message });
|
|
}
|
|
});
|
|
|
|
// Get analysis result by ID
|
|
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
const analysisId = (request.params as { id: string }).id;
|
|
const result = await analysisService.getResult(analysisId, userId);
|
|
|
|
if (!result) {
|
|
return reply.code(404).send({ error: 'Analysis not found' });
|
|
}
|
|
|
|
return reply.send({
|
|
analysis: {
|
|
id: result.id,
|
|
isSynthetic: result.isSynthetic,
|
|
confidence: result.confidence,
|
|
analysisResult: result.analysisResult,
|
|
createdAt: result.createdAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Get analysis history
|
|
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
const query = request.query as {
|
|
limit?: string;
|
|
offset?: string;
|
|
isSynthetic?: string;
|
|
};
|
|
|
|
const results = await analysisService.getHistory(userId, {
|
|
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
offset: query.offset ? parseInt(query.offset, 10) : undefined,
|
|
isSynthetic: query.isSynthetic !== undefined
|
|
? query.isSynthetic === 'true'
|
|
: undefined,
|
|
});
|
|
|
|
return reply.send({
|
|
analyses: results.map((r) => ({
|
|
id: r.id,
|
|
isSynthetic: r.isSynthetic,
|
|
confidence: r.confidence,
|
|
createdAt: r.createdAt,
|
|
})),
|
|
});
|
|
});
|
|
|
|
// Batch analyze multiple audio files
|
|
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as AuthenticatedRequest;
|
|
const userId = authReq.user?.id;
|
|
|
|
if (!userId) {
|
|
return reply.code(401).send({ error: 'User ID required' });
|
|
}
|
|
|
|
// P1-3 fix: Parse multipart form-data for multiple audio uploads
|
|
const files: Array<{ name: string; buffer: Buffer; audioUrl?: string }> = [];
|
|
let enrollmentId: string | undefined;
|
|
|
|
for await (const part of request.files()) {
|
|
if (part.type === 'file') {
|
|
const buffer = await part.toBuffer();
|
|
files.push({
|
|
name: part.filename || `file_${files.length}`,
|
|
buffer,
|
|
});
|
|
} else if (part.fieldname === 'enrollmentId') {
|
|
enrollmentId = part.value;
|
|
} else if (part.fieldname === 'audioUrl') {
|
|
if (files.length > 0) {
|
|
files[files.length - 1].audioUrl = part.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
return reply.code(400).send({ error: 'at least one audio file is required' });
|
|
}
|
|
|
|
try {
|
|
const result = await batchAnalysisService.analyzeBatch(
|
|
userId,
|
|
files,
|
|
{ enrollmentId }
|
|
);
|
|
|
|
return reply.code(201).send({
|
|
jobId: result.jobId,
|
|
results: result.results.map((r) => ({
|
|
id: r.id,
|
|
isSynthetic: r.isSynthetic,
|
|
confidence: r.confidence,
|
|
})),
|
|
summary: result.summary,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Batch analysis failed';
|
|
return reply.code(422).send({ error: message });
|
|
}
|
|
});
|
|
}
|