mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(feedback): add user feedback/review system with admin management and hardened image upload validation
This commit is contained in:
@@ -13,13 +13,12 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
|
||||
return CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
@@ -47,11 +46,28 @@ export async function GET(
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
const userFeedbackScreenshotDelegate = (db as unknown as {
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args?: unknown) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedbackScreenshot;
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.userFeedback.findFirst({
|
||||
where: { screenshotUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findFirst({
|
||||
where: { url: mediaUrl },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (!commentReferenced && !feedbackReferenced && !feedbackAttachmentReferenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
@@ -62,7 +78,7 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
const contentType = getContentType(filename);
|
||||
|
||||
const objectResponse = await r2Client.send(
|
||||
new GetObjectCommand({
|
||||
@@ -94,6 +110,8 @@ export async function GET(
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -8,6 +8,12 @@ import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
detectImageMime,
|
||||
getImageExtension,
|
||||
isAllowedImageType,
|
||||
normalizeImageMime,
|
||||
} from '@/lib/image-upload-validation';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
@@ -15,22 +21,21 @@ import {
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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.');
|
||||
}
|
||||
if (!contentLength) {
|
||||
return apiErrors.badRequest('Missing Content-Length header');
|
||||
}
|
||||
const bodySize = parseInt(contentLength, 10);
|
||||
if (isNaN(bodySize) || bodySize <= 0) {
|
||||
return apiErrors.badRequest('Invalid Content-Length header');
|
||||
}
|
||||
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
@@ -40,11 +45,15 @@ export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
const files = formData.getAll('image');
|
||||
if (files.length !== 1) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
const file = files[0];
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
if (!(file instanceof File)) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
@@ -107,19 +116,23 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = file.type;
|
||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
|
||||
const normalizedMime = normalizeImageMime(file.type);
|
||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = 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);
|
||||
const detectedMime = detectImageMime(buffer);
|
||||
if (!detectedMime) {
|
||||
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = getImageExtension(detectedMime);
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
// Upload to R2
|
||||
await r2Client.send(
|
||||
@@ -127,7 +140,7 @@ export async function POST(request: NextRequest) {
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
ContentType: detectedMime,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user