mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(feedback): add user feedback/review system with admin management and hardened image upload validation
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
|
||||
type RouteParams = { params: Promise<{ feedbackId: string }> };
|
||||
|
||||
function extractImageFilenameFromProxyUrl(url: string): string | null {
|
||||
const match = url.match(/^\/api\/upload\/image\/([0-9a-f-]+\.[a-z0-9]+)$/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// DELETE /api/admin/feedback/[feedbackId]
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
return apiErrors.forbidden('Admin access required');
|
||||
}
|
||||
|
||||
const { feedbackId } = await params;
|
||||
const userFeedbackDelegate = (db as unknown as {
|
||||
userFeedback?: {
|
||||
findUnique: (args: unknown) => Promise<{
|
||||
id: string;
|
||||
screenshotUrl: string | null;
|
||||
screenshots?: Array<{ url: string }>;
|
||||
} | null>;
|
||||
delete: (args: { where: { id: string } }) => Promise<{ id: string }>;
|
||||
findFirst: (args: { where: { screenshotUrl: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedback;
|
||||
const userFeedbackScreenshotDelegate = (db as unknown as {
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedbackScreenshot;
|
||||
|
||||
if (!userFeedbackDelegate) {
|
||||
return apiErrors.internalError('Feedback model is not available yet');
|
||||
}
|
||||
|
||||
let feedbackRecord = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
include: {
|
||||
screenshots: {
|
||||
select: { url: true },
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Unknown field `screenshots`')) return null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!feedbackRecord) {
|
||||
feedbackRecord = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!feedbackRecord) {
|
||||
return apiErrors.notFound('Feedback');
|
||||
}
|
||||
|
||||
const mediaUrls = new Set<string>();
|
||||
if (feedbackRecord.screenshotUrl) mediaUrls.add(feedbackRecord.screenshotUrl);
|
||||
(feedbackRecord.screenshots ?? []).forEach((item) => {
|
||||
if (item.url) mediaUrls.add(item.url);
|
||||
});
|
||||
|
||||
await userFeedbackDelegate.delete({
|
||||
where: { id: feedbackId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(mediaUrls).map(async (url) => {
|
||||
const filename = extractImageFilenameFromProxyUrl(url);
|
||||
if (!filename) return;
|
||||
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackDelegate.findFirst({
|
||||
where: { screenshotUrl: url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findFirst({
|
||||
where: { url },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return;
|
||||
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: `images/${filename}`,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
})
|
||||
);
|
||||
|
||||
return successResponse({ id: feedbackId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Record to delete does not exist')) {
|
||||
return apiErrors.notFound('Feedback');
|
||||
}
|
||||
console.error('Error deleting feedback:', error);
|
||||
return apiErrors.internalError('Failed to delete feedback');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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