From 6e8170d080454b2601ba5b896f793275cb77c258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 7 Feb 2026 16:51:46 +0300 Subject: [PATCH] feat(api): Implement API response Cache-Control - Introduce `withCacheControl` utility function for API responses. - Apply `private, no-store` to authentication and data modification (POST, PATCH, DELETE) routes. - Apply `private, no-cache` to sensitive data retrieval (GET) routes. - Enhance security by preventing caching of private user data. - Ensure fresh data is always fetched for authenticated API responses. --- app/api/auth/[...nextauth]/route.ts | 4 +- app/api/auth/register/route.ts | 6 +-- app/api/comments/[commentId]/route.ts | 11 ++++-- .../[projectId]/members/[memberId]/route.ts | 8 ++-- app/api/projects/[projectId]/members/route.ts | 11 ++++-- app/api/projects/[projectId]/route.ts | 13 ++++--- .../[projectId]/tags/[tagId]/route.ts | 8 ++-- app/api/projects/[projectId]/tags/route.ts | 8 ++-- .../[projectId]/videos/[videoId]/route.ts | 12 ++++-- .../videos/[videoId]/versions/route.ts | 8 ++-- app/api/projects/[projectId]/videos/route.ts | 8 ++-- app/api/projects/route.ts | 11 ++++-- app/api/settings/notifications/route.ts | 15 +++++--- app/api/upload/audio/route.ts | 5 ++- .../versions/[versionId]/comments/route.ts | 8 ++-- app/api/watch/[videoId]/route.ts | 6 ++- .../[workspaceId]/members/[memberId]/route.ts | 8 ++-- .../workspaces/[workspaceId]/members/route.ts | 11 ++++-- app/api/workspaces/[workspaceId]/route.ts | 11 ++++-- app/api/workspaces/route.ts | 8 ++-- lib/api-response.ts | 5 +++ lib/video-providers/metadata-cache.ts | 38 +++++++++++++++++++ lib/video-providers/vimeo.ts | 13 ++++++- lib/video-providers/youtube.ts | 13 ++++++- 24 files changed, 179 insertions(+), 70 deletions(-) create mode 100644 lib/video-providers/metadata-cache.ts diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 3420576..bc33e20 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,6 @@ import { handlers } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; +import { withCacheControl } from '@/lib/api-response'; export const { GET } = handlers; @@ -8,5 +9,6 @@ export async function POST(request: Request) { const limited = await rateLimit(request, 'login'); if (limited) return limited; // eslint-disable-next-line @typescript-eslint/no-explicit-any - return handlers.POST(request as any); + const response = await handlers.POST(request as any); + return withCacheControl(response, 'private, no-store'); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 8cc7c71..848f327 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,8 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; -import { apiErrors, successResponse, ErrorCode } from '@/lib/api-response'; +import { apiErrors, successResponse, ErrorCode, withCacheControl } from '@/lib/api-response'; export async function POST(request: NextRequest) { try { @@ -95,7 +95,7 @@ export async function POST(request: NextRequest) { response.headers.set(key, value); }); - return response; + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Registration error:', error); return apiErrors.internalError('Failed to create account'); diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 9d77861..f3a3812 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ commentId: string }> }; @@ -56,7 +56,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // Strip internal project data from response const { version: _version, ...commentData } = comment; - return successResponse(commentData); + const response = successResponse(commentData); + return withCacheControl(response, 'private, no-cache'); } catch (error) { console.error('Error fetching comment:', error); return apiErrors.internalError('Failed to fetch comment'); @@ -137,7 +138,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(updatedComment); + const response = successResponse(updatedComment); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating comment:', error); return apiErrors.internalError('Failed to update comment'); @@ -209,7 +211,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } } - return successResponse({ message: 'Comment deleted' }); + const response = successResponse({ message: 'Comment deleted' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error deleting comment:', error); return apiErrors.internalError('Failed to delete comment'); diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts index 12d24b9..8510338 100644 --- a/app/api/projects/[projectId]/members/[memberId]/route.ts +++ b/app/api/projects/[projectId]/members/[memberId]/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; memberId: string }> }; @@ -52,7 +52,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(member); + const response = successResponse(member); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating member role:', error); return apiErrors.internalError('Failed to update member role'); @@ -100,7 +101,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.projectMember.delete({ where: { id: memberId } }); - return successResponse({ message: 'Member removed' }); + const response = successResponse({ message: 'Member removed' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error removing member:', error); return apiErrors.internalError('Failed to remove member'); diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts index 31c8e00..25440ef 100644 --- a/app/api/projects/[projectId]/members/route.ts +++ b/app/api/projects/[projectId]/members/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -48,7 +48,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { select: { id: true, name: true, image: true }, }); - return successResponse({ members, owner }); + const response = successResponse({ members, owner }); + return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); } catch (error) { console.error('Error fetching project members:', error); return apiErrors.internalError('Failed to fetch members'); @@ -102,7 +103,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!userToInvite) { - return successResponse({ message: 'If the user exists, an invitation has been sent.' }); + const response = successResponse({ message: 'If the user exists, an invitation has been sent.' }); + return withCacheControl(response, 'private, no-store'); } if (userToInvite.id === project.ownerId) { @@ -129,7 +131,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(member, 201); + const response = successResponse(member, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error inviting project member:', error); return apiErrors.internalError('Failed to invite member'); diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 211bd9f..371dc8b 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -1,10 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; 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 { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -107,7 +107,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Access denied'); } - return successResponse(project); + const response = successResponse(project); + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching project:', error); return apiErrors.internalError('Failed to fetch project'); @@ -149,7 +150,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(project); + const response = successResponse(project); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating project:', error); return apiErrors.internalError('Failed to update project'); @@ -184,7 +186,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.project.delete({ where: { id: projectId } }); - return successResponse({ message: 'Project deleted' }); + const response = successResponse({ message: 'Project deleted' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error deleting project:', error); return apiErrors.internalError('Failed to delete project'); diff --git a/app/api/projects/[projectId]/tags/[tagId]/route.ts b/app/api/projects/[projectId]/tags/[tagId]/route.ts index 8f70a4a..710427c 100644 --- a/app/api/projects/[projectId]/tags/[tagId]/route.ts +++ b/app/api/projects/[projectId]/tags/[tagId]/route.ts @@ -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 { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; tagId: string }> }; @@ -91,7 +91,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { data: updateData, }); - return successResponse(tag); + const response = successResponse(tag); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating tag:', error); if ((error as { code?: string }).code === 'P2002') { @@ -132,7 +133,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.commentTag.delete({ where: { id: tagId } }); - return successResponse({ message: 'Tag deleted' }); + const response = successResponse({ message: 'Tag deleted' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error deleting tag:', error); return apiErrors.internalError('Failed to delete tag'); diff --git a/app/api/projects/[projectId]/tags/route.ts b/app/api/projects/[projectId]/tags/route.ts index 35decb5..ca4eec1 100644 --- a/app/api/projects/[projectId]/tags/route.ts +++ b/app/api/projects/[projectId]/tags/route.ts @@ -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 { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -79,7 +79,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); } - return successResponse(tags); + const response = successResponse(tags); + return withCacheControl(response, 'private, max-age=120, stale-while-revalidate=300'); } catch (error) { console.error('Error fetching tags:', error); return apiErrors.internalError('Failed to fetch tags'); @@ -134,7 +135,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(tag, 201); + const response = successResponse(tag, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating tag:', error); if ((error as { code?: string }).code === 'P2002') { diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 2d9662d..7610efd 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; @@ -57,10 +57,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Access denied'); } - return successResponse({ + const response = successResponse({ ...video, isAuthenticated: !!session?.user?.id, }); + + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching video:', error); return apiErrors.internalError('Failed to fetch video'); @@ -117,7 +119,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(updatedVideo); + const response = successResponse(updatedVideo); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating video:', error); return apiErrors.internalError('Failed to update video'); @@ -162,7 +165,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.video.delete({ where: { id: videoId } }); - return successResponse({ message: 'Video deleted' }); + const response = successResponse({ message: 'Video deleted' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error deleting video:', error); return apiErrors.internalError('Failed to delete video'); diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index 8ac6f87..b8252c3 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; @@ -43,7 +43,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse({ versions }); + const response = successResponse({ versions }); + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching versions:', error); return apiErrors.internalError('Failed to fetch versions'); @@ -133,7 +134,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); }); - return successResponse(version, 201); + const response = successResponse(version, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating version:', error); return apiErrors.internalError('Failed to create version'); diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index d3a7555..70f7637 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -5,7 +5,7 @@ import { ProjectMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -47,7 +47,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse({ videos }); + const response = successResponse({ videos }); + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching videos:', error); return apiErrors.internalError('Failed to fetch videos'); @@ -149,7 +150,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }).catch((err) => console.error('Notification failed:', err)); } - return successResponse(video, 201); + const response = successResponse(video, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating video:', error); return apiErrors.internalError('Failed to create video'); diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index a32c4f6..605d801 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,9 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectVisibility } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; // GET /api/projects - List all projects for the authenticated user export async function GET(request: NextRequest) { @@ -56,7 +56,7 @@ export async function GET(request: NextRequest) { }), ]); - return successResponse( + const response = successResponse( { projects }, 200, { @@ -66,6 +66,8 @@ export async function GET(request: NextRequest) { totalPages: Math.ceil(total / limit), } ); + + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching projects:', error); return apiErrors.internalError('Failed to fetch projects'); @@ -145,7 +147,8 @@ export async function POST(request: NextRequest) { }, }); - return successResponse(project, 201); + const response = successResponse(project, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating project:', error); return apiErrors.internalError('Failed to create project'); diff --git a/app/api/settings/notifications/route.ts b/app/api/settings/notifications/route.ts index c9d5d30..6982d80 100644 --- a/app/api/settings/notifications/route.ts +++ b/app/api/settings/notifications/route.ts @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import nodemailer from 'nodemailer'; import { testEmailHtml } from '@/lib/notifications'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; // GET /api/settings/notifications — Fetch current notification preferences export async function GET() { @@ -19,7 +19,7 @@ export async function GET() { }); // Return defaults if no settings exist yet - return successResponse( + const response = successResponse( settings ?? { telegramBotToken: null, telegramChatId: null, @@ -31,6 +31,8 @@ export async function GET() { timezone: 'UTC', } ); + + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching notification settings:', error); return apiErrors.internalError('Failed to fetch settings'); @@ -90,7 +92,8 @@ export async function PUT(request: NextRequest) { }, }); - return successResponse(settings); + const response = successResponse(settings); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating notification settings:', error); return apiErrors.internalError('Failed to update settings'); @@ -140,7 +143,8 @@ export async function POST(request: NextRequest) { return apiErrors.badRequest(`Telegram test failed: ${desc}`); } - return successResponse({ message: 'Test message sent to Telegram' }); + const response = successResponse({ message: 'Test message sent to Telegram' }); + return withCacheControl(response, 'private, no-store'); } if (channel === 'email') { @@ -183,7 +187,8 @@ export async function POST(request: NextRequest) { return apiErrors.internalError('Failed to send test email — check SMTP settings'); } - return successResponse({ message: `Test email sent to ${user.email}` }); + const response = successResponse({ message: `Test email sent to ${user.email}` }); + return withCacheControl(response, 'private, no-store'); } return apiErrors.badRequest('Unknown channel'); diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index 969a6b6..fe86f39 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -3,7 +3,7 @@ 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 } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav']; @@ -59,7 +59,8 @@ export async function POST(request: Request) { // Return the URL through our proxy endpoint const voiceUrl = `/api/upload/audio/${filename}`; - return successResponse({ url: voiceUrl }, 201); + const response = successResponse({ url: voiceUrl }, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error uploading audio:', error); return apiErrors.internalError('Failed to upload audio'); diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 15ef01d..2651f3e 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -4,7 +4,7 @@ import { auth } from '@/lib/auth'; import { validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ versionId: string }> }; @@ -66,7 +66,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse({ comments }); + const response = successResponse({ comments }); + return withCacheControl(response, 'private, no-cache'); } catch (error) { console.error('Error fetching comments:', error); return apiErrors.internalError('Failed to fetch comments'); @@ -215,7 +216,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } - return successResponse(comment, 201); + const response = successResponse(comment, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating comment:', error); return apiErrors.internalError('Failed to create comment'); diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 2e5ca65..3f224ae 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ videoId: string }> }; @@ -58,7 +58,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // Include auth context so the client knows if the viewer is a guest const { project, ...videoData } = video; - return successResponse({ + const response = successResponse({ ...videoData, projectId: video.projectId, project: { @@ -69,6 +69,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { isAuthenticated: !!session?.user?.id, canComment: isOwner || isMember || isPublic, }); + + return withCacheControl(response, 'public, s-maxage=60, stale-while-revalidate=300'); } catch (error) { console.error('Error fetching video:', error); return apiErrors.internalError('Failed to fetch video'); diff --git a/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts b/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts index 90615bd..9ac0423 100644 --- a/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts +++ b/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> }; @@ -53,7 +53,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(member); + const response = successResponse(member); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error updating member role:', error); return apiErrors.internalError('Failed to update member role'); @@ -102,7 +103,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.workspaceMember.delete({ where: { id: memberId } }); - return successResponse({ message: 'Member removed' }); + const response = successResponse({ message: 'Member removed' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error removing member:', error); return apiErrors.internalError('Failed to remove member'); diff --git a/app/api/workspaces/[workspaceId]/members/route.ts b/app/api/workspaces/[workspaceId]/members/route.ts index 02a327f..db917fb 100644 --- a/app/api/workspaces/[workspaceId]/members/route.ts +++ b/app/api/workspaces/[workspaceId]/members/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -49,7 +49,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { select: { id: true, name: true, image: true }, }); - return successResponse({ members, owner }); + const response = successResponse({ members, owner }); + return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); } catch (error) { console.error('Error fetching workspace members:', error); return apiErrors.internalError('Failed to fetch members'); @@ -103,7 +104,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!userToInvite) { - return successResponse({ message: 'If the user exists, an invitation has been sent.' }); + const response = successResponse({ message: 'If the user exists, an invitation has been sent.' }); + return withCacheControl(response, 'private, no-store'); } if (userToInvite.id === workspace.ownerId) { @@ -130,7 +132,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(member, 201); + const response = successResponse(member, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error inviting workspace member:', error); return apiErrors.internalError('Failed to invite member'); diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index c0a465a..2014042 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -3,7 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup'; -import { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -71,7 +71,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Access denied'); } - return successResponse(workspace); + const response = successResponse(workspace); + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error fetching workspace:', error); return apiErrors.internalError('Failed to fetch workspace'); @@ -112,7 +113,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return successResponse(workspace); + const response = successResponse(workspace); + return withCacheControl(response, 'private, max-age=30, stale-while-revalidate=60'); } catch (error) { console.error('Error updating workspace:', error); return apiErrors.internalError('Failed to update workspace'); @@ -147,7 +149,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.workspace.delete({ where: { id: workspaceId } }); - return successResponse({ message: 'Workspace deleted' }); + const response = successResponse({ message: 'Workspace deleted' }); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error deleting workspace:', error); return apiErrors.internalError('Failed to delete workspace'); diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 4ce2e7d..9705c6e 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -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 { apiErrors, successResponse } from '@/lib/api-response'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; // GET /api/workspaces - List all workspaces for the authenticated user export async function GET() { @@ -28,7 +28,8 @@ export async function GET() { orderBy: { updatedAt: 'desc' }, }); - return successResponse({ workspaces }); + const response = successResponse({ workspaces }); + return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); } catch (error) { console.error('Error fetching workspaces:', error); return apiErrors.internalError('Failed to fetch workspaces'); @@ -84,7 +85,8 @@ export async function POST(request: NextRequest) { }, }); - return successResponse(workspace, 201); + const response = successResponse(workspace, 201); + return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error creating workspace:', error); return apiErrors.internalError('Failed to create workspace'); diff --git a/lib/api-response.ts b/lib/api-response.ts index 7dec32a..25539b4 100644 --- a/lib/api-response.ts +++ b/lib/api-response.ts @@ -126,6 +126,11 @@ export function successResponse( return NextResponse.json(body, { status }); } +export function withCacheControl(response: NextResponse, value: string): NextResponse { + response.headers.set('Cache-Control', value); + return response; +} + /** * Common error response helpers */ diff --git a/lib/video-providers/metadata-cache.ts b/lib/video-providers/metadata-cache.ts new file mode 100644 index 0000000..de26c77 --- /dev/null +++ b/lib/video-providers/metadata-cache.ts @@ -0,0 +1,38 @@ +import type { VideoMetadata } from './types'; + +type CacheEntry = { + value: VideoMetadata; + expiresAt: number; +}; + +const MAX_ENTRIES = 500; +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +const cache = new Map(); + +function pruneIfNeeded(): void { + if (cache.size <= MAX_ENTRIES) return; + const overflow = cache.size - MAX_ENTRIES; + for (let i = 0; i < overflow; i += 1) { + const oldestKey = cache.keys().next().value as string | undefined; + if (!oldestKey) return; + cache.delete(oldestKey); + } +} + +export function getCachedMetadata(key: string): VideoMetadata | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + cache.delete(key); + cache.set(key, entry); + return entry.value; +} + +export function setCachedMetadata(key: string, value: VideoMetadata, ttlMs: number = DEFAULT_TTL_MS): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); + pruneIfNeeded(); +} diff --git a/lib/video-providers/vimeo.ts b/lib/video-providers/vimeo.ts index e166060..afd0724 100644 --- a/lib/video-providers/vimeo.ts +++ b/lib/video-providers/vimeo.ts @@ -1,4 +1,5 @@ import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types'; +import { getCachedMetadata, setCachedMetadata } from './metadata-cache'; // Vimeo URL patterns const VIMEO_PATTERNS = [ @@ -60,6 +61,9 @@ export const vimeoProvider: VideoProvider = { }, async getMetadata(videoId: string): Promise { + const cacheKey = `vimeo:${videoId}`; + const cached = getCachedMetadata(cacheKey); + if (cached) return cached; try { const response = await fetch( `https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}` @@ -71,7 +75,7 @@ export const vimeoProvider: VideoProvider = { const data = await response.json(); - return { + const metadata: VideoMetadata = { title: data.title, description: data.description, thumbnailUrl: data.thumbnail_url, @@ -80,11 +84,16 @@ export const vimeoProvider: VideoProvider = { authorUrl: data.author_url, uploadDate: data.upload_date ? new Date(data.upload_date) : undefined, }; + + setCachedMetadata(cacheKey, metadata); + return metadata; } catch (error) { - return { + const fallback: VideoMetadata = { title: 'Vimeo Video', thumbnailUrl: this.getThumbnailUrl(videoId, 'large'), }; + setCachedMetadata(cacheKey, fallback); + return fallback; } }, }; diff --git a/lib/video-providers/youtube.ts b/lib/video-providers/youtube.ts index 3a8fb68..8ab2bf5 100644 --- a/lib/video-providers/youtube.ts +++ b/lib/video-providers/youtube.ts @@ -1,4 +1,5 @@ import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types'; +import { getCachedMetadata, setCachedMetadata } from './metadata-cache'; // YouTube URL patterns const YOUTUBE_PATTERNS = [ @@ -57,6 +58,9 @@ export const youtubeProvider: VideoProvider = { }, async getMetadata(videoId: string): Promise { + const cacheKey = `youtube:${videoId}`; + const cached = getCachedMetadata(cacheKey); + if (cached) return cached; // Using oEmbed API - no API key required // For production, you might want to use YouTube Data API for more data try { @@ -70,18 +74,23 @@ export const youtubeProvider: VideoProvider = { const data = await response.json(); - return { + const metadata: VideoMetadata = { title: data.title, thumbnailUrl: data.thumbnail_url, author: data.author_name, authorUrl: data.author_url, }; + + setCachedMetadata(cacheKey, metadata); + return metadata; } catch (error) { // Fallback with minimal data - return { + const fallback: VideoMetadata = { title: 'YouTube Video', thumbnailUrl: this.getThumbnailUrl(videoId, 'large'), }; + setCachedMetadata(cacheKey, fallback); + return fallback; } }, };