diff --git a/.gitignore b/.gitignore index 5ef6a52..a84cded 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.gstack/ diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 96ccd00..2511704 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -11,7 +11,7 @@ services: - NODE_ENV=production - HOSTNAME=0.0.0.0 - NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging - - JWT_SECRET=${JWT_SECRET:-flh-staging-jwt-secret} + - JWT_SECRET=${JWT_SECRET} - DATABASE_URL=file:/app/data/staging.db - LLM_API_KEY=${LLM_API_KEY:-} - LLM_BASE_URL=${LLM_BASE_URL:-} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 769cc23..d2e6a1b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,9 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { UMMAHID_URL } from "@/lib/auth"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; export async function POST(req: NextRequest) { try { + const ip = getClientIp(req); + const { allowed } = checkRateLimit(ip); + if (!allowed) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429, headers: { "Retry-After": "900", "X-RateLimit-Remaining": "0" } } + ); + } + const { email, password } = await req.json(); if (!email || !password) { @@ -40,7 +50,7 @@ export async function POST(req: NextRequest) { name: ummahUser?.name || email?.split("@")[0] || "User", provider: "ummahid", providerId: ummahUser.id, - walletBalance: 5000, + flhBalance: 5000, }, }); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 0a842fe..9fa9a6b 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,9 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { UMMAHID_URL } from "@/lib/auth"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; export async function POST(req: NextRequest) { try { + const ip = getClientIp(req); + const { allowed } = checkRateLimit(ip); + if (!allowed) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429, headers: { "Retry-After": "900", "X-RateLimit-Remaining": "0" } } + ); + } + const { email, name, password } = await req.json(); if (!email || !name || !password) { diff --git a/src/app/api/upgrade/create-checkout/route.ts b/src/app/api/upgrade/create-checkout/route.ts index 331a47f..2ec349b 100644 --- a/src/app/api/upgrade/create-checkout/route.ts +++ b/src/app/api/upgrade/create-checkout/route.ts @@ -61,7 +61,7 @@ export async function POST(req: NextRequest) { // In production, this would create a Polar.sh checkout session. // For development, we mock the flow by marking the user as premium/pro. - const useMock = process.env.MOCK_PAYMENTS !== "false"; + const useMock = process.env.MOCK_PAYMENTS === "true"; if (useMock) { const trialEndsAt = new Date(); diff --git a/src/app/api/webhooks/polar-checkout/route.ts b/src/app/api/webhooks/polar-checkout/route.ts index 0ffcaf3..1a8d2c9 100644 --- a/src/app/api/webhooks/polar-checkout/route.ts +++ b/src/app/api/webhooks/polar-checkout/route.ts @@ -1,42 +1,61 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -// Polar.sh webhook for FLH purchase checkouts -// Receives checkout.completed events and credits the buyer's FLH balance +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} export async function POST(request: NextRequest) { try { - // Log the incoming webhook for debugging - const body = await request.json(); - const eventType = body.type || ""; - console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: body.data?.id })); + const rawBody = await request.text(); + let body: Record; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const eventType = (body.type as string) || ""; + console.log("[polar-webhook] Received:", JSON.stringify({ type: eventType, id: (body.data as Record | undefined)?.id })); - // Only process checkout events if (!eventType.startsWith("checkout.")) { return NextResponse.json({ received: true, ignored: true }, { status: 200 }); } - // Verify webhook secret if configured const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; if (webhookSecret) { const signature = request.headers.get("polar-signature") || ""; - // In production, verify the signature using crypto.timingSafeEqual - // For now, use the secret as a simple check - if (signature !== webhookSecret) { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(webhookSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const expectedSignatureBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody)); + const expectedSignature = Array.from(new Uint8Array(expectedSignatureBytes)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + + if (!timingSafeEqual(signature, expectedSignature)) { console.warn("[polar-webhook] Invalid signature"); return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } } - const checkout = body.data || {}; - const metadata = checkout.metadata || {}; + const checkout = (body.data as Record) || {}; + const metadata = (checkout.metadata as Record) || {}; - // Only process FLH purchase events if (metadata.type !== "flh_purchase") { return NextResponse.json({ received: true, ignored: true }, { status: 200 }); } - // Only process completed/paid checkouts if (checkout.status !== "succeeded" && checkout.status !== "paid") { return NextResponse.json( { received: true, status: checkout.status }, @@ -44,8 +63,8 @@ export async function POST(request: NextRequest) { ); } - const userId = metadata.user_id; - const flhAmount = parseInt(metadata.flh_amount, 10); + const userId = metadata.user_id as string; + const flhAmount = parseInt(metadata.flh_amount as string, 10); if (!userId || !flhAmount || flhAmount < 1) { console.error("[polar-webhook] Invalid metadata:", { userId, flhAmount }); @@ -55,8 +74,7 @@ export async function POST(request: NextRequest) { ); } - // Check if this checkout was already processed (idempotency) - const checkoutId = checkout.id || body.id; + const checkoutId = (checkout.id as string) || (body.id as string); if (checkoutId) { const existing = await prisma.xpTransaction.findFirst({ where: { @@ -70,7 +88,6 @@ export async function POST(request: NextRequest) { } } - // Credit the user's FLH balance const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { console.error(`[polar-webhook] User not found: ${userId}`); @@ -82,7 +99,6 @@ export async function POST(request: NextRequest) { data: { flhBalance: { increment: flhAmount } }, }); - // Record the transaction for idempotency and audit if (checkoutId) { await prisma.xpTransaction.create({ data: { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..4d243bc --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,27 @@ +const rateMap = new Map(); + +const WINDOW_MS = 15 * 60 * 1000; +const MAX_ATTEMPTS = 10; + +export function checkRateLimit(ip: string): { allowed: boolean; remaining: number } { + const now = Date.now(); + const entry = rateMap.get(ip); + + if (!entry || now > entry.resetAt) { + rateMap.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return { allowed: true, remaining: MAX_ATTEMPTS - 1 }; + } + + if (entry.count >= MAX_ATTEMPTS) { + return { allowed: false, remaining: 0 }; + } + + entry.count++; + return { allowed: true, remaining: MAX_ATTEMPTS - entry.count }; +} + +export function getClientIp(request: Request): string { + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || "127.0.0.1"; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..e8c9303 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const ALLOWED_ORIGINS = [ + "https://falahos.my", + "https://www.falahos.my", + "http://localhost:3000", + "http://localhost:4013", + "http://localhost:4014", +]; + +export function middleware(request: NextRequest) { + const origin = request.headers.get("origin") || ""; + const isAllowed = !origin || ALLOWED_ORIGINS.includes(origin); + + const response = NextResponse.next(); + + if (origin && isAllowed) { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.headers.set("Access-Control-Max-Age", "86400"); + } else if (origin && !isAllowed) { + response.headers.set("Access-Control-Allow-Origin", "https://falahos.my"); + } + + if (request.method === "OPTIONS") { + return new NextResponse(null, { + status: 204, + headers: response.headers, + }); + } + + return response; +} + +export const config = { + matcher: "/api/:path*", +};