mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user