feat: implement R2 audio file management and rate limiting enhancements

- Add R2 client setup and audio upload functionality in lib/r2.ts.
- Create audio file cleanup functions in lib/r2-cleanup.ts to delete voice files associated with videos, projects, and workspaces.
- Enhance rate limiting in lib/rate-limit.ts with new action-specific limits and improved IP validation.
- Introduce a unified rate limit check function that returns a 429 response when limits are exceeded.
- Update package.json to include the AWS SDK for S3.
This commit is contained in:
Yusuf İpek
2026-02-07 12:27:40 +03:00
parent f240689e27
commit 296c5257a7
23 changed files with 1913 additions and 181 deletions
+104
View File
@@ -0,0 +1,104 @@
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
/** The path prefix for audio URLs served by the upload API. */
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
/**
* Extract the R2 object key from a voice URL like /api/upload/audio/filename.webm.
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
*/
function voiceUrlToKey(url: string): string | null {
const idx = url.indexOf(AUDIO_PATH_PREFIX);
if (idx === -1) return null;
const filename = url.slice(idx + AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null;
}
/**
* Delete a list of voice files from R2 (best-effort, logs failures).
*/
async function deleteVoiceFiles(voiceUrls: string[]) {
for (const url of voiceUrls) {
try {
const key = voiceUrlToKey(url);
if (key) {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
);
}
} catch (err) {
console.error('Failed to delete audio from R2:', err);
}
}
}
/**
* Collect all voice URLs from comments under a given video (all versions).
*/
export async function collectVideoVoiceUrls(videoId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { videoParentId: videoId },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Collect all voice URLs from comments under all videos in a project.
*/
export async function collectProjectVoiceUrls(projectId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { video: { projectId } },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Collect all voice URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceVoiceUrls(workspaceId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
voiceUrl: { not: null },
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true },
});
return comments.map((c) => c.voiceUrl).filter(Boolean) as string[];
}
/**
* Delete all voice files for a video from R2.
* Call BEFORE deleting the video from the database (cascade would remove comment rows).
*/
export async function cleanupVideoVoiceFiles(videoId: string) {
const urls = await collectVideoVoiceUrls(videoId);
await deleteVoiceFiles(urls);
}
/**
* Delete all voice files for a project from R2.
* Call BEFORE deleting the project from the database.
*/
export async function cleanupProjectVoiceFiles(projectId: string) {
const urls = await collectProjectVoiceUrls(projectId);
await deleteVoiceFiles(urls);
}
/**
* Delete all voice files for a workspace from R2.
* Call BEFORE deleting the workspace from the database.
*/
export async function cleanupWorkspaceVoiceFiles(workspaceId: string) {
const urls = await collectWorkspaceVoiceUrls(workspaceId);
await deleteVoiceFiles(urls);
}
+42
View File
@@ -0,0 +1,42 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY!;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME!;
const R2_ENDPOINT = process.env.R2_ENDPOINT;
export const r2Client = new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
export async function uploadAudio(
buffer: Buffer,
filename: string,
contentType: string = 'audio/webm'
): Promise<string> {
// Sanitize: strip any path components, use only the basename
const sanitized = filename.replace(/^.*[\\/]/, '').replace(/\.\.+/g, '');
if (!sanitized) throw new Error('Invalid filename');
const key = `voice/${sanitized}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
// R2 public URL — uses the R2.dev subdomain or custom domain
// For development, we use the R2.dev auto-generated URL
return `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`;
}
export { R2_BUCKET_NAME };
+83 -8
View File
@@ -1,4 +1,5 @@
import { db } from '@/lib/db';
import { NextResponse } from 'next/server';
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
@@ -11,11 +12,29 @@ interface RateLimitResult {
resetAt: Date;
}
// Default configs for different actions
// Industry-standard rate limit defaults per action
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
// Auth — strict to prevent brute force / credential stuffing
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
// Content creation — moderate limits
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Mutations (update/delete) — moderate
'mutate': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
/**
@@ -30,6 +49,13 @@ export async function checkRateLimit(
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const windowSeconds = Math.floor(windowMs / 1000);
// Validate inputs before passing to query — defence in depth.
// Prisma's tagged template $queryRaw already parameterizes these values,
// but we enforce sane bounds to reject obviously malicious input.
if (key.length > 256 || action.length > 64) {
return { allowed: true, remaining: maxRequests, resetAt: new Date(Date.now() + windowMs) };
}
try {
// Atomic upsert with window check
// If window expired, reset count; otherwise increment
@@ -72,18 +98,39 @@ export async function checkRateLimit(
}
}
// Basic IP format validation — IPv4 or IPv6 (loose check, rejects obvious garbage)
const IP_PATTERN = /^[\da-fA-F.:]+$/;
function isPlausibleIp(value: string): boolean {
return value.length <= 45 && IP_PATTERN.test(value);
}
/**
* Get client IP from request headers
* Handles common proxy headers
* Get client IP from request headers.
*
* Header priority:
* 1. cf-connecting-ip — set by Cloudflare (trusted proxy); cannot be spoofed by clients
* 2. x-forwarded-for — first entry, trusted only behind a proxy that overwrites it
* 3. x-real-ip — set by some reverse proxies (Nginx)
* 4. 127.0.0.1 — local development fallback
*
* Deployed behind Cloudflare, so cf-connecting-ip is the canonical source.
*/
export function getClientIp(request: Request): string {
// Cloudflare always sets this to the true client IP
const cfIp = request.headers.get('cf-connecting-ip');
if (cfIp && isPlausibleIp(cfIp)) {
return cfIp;
}
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
const first = forwardedFor.split(',')[0].trim();
if (isPlausibleIp(first)) return first;
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
if (realIp && isPlausibleIp(realIp)) {
return realIp;
}
@@ -112,3 +159,31 @@ export async function cleanupRateLimits(): Promise<void> {
console.error('Rate limit cleanup failed:', error);
}
}
/**
* One-call rate limit check that returns a 429 NextResponse if blocked, or null if allowed.
* Use at the top of any API handler:
* const limited = await rateLimit(request, 'comment');
* if (limited) return limited;
*/
export async function rateLimit(
request: Request,
action: string,
config?: RateLimitConfig
): Promise<NextResponse | null> {
const ip = getClientIp(request);
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const result = await checkRateLimit(ip, action, cfg);
if (!result.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(result, cfg.maxRequests),
}
);
}
return null;
}