135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "../../../../../lib/server/db";
|
|
import {
|
|
getSessionPepper,
|
|
isDatabaseConfigured,
|
|
} from "../../../../../lib/server/env";
|
|
import {
|
|
hashSessionToken,
|
|
newSessionToken,
|
|
} from "../../../../../lib/server/hash";
|
|
import { sendEmailChangeEmail } from "../../../../../lib/server/mail";
|
|
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
|
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
|
import { logRouteError } from "../../../../../lib/server/requestId";
|
|
import {
|
|
dbUnavailable,
|
|
errorJson,
|
|
rateLimited,
|
|
serverMisconfigured,
|
|
unauthorized,
|
|
} from "../../../../../lib/server/responses";
|
|
import { getSessionUser } from "../../../../../lib/server/session";
|
|
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
|
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
|
import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas";
|
|
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
|
|
|
const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000;
|
|
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
|
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
|
const SCOPE = "user.emailChange.request";
|
|
|
|
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
|
if (!isDatabaseConfigured()) {
|
|
return dbUnavailable();
|
|
}
|
|
|
|
const user = await getSessionUser();
|
|
if (!user) {
|
|
return unauthorized();
|
|
}
|
|
|
|
const limited = await readLimitedJson(request);
|
|
if (limited.ok === false) {
|
|
return limited.response;
|
|
}
|
|
|
|
const parsed = emailChangeRequestBodySchema.safeParse(limited.value);
|
|
if (!parsed.success) {
|
|
return jsonFromZodError(parsed.error);
|
|
}
|
|
|
|
const { newEmail } = parsed.data;
|
|
if (newEmail === user.email) {
|
|
return errorJson(
|
|
"validation_error",
|
|
"New email must be different from your current email",
|
|
400,
|
|
);
|
|
}
|
|
|
|
const ip =
|
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
request.headers.get("x-real-ip") ??
|
|
"unknown";
|
|
|
|
const rlEmail = rateLimitKey(
|
|
`email-change-email:${newEmail}`,
|
|
EMAIL_MIN_INTERVAL_MS,
|
|
);
|
|
if (rlEmail.ok === false) {
|
|
return rateLimited(rlEmail.retryAfterMs);
|
|
}
|
|
|
|
const rlIp = rateLimitKey(`email-change-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
|
if (rlIp.ok === false) {
|
|
return rateLimited(rlIp.retryAfterMs);
|
|
}
|
|
|
|
const rlUser = rateLimitKey(
|
|
`email-change-user:${user.id}`,
|
|
EMAIL_MIN_INTERVAL_MS,
|
|
);
|
|
if (rlUser.ok === false) {
|
|
return rateLimited(rlUser.retryAfterMs);
|
|
}
|
|
|
|
const existing = await prisma.user.findUnique({ where: { email: newEmail } });
|
|
if (existing && existing.id !== user.id) {
|
|
return errorJson(
|
|
"validation_error",
|
|
"That email is already used by another account",
|
|
400,
|
|
{ details: { field: "newEmail" } },
|
|
);
|
|
}
|
|
|
|
let pepper: string;
|
|
try {
|
|
pepper = getSessionPepper();
|
|
} catch {
|
|
return serverMisconfigured();
|
|
}
|
|
|
|
const token = newSessionToken();
|
|
const tokenHash = hashSessionToken(token, pepper);
|
|
const expiresAt = new Date(Date.now() + EMAIL_CHANGE_TTL_MS);
|
|
|
|
await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
|
|
await prisma.emailChangeToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
newEmail,
|
|
tokenHash,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
const origin = getPublicOrigin(request);
|
|
const verifyUrl = `${origin}/api/user/email-change/verify?token=${encodeURIComponent(token)}`;
|
|
|
|
try {
|
|
await sendEmailChangeEmail(newEmail, verifyUrl);
|
|
} catch (err) {
|
|
logRouteError(SCOPE, requestId, err, {
|
|
phase: "sendEmailChangeEmail",
|
|
newEmail,
|
|
});
|
|
await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
|
|
return errorJson("mail_failed", "Could not send email", 502);
|
|
}
|
|
|
|
return NextResponse.json({ ok: true });
|
|
});
|