- move config requiredVariables to contants
All checks were successful
Code quality checks / biome (push) Successful in 13s
All checks were successful
Code quality checks / biome (push) Successful in 13s
- change register email - add verify route ( mostly still needs to be tested
This commit is contained in:
parent
83b71f62cf
commit
fff3c3ca50
16 changed files with 482 additions and 89 deletions
|
@ -1,6 +1,7 @@
|
||||||
import { echo } from "@atums/echo";
|
import { echo } from "@atums/echo";
|
||||||
import { validateJWTConfig, validateMailerConfig } from "#lib/validation";
|
import { validateJWTConfig, validateMailerConfig } from "#lib/validation";
|
||||||
import { isValidUrl } from "#lib/validation/url";
|
import { isValidUrl } from "#lib/validation/url";
|
||||||
|
import { requiredVariables } from "./constants";
|
||||||
import { cassandraConfig, validateCassandraConfig } from "./database/cassandra";
|
import { cassandraConfig, validateCassandraConfig } from "./database/cassandra";
|
||||||
import { jwt } from "./jwt";
|
import { jwt } from "./jwt";
|
||||||
import { mailerConfig } from "./mailer";
|
import { mailerConfig } from "./mailer";
|
||||||
|
@ -12,30 +13,11 @@ const environment: Environment = {
|
||||||
host: process.env.HOST || "0.0.0.0",
|
host: process.env.HOST || "0.0.0.0",
|
||||||
development:
|
development:
|
||||||
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
||||||
fqdn: process.env.FRONTEND_FQDN || "",
|
frontendFqdn: process.env.FRONTEND_FQDN || "",
|
||||||
|
backendFqdn: process.env.BACKEND_FQDN || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function verifyRequiredVariables(): void {
|
function verifyRequiredVariables(): void {
|
||||||
const requiredVariables = [
|
|
||||||
"HOST",
|
|
||||||
"PORT",
|
|
||||||
|
|
||||||
"REDIS_URL",
|
|
||||||
"REDIS_TTL",
|
|
||||||
|
|
||||||
"CASSANDRA_HOST",
|
|
||||||
"CASSANDRA_PORT",
|
|
||||||
"CASSANDRA_CONTACT_POINTS",
|
|
||||||
"CASSANDRA_AUTH_ENABLED",
|
|
||||||
"CASSANDRA_DATACENTER",
|
|
||||||
|
|
||||||
"JWT_SECRET",
|
|
||||||
"JWT_EXPIRATION",
|
|
||||||
"JWT_ISSUER",
|
|
||||||
|
|
||||||
"FRONTEND_FQDN",
|
|
||||||
];
|
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (const key of requiredVariables) {
|
for (const key of requiredVariables) {
|
||||||
|
@ -56,9 +38,11 @@ function verifyRequiredVariables(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateJWT = validateJWTConfig(jwt);
|
const validateJWT = validateJWTConfig(jwt);
|
||||||
if (!validateJWT.valid) {
|
if (!validateJWT.isValid) {
|
||||||
echo.error("JWT configuration validation failed:");
|
echo.error("JWT configuration validation failed:");
|
||||||
echo.error(`- ${validateJWT.error}`);
|
for (const error of validateJWT.errors) {
|
||||||
|
echo.error(`- ${error}`);
|
||||||
|
}
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,19 +55,24 @@ function verifyRequiredVariables(): void {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlValidation = isValidUrl(environment.fqdn, {
|
const validateUrl = (url: string, name: string) => {
|
||||||
requireProtocol: true,
|
const validation = isValidUrl(url, {
|
||||||
failOnTrailingSlash: true,
|
requireProtocol: true,
|
||||||
allowedProtocols: ["http", "https"],
|
failOnTrailingSlash: true,
|
||||||
allowLocalhost: environment.development,
|
allowedProtocols: ["http", "https"],
|
||||||
allowIP: environment.development,
|
allowLocalhost: environment.development,
|
||||||
});
|
allowIP: environment.development,
|
||||||
|
});
|
||||||
|
|
||||||
if (!urlValidation.valid) {
|
if (!validation.valid) {
|
||||||
echo.error("FRONTEND_FQDN validation failed:");
|
echo.error(`${name} validation failed:`);
|
||||||
echo.error(`- ${urlValidation.error}`);
|
echo.error(`- ${validation.error}`);
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateUrl(environment.frontendFqdn, "FRONTEND_FQDN");
|
||||||
|
validateUrl(environment.backendFqdn, "BACKEND_FQDN");
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
@ -1,3 +1,32 @@
|
||||||
|
export const requiredVariables = [
|
||||||
|
"HOST",
|
||||||
|
"PORT",
|
||||||
|
|
||||||
|
"REDIS_URL",
|
||||||
|
"REDIS_TTL",
|
||||||
|
|
||||||
|
"CASSANDRA_HOST",
|
||||||
|
"CASSANDRA_PORT",
|
||||||
|
"CASSANDRA_CONTACT_POINTS",
|
||||||
|
"CASSANDRA_DATACENTER",
|
||||||
|
"CASSANDRA_KEYSPACE",
|
||||||
|
|
||||||
|
"JWT_SECRET",
|
||||||
|
"JWT_EXPIRATION",
|
||||||
|
"JWT_ISSUER",
|
||||||
|
|
||||||
|
"FRONTEND_FQDN",
|
||||||
|
|
||||||
|
"SMTP_ADDRESS",
|
||||||
|
"SMTP_PORT",
|
||||||
|
"SMTP_FROM",
|
||||||
|
"SMTP_USERNAME",
|
||||||
|
"SMTP_PASSWORD",
|
||||||
|
|
||||||
|
"EXTRA_COMPANY_NAME",
|
||||||
|
"EXTRA_SUPPORT_EMAIL",
|
||||||
|
];
|
||||||
|
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./validation";
|
export * from "./validation";
|
||||||
export * from "./database";
|
export * from "./database";
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const reqLoggerIgnores = {
|
const reqLoggerIgnores = {
|
||||||
ignoredStartsWith: ["/public"],
|
ignoredStartsWith: ["/public"],
|
||||||
ignoredPaths: [""],
|
ignoredPaths: ["/favicon.ico"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export { reqLoggerIgnores };
|
export { reqLoggerIgnores };
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { CassandraConfig } from "#types/config";
|
import type { CassandraConfig } from "#types/config";
|
||||||
|
import type { simpleConfigValidation } from "#types/lib";
|
||||||
|
|
||||||
function isValidHost(host: string): boolean {
|
function isValidHost(host: string): boolean {
|
||||||
if (!host || host.trim().length === 0) return false;
|
if (!host || host.trim().length === 0) return false;
|
||||||
|
@ -50,10 +51,9 @@ function isValidDatacenter(datacenter: string, authEnabled: boolean): boolean {
|
||||||
return datacenter.trim().length > 0;
|
return datacenter.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCassandraConfig(config: CassandraConfig): {
|
function validateCassandraConfig(
|
||||||
isValid: boolean;
|
config: CassandraConfig,
|
||||||
errors: string[];
|
): simpleConfigValidation {
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!isValidHost(config.host)) {
|
if (!isValidHost(config.host)) {
|
||||||
|
|
6
src/environment/extra.ts
Normal file
6
src/environment/extra.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const extraValues = {
|
||||||
|
companyName: process.env.EXTRA_COMPANY_NAME || "Default Company",
|
||||||
|
supportEmail: process.env.EXTRA_SUPPORT_EMAIL || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export { extraValues };
|
|
@ -5,28 +5,95 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{subject}}</title>
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #4a9eff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #2d7a2d;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-notice {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
color: #ffa500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-url {
|
||||||
|
word-break: break-all;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<h2>Welcome to {{companyName}}!</h2>
|
||||||
<p>Hi {{displayName}},</p>
|
<p>Hi {{displayName}},</p>
|
||||||
<p>Thank you for registering with {{companyName}}. Your account has been successfully created.</p>
|
|
||||||
|
|
||||||
<h2>Account Details:</h2>
|
<p>Thanks for signing up! Please verify your email address to activate your account:</p>
|
||||||
<p>
|
|
||||||
<strong>User ID:</strong> {{id}}<br>
|
|
||||||
<strong>Verification Status:</strong> {{isVerified}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>To get started, please verify your email address by clicking the link below:</p>
|
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
|
||||||
<p><a href="{{verificationUrl}}">Verify Email Address</a></p>
|
|
||||||
|
<div class="expiry-notice">
|
||||||
|
<strong>⏰ Important:</strong> {{willExpire}} for security reasons.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>If the button doesn't work:</strong></p>
|
||||||
|
<p>Copy and paste this link into your browser:</p>
|
||||||
|
<div class="fallback-url">{{verificationUrl}}</div>
|
||||||
|
|
||||||
|
<p>Once verified, you'll have full access to your {{companyName}} account!</p>
|
||||||
|
|
||||||
|
<p>Questions? Reply to this email or contact us at {{supportEmail}}</p>
|
||||||
|
|
||||||
<p>If you didn't create this account, please ignore this email.</p>
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<p>
|
<p>Best regards,<br>The {{companyName}} team</p>
|
||||||
Best regards,<br>
|
|
||||||
The {{companyName}} team
|
<div class="footer">
|
||||||
</p>
|
<p>User ID: {{id}}</p>
|
||||||
|
<p>© {{currentYear}} {{companyName}}. All rights reserved.</p>
|
||||||
|
<p><small>This is an automated message. Please do not reply directly to this email.</small></p>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -155,7 +155,7 @@ class MailerService {
|
||||||
|
|
||||||
const info = await this.transporter.sendMail(mailOptions);
|
const info = await this.transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
noFileLog.info({
|
echo.debug({
|
||||||
message: `Email sent successfully to ${options.to}`,
|
message: `Email sent successfully to ${options.to}`,
|
||||||
messageId: info.messageId,
|
messageId: info.messageId,
|
||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { JWTConfig } from "#types/config";
|
import type { JWTConfig } from "#types/config";
|
||||||
import type { validationResult } from "#types/lib";
|
import type { simpleConfigValidation } from "#types/lib";
|
||||||
|
|
||||||
function isValidSecret(secret: string): boolean {
|
function isValidSecret(secret: string): boolean {
|
||||||
if (!secret || secret.trim().length === 0) return false;
|
if (!secret || secret.trim().length === 0) return false;
|
||||||
|
@ -36,7 +36,7 @@ function isValidAlgorithm(algorithm: string): boolean {
|
||||||
return supportedAlgorithms.includes(algorithm);
|
return supportedAlgorithms.includes(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateJWTConfig(config: JWTConfig): validationResult {
|
function validateJWTConfig(config: JWTConfig): simpleConfigValidation {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!isValidSecret(config.secret)) {
|
if (!isValidSecret(config.secret)) {
|
||||||
|
@ -67,8 +67,9 @@ function validateJWTConfig(config: JWTConfig): validationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
...(errors.length > 0 && { error: errors.join("; ") }),
|
...(errors.length > 0 && { error: errors.join("; ") }),
|
||||||
|
errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,9 @@ import { isValidEmail } from "./email";
|
||||||
import { isValidHostname, isValidPort } from "./general";
|
import { isValidHostname, isValidPort } from "./general";
|
||||||
|
|
||||||
import type { MailerConfig } from "#types/config";
|
import type { MailerConfig } from "#types/config";
|
||||||
|
import type { simpleConfigValidation } from "#types/lib";
|
||||||
|
|
||||||
function validateMailerConfig(config: MailerConfig): {
|
function validateMailerConfig(config: MailerConfig): simpleConfigValidation {
|
||||||
isValid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const isValidSMTPAddress = isValidHostname(config.address);
|
const isValidSMTPAddress = isValidHostname(config.address);
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
import { redis } from "bun";
|
||||||
import { environment } from "#environment/config";
|
import { environment } from "#environment/config";
|
||||||
|
import { extraValues } from "#environment/extra";
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
import { mailerService } from "#lib/mailer";
|
import { mailerService } from "#lib/mailer";
|
||||||
import { pika } from "#lib/utils";
|
import { pika } from "#lib/utils";
|
||||||
|
@ -8,8 +11,6 @@ import {
|
||||||
isValidPassword,
|
isValidPassword,
|
||||||
isValidUsername,
|
isValidUsername,
|
||||||
} from "#lib/validation";
|
} from "#lib/validation";
|
||||||
|
|
||||||
import { echo } from "@atums/echo";
|
|
||||||
import type {
|
import type {
|
||||||
ExtendedRequest,
|
ExtendedRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
@ -116,6 +117,7 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = pika.gen("user");
|
const userId = pika.gen("user");
|
||||||
|
const verificationToken = Bun.randomUUIDv7();
|
||||||
|
|
||||||
const hashedPassword = await Bun.password.hash(password, {
|
const hashedPassword = await Bun.password.hash(password, {
|
||||||
algorithm: "argon2id",
|
algorithm: "argon2id",
|
||||||
|
@ -151,40 +153,81 @@ async function handler(
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: RegisterResponse = {
|
|
||||||
code: 201,
|
|
||||||
success: true,
|
|
||||||
message: "User registered successfully",
|
|
||||||
user: responseUser,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await redis.set(
|
||||||
|
`mail-verification:${verificationToken}`,
|
||||||
|
JSON.stringify({
|
||||||
|
userId: responseUser.id,
|
||||||
|
email: responseUser.email,
|
||||||
|
}),
|
||||||
|
"EX",
|
||||||
|
3 * 60 * 60, // 3h
|
||||||
|
);
|
||||||
|
|
||||||
const emailVariables = {
|
const emailVariables = {
|
||||||
subject: "Welcome to void",
|
subject: `Welcome to ${extraValues.companyName}`,
|
||||||
companyName: "void",
|
companyName: extraValues.companyName,
|
||||||
id: responseUser.id,
|
id: responseUser.id,
|
||||||
displayName: responseUser.displayName || responseUser.username,
|
displayName: responseUser.displayName || responseUser.username,
|
||||||
isVerified: "Pending verification",
|
isVerified: "Pending verification",
|
||||||
verificationUrl: `${environment.fqdn}/verify?token=generated_token`, // TODO: Actually generate a token
|
willExpire: "This link will expire in 3 hours",
|
||||||
|
verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`,
|
||||||
|
supportEmail: extraValues.supportEmail,
|
||||||
|
currentYear: new Date().getFullYear(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mailerService.sendTemplateEmail(
|
await mailerService.sendTemplateEmail(
|
||||||
responseUser.email,
|
responseUser.email,
|
||||||
"Welcome to void",
|
`Welcome to ${extraValues.companyName}`,
|
||||||
"register",
|
"register",
|
||||||
emailVariables,
|
emailVariables,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const response: RegisterResponse = {
|
||||||
|
code: 201,
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"User registered successfully - please check your email to verify your account",
|
||||||
|
user: responseUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(response, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await cassandra.execute("DELETE FROM users WHERE id = ?", [userId]);
|
||||||
|
|
||||||
|
await redis.del(`mail-verification:${verificationToken}`);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
echo.error({
|
||||||
|
message: "Failed to cleanup user after email failure",
|
||||||
|
error: cleanupError,
|
||||||
|
userId,
|
||||||
|
email: responseUser.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
echo.error({
|
echo.error({
|
||||||
message: "Failed to send registration email",
|
message: "Registration failed - could not send verification email",
|
||||||
error,
|
error,
|
||||||
to: responseUser.email,
|
userId,
|
||||||
|
email: responseUser.email,
|
||||||
template: "register",
|
template: "register",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(response, { status: 201 });
|
const response: RegisterResponse = {
|
||||||
} catch {
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Registration failed - unable to send verification email. Please try again.",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
echo.error({
|
||||||
|
message: "Registration failed with unexpected error",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
const response: RegisterResponse = {
|
const response: RegisterResponse = {
|
||||||
code: 500,
|
code: 500,
|
||||||
success: false,
|
success: false,
|
||||||
|
|
246
src/routes/user/verify.ts
Normal file
246
src/routes/user/verify.ts
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
import { redis } from "bun";
|
||||||
|
import { sessionManager } from "#lib/auth";
|
||||||
|
import { cassandra } from "#lib/database";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExtendedRequest,
|
||||||
|
RouteDef,
|
||||||
|
UserResponse,
|
||||||
|
VerificationData,
|
||||||
|
VerifyEmailResponse,
|
||||||
|
} from "#types/server";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "GET",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { token } = request.query;
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string" || token.trim() === "") {
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Verification token is required",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationKey = `mail-verification:${token}`;
|
||||||
|
const verificationDataRaw = await redis.get(verificationKey);
|
||||||
|
|
||||||
|
if (!verificationDataRaw) {
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid or expired verification token",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let verificationData: VerificationData;
|
||||||
|
try {
|
||||||
|
verificationData = JSON.parse(verificationDataRaw);
|
||||||
|
} catch {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid verification token format",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verificationData.userId || !verificationData.email) {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid verification data",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userQuery = `
|
||||||
|
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||||
|
FROM users WHERE id = ? LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userResult = (await cassandra.execute(userQuery, [
|
||||||
|
verificationData.userId,
|
||||||
|
])) as {
|
||||||
|
rows: Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
email: string;
|
||||||
|
is_verified: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!userResult?.rows || userResult.rows.length === 0) {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email !== verificationData.email) {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Verification token does not match current email address",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_verified) {
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const responseUser: UserResponse = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
isVerified: user.is_verified,
|
||||||
|
createdAt: user.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
message: "Email is already verified",
|
||||||
|
user: responseUser,
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE users
|
||||||
|
SET is_verified = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
await cassandra.execute(updateQuery, [true, new Date(), user.id]);
|
||||||
|
|
||||||
|
await redis.del(verificationKey);
|
||||||
|
|
||||||
|
const updatedUserResult = (await cassandra.execute(userQuery, [
|
||||||
|
user.id,
|
||||||
|
])) as {
|
||||||
|
rows: Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
email: string;
|
||||||
|
is_verified: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedUser = updatedUserResult.rows[0];
|
||||||
|
if (!updatedUser) {
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error: "Failed to fetch updated user data",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await sessionManager.getSession(request);
|
||||||
|
let sessionCookie: string | undefined;
|
||||||
|
|
||||||
|
if (session && session.id === user.id) {
|
||||||
|
try {
|
||||||
|
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
||||||
|
const updatedSessionPayload = {
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
email: updatedUser.email,
|
||||||
|
isVerified: updatedUser.is_verified,
|
||||||
|
displayName: updatedUser.display_name,
|
||||||
|
createdAt: updatedUser.created_at.toISOString(),
|
||||||
|
updatedAt: updatedUser.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionCookie = await sessionManager.updateSession(
|
||||||
|
request,
|
||||||
|
updatedSessionPayload,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
} catch (sessionError) {
|
||||||
|
echo.warn({
|
||||||
|
message: "Failed to update session after email verification",
|
||||||
|
error: sessionError,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseUser: UserResponse = {
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
displayName: updatedUser.display_name,
|
||||||
|
email: updatedUser.email,
|
||||||
|
isVerified: updatedUser.is_verified,
|
||||||
|
createdAt: updatedUser.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
message: "Email verified successfully",
|
||||||
|
user: responseUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(response, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": sessionCookie ? sessionCookie : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
echo.error({
|
||||||
|
message: "Error during email verification",
|
||||||
|
error,
|
||||||
|
token: request.query.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: VerifyEmailResponse = {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -2,7 +2,8 @@ type Environment = {
|
||||||
port: number;
|
port: number;
|
||||||
host: string;
|
host: string;
|
||||||
development: boolean;
|
development: boolean;
|
||||||
fqdn: string;
|
frontendFqdn: string;
|
||||||
|
backendFqdn: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { Environment };
|
export type { Environment };
|
||||||
|
|
|
@ -25,9 +25,15 @@ interface UrlValidationResult extends validationResult {
|
||||||
normalizedUrl?: string;
|
normalizedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type simpleConfigValidation = {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
genericValidation,
|
genericValidation,
|
||||||
validationResult,
|
validationResult,
|
||||||
UrlValidationOptions,
|
UrlValidationOptions,
|
||||||
UrlValidationResult,
|
UrlValidationResult,
|
||||||
|
simpleConfigValidation,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,5 +2,6 @@ export * from "./base";
|
||||||
export * from "./responses";
|
export * from "./responses";
|
||||||
export * from "./register";
|
export * from "./register";
|
||||||
export * from "./login";
|
export * from "./login";
|
||||||
|
export * from "./verify";
|
||||||
|
|
||||||
export * from "./update";
|
export * from "./update";
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
interface UpdatePasswordRequest {
|
|
||||||
currentPassword: string;
|
|
||||||
newPassword: string;
|
|
||||||
logoutAllSessions?: boolean; // defaults to false
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { UpdatePasswordRequest };
|
|
13
types/server/requests/user/verify.ts
Normal file
13
types/server/requests/user/verify.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import type { BaseResponse } from "../base";
|
||||||
|
import type { UserResponse } from "./base";
|
||||||
|
|
||||||
|
interface VerifyEmailResponse extends BaseResponse {
|
||||||
|
user?: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerificationData {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { VerifyEmailResponse, VerificationData };
|
Loading…
Add table
Add a link
Reference in a new issue