- move config requiredVariables to contants
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:
creations 2025-06-11 09:20:48 -04:00
parent 83b71f62cf
commit fff3c3ca50
Signed by: creations
GPG key ID: 8F553AA4320FC711
16 changed files with 482 additions and 89 deletions

View file

@ -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);

View file

@ -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";

View file

@ -1,6 +1,6 @@
const reqLoggerIgnores = { const reqLoggerIgnores = {
ignoredStartsWith: ["/public"], ignoredStartsWith: ["/public"],
ignoredPaths: [""], ignoredPaths: ["/favicon.ico"],
}; };
export { reqLoggerIgnores }; export { reqLoggerIgnores };

View file

@ -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
View file

@ -0,0 +1,6 @@
const extraValues = {
companyName: process.env.EXTRA_COMPANY_NAME || "Default Company",
supportEmail: process.env.EXTRA_SUPPORT_EMAIL || "",
};
export { extraValues };

View file

@ -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>

View file

@ -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,

View file

@ -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,
}; };
} }

View file

@ -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);

View file

@ -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
View 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 };

View file

@ -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 };

View file

@ -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,
}; };

View file

@ -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";

View file

@ -1,7 +0,0 @@
interface UpdatePasswordRequest {
currentPassword: string;
newPassword: string;
logoutAllSessions?: boolean; // defaults to false
}
export type { UpdatePasswordRequest };

View 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 };