Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 8 additions & 22 deletions apps/web/migrations/meta/0003_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,8 @@
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand Down Expand Up @@ -145,9 +141,7 @@
"export_waitlist_email_unique": {
"name": "export_waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
Expand Down Expand Up @@ -213,12 +207,8 @@
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand All @@ -228,9 +218,7 @@
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
Expand Down Expand Up @@ -292,9 +280,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
Expand Down Expand Up @@ -362,4 +348,4 @@
"schemas": {},
"tables": {}
}
}
}
2 changes: 1 addition & 1 deletion apps/web/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
"breakpoints": true
}
]
}
}
256 changes: 128 additions & 128 deletions apps/web/src/app/api/get-upload-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,128 +1,128 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { AwsClient } from "aws4fetch";
import { nanoid } from "nanoid";
import { env } from "@/env";
import { baseRateLimit } from "@/lib/rate-limit";
import { isTranscriptionConfigured } from "@/lib/transcription-utils";

const uploadRequestSchema = z.object({
fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
errorMap: () => ({
message: "File extension must be wav, mp3, m4a, or flac",
}),
}),
});

const apiResponseSchema = z.object({
uploadUrl: z.string().url(),
fileName: z.string().min(1),
});

export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);

if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}

// Check transcription configuration
const transcriptionCheck = isTranscriptionConfigured();
if (!transcriptionCheck.configured) {
console.error(
"Missing environment variables:",
JSON.stringify(transcriptionCheck.missingVars)
);

return NextResponse.json(
{
error: "Transcription not configured",
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
},
{ status: 503 }
);
}

// Parse and validate request body
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}

const validationResult = uploadRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}

const { fileExtension } = validationResult.data;

// Initialize R2 client
const client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});

// Generate unique filename with timestamp
const timestamp = Date.now();
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;

// Create presigned URL
const url = new URL(
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
);

url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry

const signed = await client.sign(new Request(url, { method: "PUT" }), {
aws: { signQuery: true },
});

if (!signed.url) {
throw new Error("Failed to generate presigned URL");
}

// Prepare and validate response
const responseData = {
uploadUrl: signed.url,
fileName,
};

const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}

return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error generating upload URL:", error);
return NextResponse.json(
{
error: "Failed to generate upload URL",
message:
error instanceof Error
? error.message
: "An unexpected error occurred",
},
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { AwsClient } from "aws4fetch";
import { nanoid } from "nanoid";
import { env } from "@/env";
import { baseRateLimit } from "@/lib/rate-limit";
import { isTranscriptionConfigured } from "@/lib/transcription-utils";

const uploadRequestSchema = z.object({
fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
errorMap: () => ({
message: "File extension must be wav, mp3, m4a, or flac",
}),
}),
});

const apiResponseSchema = z.object({
uploadUrl: z.string().url(),
fileName: z.string().min(1),
});

export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);

if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
Comment on lines +25 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Parse X-Forwarded-For correctly; don’t rate-limit on the entire header

X-Forwarded-For can be a comma-separated list. Rate‑limit by the first IP to avoid treating an entire chain as one identifier.

-    const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
-    const { success } = await baseRateLimit.limit(ip);
+    const xff = request.headers.get("x-forwarded-for") ?? "";
+    const ip = xff.split(",")[0]?.trim() || "anonymous";
+    const { success } = await baseRateLimit.limit(ip);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const xff = request.headers.get("x-forwarded-for") ?? "";
const ip = xff.split(",")[0]?.trim() || "anonymous";
const { success } = await baseRateLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
🤖 Prompt for AI Agents
In apps/web/src/app/api/get-upload-url/route.ts around lines 25 to 30, the code
currently uses the full X-Forwarded-For header as the rate-limit key; change it
to parse the header as a comma-separated list and use only the first entry
(trimmed) as the client IP, falling back to "anonymous" if missing, then pass
that single IP into baseRateLimit.limit; ensure you trim whitespace from the
first value so entries like "client, proxy1" yield "client".


// Check transcription configuration
const transcriptionCheck = isTranscriptionConfigured();
if (!transcriptionCheck.configured) {
console.error(
"Missing environment variables:",
JSON.stringify(transcriptionCheck.missingVars)
);

return NextResponse.json(
{
error: "Transcription not configured",
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
},
{ status: 503 }
);
}

// Parse and validate request body
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}

const validationResult = uploadRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}

const { fileExtension } = validationResult.data;

// Initialize R2 client
const client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});

// Generate unique filename with timestamp
const timestamp = Date.now();
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;

// Create presigned URL
const url = new URL(
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
);

url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry

const signed = await client.sign(new Request(url, { method: "PUT" }), {
aws: { signQuery: true },
});

if (!signed.url) {
throw new Error("Failed to generate presigned URL");
}

// Prepare and validate response
const responseData = {
uploadUrl: signed.url,
fileName,
};

const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}

return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error generating upload URL:", error);
return NextResponse.json(
{
error: "Failed to generate upload URL",
message:
error instanceof Error
? error.message
: "An unexpected error occurred",
},
{ status: 500 }
);
}
}
Loading