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:
+14
-1
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedTotalStorage } from '@/lib/admin-stats';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive } from 'lucide-react';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | OpenFrame',
|
||||
@@ -34,6 +34,7 @@ export default async function AdminDashboardPage() {
|
||||
totalVideos,
|
||||
totalComments,
|
||||
totalVoiceComments,
|
||||
totalImageComments,
|
||||
] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.project.count(),
|
||||
@@ -42,6 +43,9 @@ export default async function AdminDashboardPage() {
|
||||
db.comment.count({
|
||||
where: { voiceUrl: { not: null } },
|
||||
}),
|
||||
db.comment.count({
|
||||
where: { imageUrl: { not: null } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 2. Storage Stats (Cached)
|
||||
@@ -101,6 +105,15 @@ export default async function AdminDashboardPage() {
|
||||
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Image Attachments</CardTitle>
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalImageComments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Metadata } from 'next';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedUserVoiceStorage } from '@/lib/admin-stats';
|
||||
import { getCachedUserMediaStorage } from '@/lib/admin-stats';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -69,8 +70,8 @@ export default async function AdminUsersPage({
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
// Determine voice storage per user (Cached)
|
||||
const userStorage = await getCachedUserVoiceStorage();
|
||||
// Determine media storage per user (Cached)
|
||||
const userStorage = await getCachedUserMediaStorage();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
@@ -95,7 +96,7 @@ export default async function AdminUsersPage({
|
||||
<TableHead className="text-center">Workspaces Owned</TableHead>
|
||||
<TableHead className="text-center">Projects Owned</TableHead>
|
||||
<TableHead className="text-center">Total Comments</TableHead>
|
||||
<TableHead className="text-right">Voice Storage</TableHead>
|
||||
<TableHead className="text-right">Media Storage</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -120,8 +121,17 @@ export default async function AdminUsersPage({
|
||||
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm">
|
||||
{formatBytes(userStorage[user.id] || 0)}
|
||||
<TableCell className="text-right text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium text-foreground">{formatBytes(userStorage[user.id]?.total || 0)}</span>
|
||||
{(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && (
|
||||
<span className="text-xs text-muted-foreground mt-0.5 whitespace-nowrap space-x-1">
|
||||
{userStorage[user.id]?.voice > 0 && <span>🎤 {formatBytes(userStorage[user.id]?.voice)}</span>}
|
||||
{userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && <span>•</span>}
|
||||
{userStorage[user.id]?.image > 0 && <span>🖼️ {formatBytes(userStorage[user.id]?.image)}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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 } });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user