fix: address security vulnerabilities and add image attachments

- Fix type confusion vulnerability in comment content updates
- Validate pagination offsets to prevent negative values
- Validate timestamp is a valid number before parsing
- Exclude guestEmail from comment API responses for privacy
- Fix TypeScript error in audio upload route
- Add image attachment support for comments with upload API
- Update admin dashboard to track image attachments
- Rename cleanup functions to handle both voice and image media
This commit is contained in:
Yusuf İpek
2026-02-21 16:40:58 +03:00
parent cd9b89c971
commit e32196c430
15 changed files with 837 additions and 119 deletions
+9
View File
@@ -17,6 +17,7 @@ export async function GET() {
totalVideos,
totalComments,
totalVoiceComments,
totalImageComments,
] = await Promise.all([
db.user.count(),
db.project.count(),
@@ -28,6 +29,13 @@ export async function GET() {
not: null,
}
}
}),
db.comment.count({
where: {
imageUrl: {
not: null,
}
}
})
]);
@@ -40,6 +48,7 @@ export async function GET() {
totalVideos,
totalComments,
totalVoiceComments,
totalImageComments,
totalStorageBytes,
});
} catch (error) {
+58 -14
View File
@@ -16,12 +16,46 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
version: {
@@ -155,7 +189,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
const updateData: Record<string, unknown> = {};
if (content !== undefined) updateData.content = content.trim();
if (content !== undefined && typeof content === 'string') updateData.content = content.trim();
if (tagId !== undefined) updateData.tagId = tagId;
if (isResolved !== undefined) {
updateData.isResolved = isResolved;
@@ -201,7 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
replies: { select: { voiceUrl: true } },
replies: { select: { voiceUrl: true, imageUrl: true } },
},
});
@@ -215,27 +249,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('You can only delete your own comments');
}
// Collect all voice URLs to delete from R2 (comment + its replies)
const voiceUrls: string[] = [];
if (comment.voiceUrl) voiceUrls.push(comment.voiceUrl);
// Collect all media URLs to delete from R2 (comment + its replies)
const mediaUrls: string[] = [];
if (comment.voiceUrl) mediaUrls.push(comment.voiceUrl);
if (comment.imageUrl) mediaUrls.push(comment.imageUrl);
for (const reply of comment.replies) {
if (reply.voiceUrl) voiceUrls.push(reply.voiceUrl);
if (reply.voiceUrl) mediaUrls.push(reply.voiceUrl);
if (reply.imageUrl) mediaUrls.push(reply.imageUrl);
}
await db.comment.delete({ where: { id: commentId } });
// Clean up audio files from R2 (best-effort, don't block on failure)
// Clean up media files from R2 (best-effort, don't block on failure)
const AUDIO_PREFIX = '/api/upload/audio/';
for (const url of voiceUrls) {
const IMAGE_PREFIX = '/api/upload/image/';
for (const url of mediaUrls) {
try {
// Extract filename using string parsing (safe against ReDoS)
const idx = url.indexOf(AUDIO_PREFIX);
const filename = idx !== -1 ? url.slice(idx + AUDIO_PREFIX.length) : null;
if (filename) {
let key: string | null = null;
if (url.includes(AUDIO_PREFIX)) {
const filename = url.slice(url.indexOf(AUDIO_PREFIX) + AUDIO_PREFIX.length);
if (filename) key = `voice/${filename}`;
} else if (url.includes(IMAGE_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PREFIX) + IMAGE_PREFIX.length);
if (filename) key = `images/${filename}`;
}
if (key) {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: `voice/${filename}`,
Key: key,
})
);
}
+4 -4
View File
@@ -3,7 +3,7 @@ import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup';
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -52,11 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
// Parse pagination params
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
const project = await db.project.findUnique({
where: { id: projectId },
@@ -197,7 +197,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectVoiceFiles(projectId);
await cleanupProjectMediaFiles(projectId);
await db.project.delete({ where: { id: projectId } });
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup';
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -18,7 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams;
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = parseInt(searchParams.get('commentOffset') || '0');
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true';
const video = await db.video.findFirst({
@@ -32,20 +32,54 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
orderBy: { timestamp: 'asc' },
skip: commentOffset,
take: commentLimit,
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? {
replies: {
orderBy: { createdAt: 'asc' },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
// guestEmail excluded for privacy
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
} : {}),
},
where: { parentId: null }, // Only top-level comments
where: { parentId: null },
},
_count: { select: { comments: true } },
},
@@ -194,7 +228,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoVoiceFiles(videoId);
await cleanupVideoMediaFiles(videoId);
await db.video.delete({ where: { id: videoId } });
+2 -2
View File
@@ -62,8 +62,8 @@ export async function GET(
// Convert stream to Uint8Array
const chunks: Uint8Array[] = [];
// @ts-expect-error - body is an iterable
for await (const chunk of body) {
const asyncIterable = body as AsyncIterable<Uint8Array>;
for await (const chunk of asyncIterable) {
chunks.push(chunk);
}
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { apiErrors } from '@/lib/api-response';
// Only allow UUID filenames with safe extensions
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const CONTENT_TYPE_MAP: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
};
function getContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_FILENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
const key = `images/${filename}`;
// Get file metadata to determine content type
const headResponse = await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
const contentType = headResponse.ContentType || getContentType(filename);
const objectResponse = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
const body = objectResponse.Body;
if (!body) {
return apiErrors.internalError('Empty file');
}
const chunks: Uint8Array[] = [];
// @ts-expect-error - body is an iterable
for await (const chunk of body) {
chunks.push(chunk);
}
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
uint8Array.set(chunk, offset);
offset += chunk.length;
}
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Accept-Ranges': 'bytes',
},
});
} catch (error: unknown) {
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'NoSuchKey') {
return apiErrors.notFound('File');
}
console.error('Error serving image:', error);
return apiErrors.internalError('Failed to retrieve image');
}
}
+84
View File
@@ -0,0 +1,84 @@
import { auth } from '@/lib/auth';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml'
];
export async function POST(request: Request) {
try {
// Check Content-Length header BEFORE loading the file
const contentLength = request.headers.get('content-length');
if (contentLength) {
const fileSize = parseInt(contentLength, 10);
if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
}
// Rate limit
const limited = await rateLimit(request, 'image-upload');
if (limited) return limited;
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const formData = await request.formData();
const file = formData.get('image') as File | null;
if (!file) {
return apiErrors.badRequest('No image file provided');
}
// Double-check file size (defense in depth - Content-Length can be spoofed)
if (file.size > MAX_FILE_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
// Check content type
const contentType = file.type;
if (!ALLOWED_TYPES.includes(contentType)) {
return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
}
// Generate unique filename
const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
const filename = `${randomUUID()}.${ext}`;
const key = `images/${filename}`;
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to R2
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// Return the URL through our proxy endpoint
const imageUrl = `/api/upload/image/${filename}`;
const response = successResponse({ url: imageUrl }, 201);
return withCacheControl(response, 'public, max-age=31536000, immutable');
} catch (error) {
console.error('Error uploading image:', error);
return apiErrors.internalError('Failed to upload image');
}
}
+53 -8
View File
@@ -71,12 +71,44 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
...(includeResolved ? {} : { isResolved: false }),
},
orderBy: { timestamp: 'asc' },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
select: {
id: true,
content: true,
timestamp: true,
timestampEnd: true,
createdAt: true,
updatedAt: true,
isResolved: true,
resolvedAt: true,
voiceUrl: true,
voiceDuration: true,
imageUrl: true,
parentId: true,
authorId: true,
tagId: true,
versionId: true,
guestName: true,
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
@@ -152,15 +184,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const body = await request.json();
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId } = body;
const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId, imageUrl } = body;
// Validate required fields
if (timestamp === undefined || timestamp === null) {
return apiErrors.badRequest('Timestamp is required');
}
if (!content && !voiceUrl) {
return apiErrors.badRequest('Either content or voice recording is required');
const parsedTimestamp = parseFloat(timestamp);
if (isNaN(parsedTimestamp)) {
return apiErrors.badRequest('Timestamp must be a valid number');
}
if (!content && !voiceUrl && !imageUrl) {
return apiErrors.badRequest('Either content, a voice recording, or an image attachment is required');
}
// If replying, verify parent exists in same version
@@ -187,14 +224,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
if (imageUrl && !imageUrl.startsWith('/api/')) {
const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
if (imageUrlError) {
return apiErrors.badRequest(imageUrlError);
}
}
const comment = await db.comment.create({
data: {
content: content?.trim() || null,
timestamp: parseFloat(timestamp),
timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
imageUrl: imageUrl || null,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null,
@@ -234,7 +279,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name,
videoTitle,
replyAuthor: commentAuthorName,
replyText: content?.trim() || '(voice note)',
replyText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
parentAuthor: parentComment?.author?.name || parentComment?.guestName || 'Someone',
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
@@ -245,7 +290,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
projectName: project.name,
videoTitle,
commentAuthor: commentAuthorName,
commentText: content?.trim() || '(voice note)',
commentText: content?.trim() || (imageUrl ? '(image attachment)' : '(voice note)'),
timestamp: ts,
url: `${baseUrl}/watch/${version.video.id}`,
}).catch((err) => console.error('Notification failed:', err));
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup';
import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -145,7 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceVoiceFiles(workspaceId);
await cleanupWorkspaceMediaFiles(workspaceId);
await db.workspace.delete({ where: { id: workspaceId } });