From 20005f1a15617771a2f2fe1ff9db89a60f83fcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 14 Feb 2026 15:59:30 +0300 Subject: [PATCH] fix(api): add rate limiting, file size validation, and timeout handling - Add Content-Length header check for early file size validation on audio upload - Add rate limiting (60 req/min) to public watch endpoint - Add 10-second timeout with AbortController for YouTube and Vimeo oEmbed requests - Add automatic rate limit cleanup interval for self-hosted servers - Fix null check for comment.replies in video page content - Add checkWorkspaceAccess helper for workspace authorization --- .gitignore | 3 ++- app/api/upload/audio/route.ts | 10 ++++++++++ app/api/watch/[videoId]/route.ts | 5 +++++ components/video-page-content.tsx | 2 +- lib/auth.ts | 30 ++++++++++++++++++++++++++++++ lib/rate-limit.ts | 8 ++++++++ lib/video-providers/vimeo.ts | 7 ++++++- lib/video-providers/youtube.ts | 7 ++++++- 8 files changed, 68 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index dd85413..e870033 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ next-env.d.ts # Progress (Internal Tracking) PROGRESS.md -Optimization.md \ No newline at end of file +Optimization.md +.kilocode \ No newline at end of file diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index fe86f39..7c2cffd 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -10,6 +10,15 @@ const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'au 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, 'voice-upload'); if (limited) return limited; @@ -27,6 +36,7 @@ export async function POST(request: Request) { return apiErrors.badRequest('No audio 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.'); } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 10cda16..e94f238 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -2,12 +2,17 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; type RouteParams = { params: Promise<{ videoId: string }> }; // GET /api/watch/[videoId] - Public watch endpoint (no projectId needed) export async function GET(request: NextRequest, { params }: RouteParams) { try { + // Rate limit: 60 requests per minute per IP for public watch endpoint + const limited = await rateLimit(request, 'watch', { windowMs: 60 * 1000, maxRequests: 60 }); + if (limited) return limited; + const session = await auth(); const { videoId } = await params; diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 89d3f84..ddb22a3 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -2224,7 +2224,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi )} - {comment.replies.length > 0 && ( + {comment.replies && comment.replies.length > 0 && (
{comment.replies.map((reply) => { const replyAuthor = diff --git a/lib/auth.ts b/lib/auth.ts index dad77bf..8e8afa4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -125,3 +125,33 @@ export async function checkProjectAccess( canDelete, }; } + +// Helper to check workspace access +export async function checkWorkspaceAccess( + workspace: { id: string; ownerId: string }, + userId: string | undefined +) { + const isOwner = userId === workspace.ownerId; + + // Get workspace membership + const workspaceMember = userId + ? await db.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId } }, + }) + : null; + const isMember = !!workspaceMember; + const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN; + + const hasAccess = isOwner || isMember; + const canEdit = isOwner || isAdmin; + const canDelete = isOwner; + + return { + isOwner, + isMember, + isAdmin, + hasAccess, + canEdit, + canDelete, + }; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 0abff8a..cecadda 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -160,6 +160,14 @@ export async function cleanupRateLimits(): Promise { } } +// Start cleanup interval when the module is loaded (for self-hosted servers) +// Cleanup runs every 5 minutes to remove expired rate limit entries +if (typeof setInterval !== 'undefined') { + setInterval(() => { + cleanupRateLimits().catch(console.error); + }, 5 * 60 * 1000); +} + /** * One-call rate limit check that returns a 429 NextResponse if blocked, or null if allowed. * Use at the top of any API handler: diff --git a/lib/video-providers/vimeo.ts b/lib/video-providers/vimeo.ts index afd0724..f517ab7 100644 --- a/lib/video-providers/vimeo.ts +++ b/lib/video-providers/vimeo.ts @@ -65,9 +65,14 @@ export const vimeoProvider: VideoProvider = { const cached = getCachedMetadata(cacheKey); if (cached) return cached; try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + const response = await fetch( - `https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}` + `https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`, + { signal: controller.signal } ); + clearTimeout(timeoutId); if (!response.ok) { throw new Error('Failed to fetch video metadata'); diff --git a/lib/video-providers/youtube.ts b/lib/video-providers/youtube.ts index 8ab2bf5..5054f17 100644 --- a/lib/video-providers/youtube.ts +++ b/lib/video-providers/youtube.ts @@ -64,9 +64,14 @@ export const youtubeProvider: VideoProvider = { // Using oEmbed API - no API key required // For production, you might want to use YouTube Data API for more data try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + const response = await fetch( - `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json` + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`, + { signal: controller.signal } ); + clearTimeout(timeoutId); if (!response.ok) { throw new Error('Failed to fetch video metadata');