From fff3c3ca50d4fd13ef8ff87e7d3eed5bc18a3d39 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 11 Jun 2025 09:20:48 -0400 Subject: [PATCH] - move config requiredVariables to contants - change register email - add verify route ( mostly still needs to be tested --- src/environment/config.ts | 59 ++--- src/environment/constants/index.ts | 29 +++ src/environment/constants/server.ts | 2 +- src/environment/database/cassandra.ts | 8 +- src/environment/extra.ts | 6 + .../mailer/templates/register.html | 95 ++++++- src/lib/mailer/index.ts | 2 +- src/lib/validation/jwt.ts | 7 +- src/lib/validation/mailer.ts | 6 +- src/routes/user/register.ts | 81 ++++-- src/routes/user/verify.ts | 246 ++++++++++++++++++ types/config/environment.ts | 3 +- types/lib/validation.ts | 6 + types/server/requests/user/index.ts | 1 + types/server/requests/user/password.ts | 7 - types/server/requests/user/verify.ts | 13 + 16 files changed, 482 insertions(+), 89 deletions(-) create mode 100644 src/environment/extra.ts create mode 100644 src/routes/user/verify.ts delete mode 100644 types/server/requests/user/password.ts create mode 100644 types/server/requests/user/verify.ts diff --git a/src/environment/config.ts b/src/environment/config.ts index 009c1a2..81c7bf0 100644 --- a/src/environment/config.ts +++ b/src/environment/config.ts @@ -1,6 +1,7 @@ import { echo } from "@atums/echo"; import { validateJWTConfig, validateMailerConfig } from "#lib/validation"; import { isValidUrl } from "#lib/validation/url"; +import { requiredVariables } from "./constants"; import { cassandraConfig, validateCassandraConfig } from "./database/cassandra"; import { jwt } from "./jwt"; import { mailerConfig } from "./mailer"; @@ -12,30 +13,11 @@ const environment: Environment = { host: process.env.HOST || "0.0.0.0", development: 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 { - 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; for (const key of requiredVariables) { @@ -56,9 +38,11 @@ function verifyRequiredVariables(): void { } const validateJWT = validateJWTConfig(jwt); - if (!validateJWT.valid) { + if (!validateJWT.isValid) { echo.error("JWT configuration validation failed:"); - echo.error(`- ${validateJWT.error}`); + for (const error of validateJWT.errors) { + echo.error(`- ${error}`); + } hasError = true; } @@ -71,19 +55,24 @@ function verifyRequiredVariables(): void { hasError = true; } - const urlValidation = isValidUrl(environment.fqdn, { - requireProtocol: true, - failOnTrailingSlash: true, - allowedProtocols: ["http", "https"], - allowLocalhost: environment.development, - allowIP: environment.development, - }); + const validateUrl = (url: string, name: string) => { + const validation = isValidUrl(url, { + requireProtocol: true, + failOnTrailingSlash: true, + allowedProtocols: ["http", "https"], + allowLocalhost: environment.development, + allowIP: environment.development, + }); - if (!urlValidation.valid) { - echo.error("FRONTEND_FQDN validation failed:"); - echo.error(`- ${urlValidation.error}`); - hasError = true; - } + if (!validation.valid) { + echo.error(`${name} validation failed:`); + echo.error(`- ${validation.error}`); + hasError = true; + } + }; + + validateUrl(environment.frontendFqdn, "FRONTEND_FQDN"); + validateUrl(environment.backendFqdn, "BACKEND_FQDN"); if (hasError) { process.exit(1); diff --git a/src/environment/constants/index.ts b/src/environment/constants/index.ts index dab8414..310a710 100644 --- a/src/environment/constants/index.ts +++ b/src/environment/constants/index.ts @@ -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 "./validation"; export * from "./database"; diff --git a/src/environment/constants/server.ts b/src/environment/constants/server.ts index 2ff4a9f..aedf92e 100644 --- a/src/environment/constants/server.ts +++ b/src/environment/constants/server.ts @@ -1,6 +1,6 @@ const reqLoggerIgnores = { ignoredStartsWith: ["/public"], - ignoredPaths: [""], + ignoredPaths: ["/favicon.ico"], }; export { reqLoggerIgnores }; diff --git a/src/environment/database/cassandra.ts b/src/environment/database/cassandra.ts index 41254cc..25a988d 100644 --- a/src/environment/database/cassandra.ts +++ b/src/environment/database/cassandra.ts @@ -1,4 +1,5 @@ import type { CassandraConfig } from "#types/config"; +import type { simpleConfigValidation } from "#types/lib"; function isValidHost(host: string): boolean { if (!host || host.trim().length === 0) return false; @@ -50,10 +51,9 @@ function isValidDatacenter(datacenter: string, authEnabled: boolean): boolean { return datacenter.trim().length > 0; } -function validateCassandraConfig(config: CassandraConfig): { - isValid: boolean; - errors: string[]; -} { +function validateCassandraConfig( + config: CassandraConfig, +): simpleConfigValidation { const errors: string[] = []; if (!isValidHost(config.host)) { diff --git a/src/environment/extra.ts b/src/environment/extra.ts new file mode 100644 index 0000000..4cd5848 --- /dev/null +++ b/src/environment/extra.ts @@ -0,0 +1,6 @@ +const extraValues = { + companyName: process.env.EXTRA_COMPANY_NAME || "Default Company", + supportEmail: process.env.EXTRA_SUPPORT_EMAIL || "", +}; + +export { extraValues }; diff --git a/src/environment/mailer/templates/register.html b/src/environment/mailer/templates/register.html index 5573145..cd55ac0 100644 --- a/src/environment/mailer/templates/register.html +++ b/src/environment/mailer/templates/register.html @@ -5,28 +5,95 @@ {{subject}} + +

Welcome to {{companyName}}!

Hi {{displayName}},

-

Thank you for registering with {{companyName}}. Your account has been successfully created.

-

Account Details:

-

- User ID: {{id}}
- Verification Status: {{isVerified}} -

+

Thanks for signing up! Please verify your email address to activate your account:

-

To get started, please verify your email address by clicking the link below:

-

Verify Email Address

+

Verify Email Address

+ +
+ ⏰ Important: {{willExpire}} for security reasons. +
+ +

If the button doesn't work:

+

Copy and paste this link into your browser:

+
{{verificationUrl}}
+ +

Once verified, you'll have full access to your {{companyName}} account!

+ +

Questions? Reply to this email or contact us at {{supportEmail}}

-

If you didn't create this account, please ignore this email.


-

- Best regards,
- The {{companyName}} team -

+

Best regards,
The {{companyName}} team

+ + - + \ No newline at end of file diff --git a/src/lib/mailer/index.ts b/src/lib/mailer/index.ts index 2fa26be..c5bcb3e 100644 --- a/src/lib/mailer/index.ts +++ b/src/lib/mailer/index.ts @@ -155,7 +155,7 @@ class MailerService { const info = await this.transporter.sendMail(mailOptions); - noFileLog.info({ + echo.debug({ message: `Email sent successfully to ${options.to}`, messageId: info.messageId, subject: options.subject, diff --git a/src/lib/validation/jwt.ts b/src/lib/validation/jwt.ts index c2fef1d..4d07d8f 100644 --- a/src/lib/validation/jwt.ts +++ b/src/lib/validation/jwt.ts @@ -1,5 +1,5 @@ import type { JWTConfig } from "#types/config"; -import type { validationResult } from "#types/lib"; +import type { simpleConfigValidation } from "#types/lib"; function isValidSecret(secret: string): boolean { if (!secret || secret.trim().length === 0) return false; @@ -36,7 +36,7 @@ function isValidAlgorithm(algorithm: string): boolean { return supportedAlgorithms.includes(algorithm); } -function validateJWTConfig(config: JWTConfig): validationResult { +function validateJWTConfig(config: JWTConfig): simpleConfigValidation { const errors: string[] = []; if (!isValidSecret(config.secret)) { @@ -67,8 +67,9 @@ function validateJWTConfig(config: JWTConfig): validationResult { } return { - valid: errors.length === 0, + isValid: errors.length === 0, ...(errors.length > 0 && { error: errors.join("; ") }), + errors, }; } diff --git a/src/lib/validation/mailer.ts b/src/lib/validation/mailer.ts index 0d0321b..b3042f5 100644 --- a/src/lib/validation/mailer.ts +++ b/src/lib/validation/mailer.ts @@ -2,11 +2,9 @@ import { isValidEmail } from "./email"; import { isValidHostname, isValidPort } from "./general"; import type { MailerConfig } from "#types/config"; +import type { simpleConfigValidation } from "#types/lib"; -function validateMailerConfig(config: MailerConfig): { - isValid: boolean; - errors: string[]; -} { +function validateMailerConfig(config: MailerConfig): simpleConfigValidation { const errors: string[] = []; const isValidSMTPAddress = isValidHostname(config.address); diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index cebc732..4d92d61 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -1,4 +1,7 @@ +import { echo } from "@atums/echo"; +import { redis } from "bun"; import { environment } from "#environment/config"; +import { extraValues } from "#environment/extra"; import { cassandra } from "#lib/database"; import { mailerService } from "#lib/mailer"; import { pika } from "#lib/utils"; @@ -8,8 +11,6 @@ import { isValidPassword, isValidUsername, } from "#lib/validation"; - -import { echo } from "@atums/echo"; import type { ExtendedRequest, RegisterRequest, @@ -116,6 +117,7 @@ async function handler( } const userId = pika.gen("user"); + const verificationToken = Bun.randomUUIDv7(); const hashedPassword = await Bun.password.hash(password, { algorithm: "argon2id", @@ -151,40 +153,81 @@ async function handler( createdAt: now.toISOString(), }; - const response: RegisterResponse = { - code: 201, - success: true, - message: "User registered successfully", - user: responseUser, - }; - try { + await redis.set( + `mail-verification:${verificationToken}`, + JSON.stringify({ + userId: responseUser.id, + email: responseUser.email, + }), + "EX", + 3 * 60 * 60, // 3h + ); + const emailVariables = { - subject: "Welcome to void", - companyName: "void", + subject: `Welcome to ${extraValues.companyName}`, + companyName: extraValues.companyName, id: responseUser.id, displayName: responseUser.displayName || responseUser.username, 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, - "Welcome to void", + `Welcome to ${extraValues.companyName}`, "register", 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) { + 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({ - message: "Failed to send registration email", + message: "Registration failed - could not send verification email", error, - to: responseUser.email, + userId, + email: responseUser.email, template: "register", }); - } - return Response.json(response, { status: 201 }); - } catch { + const response: RegisterResponse = { + 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 = { code: 500, success: false, diff --git a/src/routes/user/verify.ts b/src/routes/user/verify.ts new file mode 100644 index 0000000..eac0019 --- /dev/null +++ b/src/routes/user/verify.ts @@ -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 { + 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 }; diff --git a/types/config/environment.ts b/types/config/environment.ts index 400d15f..7bd08c3 100644 --- a/types/config/environment.ts +++ b/types/config/environment.ts @@ -2,7 +2,8 @@ type Environment = { port: number; host: string; development: boolean; - fqdn: string; + frontendFqdn: string; + backendFqdn: string; }; export type { Environment }; diff --git a/types/lib/validation.ts b/types/lib/validation.ts index ef3b509..a5bb5d8 100644 --- a/types/lib/validation.ts +++ b/types/lib/validation.ts @@ -25,9 +25,15 @@ interface UrlValidationResult extends validationResult { normalizedUrl?: string; } +type simpleConfigValidation = { + isValid: boolean; + errors: string[]; +}; + export type { genericValidation, validationResult, UrlValidationOptions, UrlValidationResult, + simpleConfigValidation, }; diff --git a/types/server/requests/user/index.ts b/types/server/requests/user/index.ts index 844adaf..c835960 100644 --- a/types/server/requests/user/index.ts +++ b/types/server/requests/user/index.ts @@ -2,5 +2,6 @@ export * from "./base"; export * from "./responses"; export * from "./register"; export * from "./login"; +export * from "./verify"; export * from "./update"; diff --git a/types/server/requests/user/password.ts b/types/server/requests/user/password.ts deleted file mode 100644 index dde9e1c..0000000 --- a/types/server/requests/user/password.ts +++ /dev/null @@ -1,7 +0,0 @@ -interface UpdatePasswordRequest { - currentPassword: string; - newPassword: string; - logoutAllSessions?: boolean; // defaults to false -} - -export type { UpdatePasswordRequest }; diff --git a/types/server/requests/user/verify.ts b/types/server/requests/user/verify.ts new file mode 100644 index 0000000..84b77f5 --- /dev/null +++ b/types/server/requests/user/verify.ts @@ -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 };