feat(feedback): add user feedback/review system with admin management and hardened image upload validation

This commit is contained in:
Yusuf İpek
2026-02-24 16:05:11 +03:00
parent 4083651025
commit afa1529873
15 changed files with 1725 additions and 34 deletions
+151
View File
@@ -0,0 +1,151 @@
import { NextRequest } from 'next/server';
import { FeedbackCategory, FeedbackEntryType } from '@prisma/client';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { apiErrors, successResponse } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
interface FeedbackPayload {
type?: string;
category?: string;
title?: string;
message?: string;
screenshotUrl?: string;
screenshotUrls?: string[];
rating?: number;
allowShowcase?: boolean;
}
// POST /api/feedback
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'feedback-submit');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized('You must be signed in to submit feedback');
}
const body = (await request.json()) as FeedbackPayload;
const type = body.type;
const title = body.title?.trim() ?? '';
const message = body.message?.trim() ?? '';
const legacyScreenshotUrl = body.screenshotUrl?.trim() ?? null;
const screenshotUrls = Array.isArray(body.screenshotUrls)
? body.screenshotUrls
.map((url) => (typeof url === 'string' ? url.trim() : ''))
.filter((url) => !!url)
: (legacyScreenshotUrl ? [legacyScreenshotUrl] : []);
if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) {
return apiErrors.badRequest('Invalid entry type');
}
if (title.length < 3 || title.length > 120) {
return apiErrors.badRequest('Title must be between 3 and 120 characters');
}
if (message.length < 10 || message.length > 3000) {
return apiErrors.badRequest('Message must be between 10 and 3000 characters');
}
if (screenshotUrls.length > 5) {
return apiErrors.badRequest('You can upload up to 5 screenshots');
}
if (screenshotUrls.some((url) => !url.startsWith('/api/upload/image/'))) {
return apiErrors.badRequest('Invalid screenshot URL(s)');
}
if (type === FeedbackEntryType.FEEDBACK) {
if (
body.category !== FeedbackCategory.BUG &&
body.category !== FeedbackCategory.FEATURE &&
body.category !== FeedbackCategory.OTHER
) {
return apiErrors.badRequest('Feedback category is required');
}
}
if (type === FeedbackEntryType.REVIEW) {
if (!Number.isInteger(body.rating) || (body.rating as number) < 1 || (body.rating as number) > 5) {
return apiErrors.badRequest('Review rating must be between 1 and 5');
}
}
let usedLegacyCreatePath = false;
let entry: { id: string; type: FeedbackEntryType; createdAt: Date };
try {
entry = await db.userFeedback.create({
data: {
userId: session.user.id,
type,
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
title,
message,
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
screenshots: type === FeedbackEntryType.FEEDBACK
? {
create: screenshotUrls.map((url) => ({ url })),
}
: undefined,
},
select: {
id: true,
type: true,
createdAt: true,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '';
if (!errorMessage.includes('Unknown argument `screenshots`')) {
throw error;
}
usedLegacyCreatePath = true;
entry = await db.userFeedback.create({
data: {
userId: session.user.id,
type,
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
title,
message,
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
},
select: {
id: true,
type: true,
createdAt: true,
},
});
}
if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) {
const screenshotDelegate = (db as unknown as {
userFeedbackScreenshot?: {
createMany: (args: { data: Array<{ feedbackId: string; url: string }> }) => Promise<unknown>;
};
}).userFeedbackScreenshot;
if (screenshotDelegate) {
await screenshotDelegate.createMany({
data: screenshotUrls.map((url) => ({
feedbackId: entry.id,
url,
})),
}).catch(() => undefined);
}
}
return successResponse(entry, 201);
} catch (error) {
console.error('Error submitting feedback:', error);
return apiErrors.internalError('Failed to submit feedback');
}
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from 'crypto';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
import {
detectImageMime,
getImageExtension,
isAllowedImageType,
normalizeImageMime,
} from '@/lib/image-upload-validation';
import { rateLimit } from '@/lib/rate-limit';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
// POST /api/feedback/upload
export async function POST(request: NextRequest) {
try {
const limited = await rateLimit(request, 'feedback-upload');
if (limited) return limited;
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized('You must be signed in to upload screenshots');
}
const contentLength = request.headers.get('content-length');
if (!contentLength) {
return apiErrors.badRequest('Missing Content-Length header');
}
const size = parseInt(contentLength, 10);
if (Number.isNaN(size) || size <= 0) {
return apiErrors.badRequest('Invalid Content-Length header');
}
if (size > MAX_MULTIPART_BODY_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
const formData = await request.formData();
const files = formData.getAll('image');
if (files.length !== 1) {
return apiErrors.badRequest('No image file provided');
}
const file = files[0];
if (!(file instanceof File)) {
return apiErrors.badRequest('No image file provided');
}
if (file.size > MAX_FILE_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
const normalizedMime = normalizeImageMime(file.type);
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
}
const buffer = Buffer.from(await file.arrayBuffer());
const detectedMime = detectImageMime(buffer);
if (!detectedMime) {
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
}
const ext = getImageExtension(detectedMime);
const filename = `${randomUUID()}.${ext}`;
const key = `images/${filename}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: detectedMime,
})
);
return successResponse({ url: `/api/upload/image/${filename}` }, 201);
} catch (error) {
console.error('Error uploading feedback screenshot:', error);
return apiErrors.internalError('Failed to upload screenshot');
}
}