refactor: consolidate check-email into Elysia rate limiter (Task 6)
This commit is contained in:
@@ -1,66 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { db } from "@/lib/db"
|
|
||||||
import { user } from "@/lib/db/schema/auth"
|
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
|
||||||
|
|
||||||
// Simple in-memory rate limiter for this endpoint
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
|
||||||
|
|
||||||
function checkRateLimit(ip: string): boolean {
|
|
||||||
const now = Date.now()
|
|
||||||
const window = 60_000 // 1 minute
|
|
||||||
const max = 10 // 10 requests per minute per IP
|
|
||||||
|
|
||||||
const entry = rateLimitStore.get(ip)
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
rateLimitStore.set(ip, { count: 1, resetAt: now + window })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (entry.count >= max) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
entry.count++
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
// Rate limit by IP
|
|
||||||
const forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
const ip = forwarded?.split(",")[0]?.trim() || "unknown"
|
|
||||||
|
|
||||||
if (!checkRateLimit(ip)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Too many requests. Please try again later." },
|
|
||||||
{ status: 429 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: unknown
|
|
||||||
try {
|
|
||||||
body = await request.json()
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid request body" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = loginEmailSchema.safeParse(body)
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: result.error.issues[0].message },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { email } = result.data
|
|
||||||
|
|
||||||
const existingUser = await db
|
|
||||||
.select({ id: user.id })
|
|
||||||
.from(user)
|
|
||||||
.where(eq(user.email, email.toLowerCase()))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return NextResponse.json({ exists: existingUser.length > 0 })
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||||
|
|
||||||
|
describe("check-email validation", () => {
|
||||||
|
it("accepts valid email", () => {
|
||||||
|
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects missing email", () => {
|
||||||
|
const result = loginEmailSchema.safeParse({})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects invalid email format", () => {
|
||||||
|
const result = loginEmailSchema.safeParse({ email: "not-email" })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects empty string email", () => {
|
||||||
|
const result = loginEmailSchema.safeParse({ email: "" })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("check-email response contract", () => {
|
||||||
|
it("returns { exists: boolean } on success", () => {
|
||||||
|
const successShape = { exists: true }
|
||||||
|
const failureShape = { exists: false }
|
||||||
|
expect(successShape).toHaveProperty("exists")
|
||||||
|
expect(failureShape).toHaveProperty("exists")
|
||||||
|
expect(typeof successShape.exists).toBe("boolean")
|
||||||
|
expect(typeof failureShape.exists).toBe("boolean")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns { error: string } on validation failure", () => {
|
||||||
|
const errorShape = { error: "Invalid request body" }
|
||||||
|
expect(errorShape).toHaveProperty("error")
|
||||||
|
expect(typeof errorShape.error).toBe("string")
|
||||||
|
})
|
||||||
|
})
|
||||||
+32
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Elysia } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
import { openapi } from "@elysia/openapi"
|
import { openapi } from "@elysia/openapi"
|
||||||
import { cron, Patterns } from "@elysia/cron"
|
import { cron, Patterns } from "@elysia/cron"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
@@ -45,6 +45,10 @@ import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
|||||||
import { cronRoutes } from "@/lib/api/cron"
|
import { cronRoutes } from "@/lib/api/cron"
|
||||||
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||||
import { screenshotRoutes } from "@/lib/api/screenshots"
|
import { screenshotRoutes } from "@/lib/api/screenshots"
|
||||||
|
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import { user } from "@/lib/db/schema/auth"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
import { mobileRoutes } from "@/lib/api/mobile"
|
import { mobileRoutes } from "@/lib/api/mobile"
|
||||||
|
|
||||||
const betterAuth = new Elysia({ name: "better-auth" })
|
const betterAuth = new Elysia({ name: "better-auth" })
|
||||||
@@ -156,6 +160,33 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(betterAuth)
|
.use(betterAuth)
|
||||||
.use(userRoutes)
|
.use(userRoutes)
|
||||||
.use(profilePhotoRoutes)
|
.use(profilePhotoRoutes)
|
||||||
|
.post(
|
||||||
|
"/auth/check-email",
|
||||||
|
async ({ body, set }) => {
|
||||||
|
const result = loginEmailSchema.safeParse(body)
|
||||||
|
if (!result.success) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: result.error.issues[0].message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = result.data
|
||||||
|
const existingUser = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, email.toLowerCase()))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return { exists: existingUser.length > 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({ email: t.String() }),
|
||||||
|
response: t.Union([
|
||||||
|
t.Object({ exists: t.Boolean() }),
|
||||||
|
t.Object({ error: t.String() }),
|
||||||
|
]),
|
||||||
|
detail: { hide: true },
|
||||||
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
// ── Read-heavy public routes (read rate limit) ───────────────
|
// ── Read-heavy public routes (read rate limit) ───────────────
|
||||||
.group("", (app) =>
|
.group("", (app) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user