From a783a0e6637b6549e583d0358594a464f01522f9 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 13 Jun 2025 20:33:29 -0400 Subject: [PATCH] move the database and mailer to own log sub dir, add all email change logic --- bun.lock | 10 +- package.json | 6 +- src/environment/constants/index.ts | 1 + src/environment/constants/user/index.ts | 1 + src/environment/constants/user/update.ts | 6 + .../email-change-completed-notification.html | 40 ++ .../email-change-request-notification.html | 41 ++ .../templates/email-change-verification.html | 41 ++ .../mailer/templates/forgot-password.html | 34 +- .../mailer/templates/register.html | 27 +- src/lib/database/cassandra.ts | 63 ++- src/lib/database/migrations.ts | 35 +- src/lib/mailer/index.ts | 64 ++- src/routes/user/[id].ts | 3 +- src/routes/user/forgot/password.ts | 1 - src/routes/user/logout.ts | 2 +- src/routes/user/register.ts | 1 - src/routes/user/update/email.ts | 495 ++++++++++++++++++ src/routes/user/update/info.ts | 127 +---- src/routes/user/update/password.ts | 2 +- src/routes/user/verify.ts | 2 +- src/server.ts | 3 + types/server/requests/user/update/email.ts | 23 + types/server/requests/user/update/index.ts | 1 + types/server/requests/user/update/info.ts | 1 - types/server/server.ts | 3 + 26 files changed, 808 insertions(+), 225 deletions(-) create mode 100644 src/environment/constants/user/index.ts create mode 100644 src/environment/constants/user/update.ts create mode 100644 src/environment/mailer/templates/email-change-completed-notification.html create mode 100644 src/environment/mailer/templates/email-change-request-notification.html create mode 100644 src/environment/mailer/templates/email-change-verification.html create mode 100644 src/routes/user/update/email.ts create mode 100644 types/server/requests/user/update/email.ts diff --git a/bun.lock b/bun.lock index 8736447..9db602c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,15 @@ "name": "void.backend", "dependencies": { "@atums/echo": "latest", - "@types/nodemailer": "^6.4.17", "cassandra-driver": "latest", "fast-jwt": "latest", - "nodemailer": "^7.0.3", + "nodemailer": "latest", "pika-id": "latest", }, "devDependencies": { "@biomejs/biome": "latest", "@types/bun": "latest", + "@types/nodemailer": "latest", }, }, }, @@ -21,7 +21,7 @@ "@biomejs/biome", ], "packages": { - "@atums/echo": ["@atums/echo@1.0.6", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-2v0coX0Ptau6pjh4aTJDXMMJ2z/Q+0r8tvLokjeyUnLWGOPMwg+i4saBrkvDtHvQbNiq/NiEwMFLCxeIlxEyLQ=="], + "@atums/echo": ["@atums/echo@1.0.7", "", { "dependencies": { "date-fns-tz": "latest" } }, "sha512-RLHRmAmf/4a4CCGaNJIA1xkgycKBonVoWjyQ5bwW/srLjwhTgxkPbn6o56cRgmRMDguribqv5mWxEol3UL0srg=="], "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], @@ -43,7 +43,7 @@ "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], - "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], "@types/node": ["@types/node@18.19.111", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw=="], @@ -55,7 +55,7 @@ "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], - "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], "cassandra-driver": ["cassandra-driver@4.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "adm-zip": "~0.5.10", "long": "~5.2.3" } }, "sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw=="], diff --git a/package.json b/package.json index 466601d..8fc1362 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ }, "devDependencies": { "@biomejs/biome": "latest", - "@types/bun": "latest" + "@types/bun": "latest", + "@types/nodemailer": "latest" }, "dependencies": { "@atums/echo": "latest", - "@types/nodemailer": "^6.4.17", "cassandra-driver": "latest", "fast-jwt": "latest", - "nodemailer": "^7.0.3", + "nodemailer": "latest", "pika-id": "latest" }, "trustedDependencies": ["@biomejs/biome"] diff --git a/src/environment/constants/index.ts b/src/environment/constants/index.ts index 310a710..6ca789c 100644 --- a/src/environment/constants/index.ts +++ b/src/environment/constants/index.ts @@ -31,3 +31,4 @@ export * from "./server"; export * from "./validation"; export * from "./database"; export * from "./mailer"; +export * from "./user"; diff --git a/src/environment/constants/user/index.ts b/src/environment/constants/user/index.ts new file mode 100644 index 0000000..635be64 --- /dev/null +++ b/src/environment/constants/user/index.ts @@ -0,0 +1 @@ +export * from "./update"; diff --git a/src/environment/constants/user/update.ts b/src/environment/constants/user/update.ts new file mode 100644 index 0000000..f69fe57 --- /dev/null +++ b/src/environment/constants/user/update.ts @@ -0,0 +1,6 @@ +const emailUpdateTimes = { + coolDownMinutes: 5, + tokenExpiryHours: 3, +}; + +export { emailUpdateTimes }; diff --git a/src/environment/mailer/templates/email-change-completed-notification.html b/src/environment/mailer/templates/email-change-completed-notification.html new file mode 100644 index 0000000..87cb9c4 --- /dev/null +++ b/src/environment/mailer/templates/email-change-completed-notification.html @@ -0,0 +1,40 @@ + + + + + + {{subject}} + + + +

Email Address Changed - {{companyName}}

+

Hi {{displayName}},

+

Your email address has been successfully changed.

+ +

Change Details:

+

Previous Email: {{oldEmail}} (this address)
+ New Email: {{newEmail}}
+ Changed On: {{changeTime}}

+ +

Important: You will no longer receive account emails at this address ({{oldEmail}}). All future communications will be sent to {{newEmail}}.

+ +

For future logins, please use:

+

Email: {{newEmail}}
+ Password: (unchanged)

+ +

If this change was not authorized by you: Contact our support team immediately at {{supportEmail}}. Your account may have been compromised and we will help you recover it.

+
+

User ID: {{id}} | {{companyName}}

+ + diff --git a/src/environment/mailer/templates/email-change-request-notification.html b/src/environment/mailer/templates/email-change-request-notification.html new file mode 100644 index 0000000..5d7216b --- /dev/null +++ b/src/environment/mailer/templates/email-change-request-notification.html @@ -0,0 +1,41 @@ + + + + + + {{subject}} + + + +

Email Change Request - {{companyName}}

+

Hi {{displayName}},

+

Security Notice: Someone requested to change your account email address.

+ +

Change Details:

+

Current Email: {{currentEmail}}
+ Requested New Email: {{newEmail}}
+ Request Time: {{requestTime}}

+ +

A verification email has been sent to {{newEmail}}.

+

{{willExpire}} if not completed.

+ +

If this was you: Check your new email ({{newEmail}}) and click the verification link to complete the change.

+ +

If this was NOT you: Your account may be compromised. Please change your password immediately and contact our support team at {{supportEmail}}.

+ +

Questions? Contact {{supportEmail}}

+
+

User ID: {{id}} | {{companyName}}

+ + diff --git a/src/environment/mailer/templates/email-change-verification.html b/src/environment/mailer/templates/email-change-verification.html new file mode 100644 index 0000000..61e43c2 --- /dev/null +++ b/src/environment/mailer/templates/email-change-verification.html @@ -0,0 +1,41 @@ + + + + + + {{subject}} + + + +

Email Change Verification - {{companyName}}

+

Hi {{displayName}},

+

You requested to change your email address from {{currentEmail}} to {{newEmail}}.

+

To complete this email change, please click the button below:

+ + Verify Email Change + +

{{willExpire}} for security reasons.

+

Important: After verification, your account email will be changed to this address and you'll need to use it for future logins.

+

If you did not request this email change, contact {{supportEmail}} immediately.

+

Questions? Contact {{supportEmail}}

+
+

User ID: {{id}} | {{companyName}}

+ + diff --git a/src/environment/mailer/templates/forgot-password.html b/src/environment/mailer/templates/forgot-password.html index b018ef5..03d682d 100644 --- a/src/environment/mailer/templates/forgot-password.html +++ b/src/environment/mailer/templates/forgot-password.html @@ -1,6 +1,5 @@ - @@ -13,35 +12,28 @@ padding: 20px; line-height: 1.5; } - - a { - color: #0066cc; + .reset-button { + display: inline-block; + background-color: #0066cc; + color: white; + padding: 10px 20px; + text-decoration: none; + border-radius: 3px; + margin: 15px 0; } -

Password Reset - {{companyName}}

-

Hi {{displayName}},

+

You requested a password reset for your account. Click the button below to reset your password:

-

You requested a password reset for your account. Click the link below to reset your password:

- -

{{resetUrl}}

+ Reset Password

{{willExpire}} for security reasons.

- -

If you did not request this password reset, please ignore this email. Your password will remain unchanged.

- +

If you did not request this password reset, please contact {{supportEmail}} immediately. Your password will remain unchanged.

Questions? Contact {{supportEmail}}

- -

Best regards,
- The {{companyName}} team

-
- -

User ID: {{id}} | © {{currentYear}} {{companyName}}

- +

User ID: {{id}} | {{companyName}}

- - \ No newline at end of file + diff --git a/src/environment/mailer/templates/register.html b/src/environment/mailer/templates/register.html index 499013f..c603819 100644 --- a/src/environment/mailer/templates/register.html +++ b/src/environment/mailer/templates/register.html @@ -1,6 +1,5 @@ - @@ -13,33 +12,29 @@ padding: 20px; line-height: 1.5; } - - a { - color: #0066cc; + .verify-button { + display: inline-block; + background-color: #0066cc; + color: white; + padding: 10px 20px; + text-decoration: none; + border-radius: 3px; + margin: 15px 0; } -

Welcome to {{companyName}}!

-

Hi {{displayName}},

-

Please verify your email address:

-

{{verificationUrl}}

+ Verify Email

{{willExpire}} for security reasons.

-

Questions? Contact {{supportEmail}}

-

Best regards,
The {{companyName}} team

-
- -

User ID: {{id}} | © {{currentYear}} {{companyName}}

- +

User ID: {{id}} | {{companyName}}

- - \ No newline at end of file + diff --git a/src/lib/database/cassandra.ts b/src/lib/database/cassandra.ts index 5b3cbe1..98a0a9e 100644 --- a/src/lib/database/cassandra.ts +++ b/src/lib/database/cassandra.ts @@ -1,6 +1,5 @@ -import { echo } from "@atums/echo"; +import { Echo } from "@atums/echo"; import { cassandraConfig as config } from "#environment/database"; -import { noFileLog } from "#index"; import { Client, @@ -15,8 +14,10 @@ class CassandraService { private static instance: Client | null = null; private static isConnecting = false; private static connectionPromise: Promise | null = null; + private static logger: Echo = new Echo({ subDirectory: "cassandra" }); + private static loggerNoFile: Echo = new Echo({ disableFile: true }); - private constructor() {} + private constructor() { } public static getClient(): Client { if (!CassandraService.instance) { @@ -96,7 +97,7 @@ class CassandraService { const clientOptions = CassandraService.buildClientOptions(options); if (options.logging !== false) { - noFileLog.info({ + CassandraService.loggerNoFile.info({ message: "Connecting to Cassandra...", contactPoints: config.contactPoints, datacenter: config.datacenter, @@ -114,7 +115,7 @@ class CassandraService { const hostCount = hosts.length; if (options.logging !== false) { - noFileLog.info( + CassandraService.loggerNoFile.info( `Connected to Cassandra successfully. Active hosts: ${hostCount}`, ); } @@ -126,16 +127,23 @@ class CassandraService { "log", (level: string, className: string, message: string) => { if (level === "error") { - echo.error(`Cassandra ${className}: ${message}`); + CassandraService.logger.error( + `Cassandra ${className}: ${message}`, + ); } else if (level === "warning") { - echo.warn(`Cassandra ${className}: ${message}`); + CassandraService.logger.warn( + `Cassandra ${className}: ${message}`, + ); } }, ); } } catch (error) { - echo.error({ message: "Failed to connect to Cassandra:", error }); - await client.shutdown().catch(() => {}); + CassandraService.logger.error({ + message: "Failed to connect to Cassandra:", + error, + }); + await client.shutdown().catch(() => { }); throw error; } } @@ -157,9 +165,11 @@ class CassandraService { try { await client.execute(query); - noFileLog.debug(`Keyspace '${config.keyspace}' ensured to exist`); + CassandraService.loggerNoFile.debug( + `Keyspace '${config.keyspace}' ensured to exist`, + ); } catch (error) { - echo.error({ + CassandraService.logger.error({ message: `Failed to create keyspace '${config.keyspace}':`, error, }); @@ -178,7 +188,7 @@ class CassandraService { const result = await client.execute(query, params, options); return result; } catch (error) { - echo.error({ + CassandraService.logger.error({ message: "Cassandra query failed:", query: query.substring(0, 100) + (query.length > 100 ? "..." : ""), error, @@ -192,10 +202,15 @@ class CassandraService { try { await CassandraService.instance.shutdown(); if (!disableLogging) { - noFileLog.info("Cassandra client shut down gracefully"); + CassandraService.loggerNoFile.info( + "Cassandra client shut down gracefully", + ); } } catch (error) { - echo.error({ message: "Error during Cassandra shutdown:", error }); + CassandraService.logger.error({ + message: "Error during Cassandra shutdown:", + error, + }); } finally { CassandraService.instance = null; } @@ -242,11 +257,11 @@ class CassandraService { }); if (tableNames.length > 0) { - noFileLog.warn( + CassandraService.loggerNoFile.warn( `About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`, ); } else { - noFileLog.info( + CassandraService.loggerNoFile.info( `Keyspace '${config.keyspace}' is empty or doesn't exist`, ); } @@ -254,9 +269,11 @@ class CassandraService { const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`; await client.execute(dropQuery); - noFileLog.info(`Keyspace '${config.keyspace}' dropped successfully`); + CassandraService.loggerNoFile.info( + `Keyspace '${config.keyspace}' dropped successfully`, + ); } catch (error) { - echo.error({ + CassandraService.logger.error({ message: `Failed to drop keyspace '${config.keyspace}':`, error, }); @@ -267,7 +284,7 @@ class CassandraService { try { await CassandraService.shutdown(true); } catch (shutdownError) { - noFileLog.warn({ + CassandraService.loggerNoFile.warn({ message: "Error during shutdown after drop:", error: shutdownError, }); @@ -278,7 +295,9 @@ class CassandraService { CassandraService.isConnecting = false; CassandraService.connectionPromise = null; - noFileLog.info("Cassandra client state reset after dropping keyspace"); + CassandraService.loggerNoFile.info( + "Cassandra client state reset after dropping keyspace", + ); } public static async resetDatabase(): Promise { @@ -287,7 +306,7 @@ class CassandraService { "Reset operation is only allowed in development environment", ); - noFileLog.info("Starting database reset..."); + CassandraService.loggerNoFile.info("Starting database reset..."); await CassandraService.dropEverything(); @@ -295,7 +314,7 @@ class CassandraService { await CassandraService.createKeyspaceIfNotExists(); await CassandraService.shutdown(true); - noFileLog.info( + CassandraService.loggerNoFile.info( "Database reset complete. Restart your application to run migrations.", ); } diff --git a/src/lib/database/migrations.ts b/src/lib/database/migrations.ts index 7f86ffa..470fcd5 100644 --- a/src/lib/database/migrations.ts +++ b/src/lib/database/migrations.ts @@ -1,15 +1,16 @@ import { readFile, readdir } from "node:fs/promises"; import { resolve } from "node:path"; -import { echo } from "@atums/echo"; +import { Echo } from "@atums/echo"; import { environment } from "#environment/config"; import { migrationsPath } from "#environment/constants"; -import { noFileLog } from "#index"; import { cassandra } from "#lib/database"; import type { SqlMigration } from "#types/config"; class MigrationRunner { private migrations: SqlMigration[] = []; + private static logger: Echo = new Echo({ subDirectory: "migrations" }); + private static loggerNoFile: Echo = new Echo({ disableFile: true }); async loadMigrations(): Promise { try { @@ -28,7 +29,7 @@ class MigrationRunner { const name = nameParts.join("_") || "migration"; if (!id || id.trim() === "") { - noFileLog.debug( + MigrationRunner.loggerNoFile.debug( `Skipping migration file with invalid ID: ${sqlFile}`, ); continue; @@ -50,16 +51,18 @@ class MigrationRunner { ...(downSql && { downSql: downSql.trim() }), }); } catch (error) { - echo.error({ + MigrationRunner.logger.error({ message: `Failed to load migration ${sqlFile}:`, error, }); } } - noFileLog.debug(`Loaded ${this.migrations.length} migrations`); + MigrationRunner.loggerNoFile.debug( + `Loaded ${this.migrations.length} migrations`, + ); } catch (error) { - noFileLog.debug({ + MigrationRunner.loggerNoFile.debug({ message: "No migrations directory found or error reading:", error, }); @@ -76,7 +79,7 @@ class MigrationRunner { ) `; await cassandra.execute(query); - noFileLog.debug("Schema migrations table ready"); + MigrationRunner.loggerNoFile.debug("Schema migrations table ready"); } private async getExecutedMigrations(): Promise> { @@ -86,7 +89,7 @@ class MigrationRunner { )) as { rows: Array<{ id: string }> }; return new Set(result.rows.map((row) => row.id)); } catch (error) { - noFileLog.debug({ + MigrationRunner.loggerNoFile.debug({ message: "Could not fetch executed migrations:", error, }); @@ -133,7 +136,7 @@ class MigrationRunner { async runMigrations(): Promise { if (this.migrations.length === 0) { - noFileLog.debug("No migrations to run"); + MigrationRunner.loggerNoFile.debug("No migrations to run"); return; } await this.createMigrationsTable(); @@ -142,29 +145,31 @@ class MigrationRunner { (migration) => !executedMigrations.has(migration.id), ); if (pendingMigrations.length === 0) { - noFileLog.debug("All migrations are up to date"); + MigrationRunner.loggerNoFile.debug("All migrations are up to date"); return; } - noFileLog.debug( + MigrationRunner.loggerNoFile.debug( `Running ${pendingMigrations.length} pending migrations...`, ); for (const migration of pendingMigrations) { try { - noFileLog.debug( + MigrationRunner.loggerNoFile.debug( `Running migration: ${migration.id} - ${migration.name}`, ); await this.executeSql(migration.upSql); await this.markMigrationExecuted(migration); - noFileLog.debug(`Migration ${migration.id} completed`); + MigrationRunner.loggerNoFile.debug( + `Migration ${migration.id} completed`, + ); } catch (error) { - echo.error({ + MigrationRunner.logger.error({ message: `Failed to run migration ${migration.id}:`, error, }); throw error; } } - noFileLog.debug("All migrations completed successfully"); + MigrationRunner.loggerNoFile.debug("All migrations completed successfully"); } async initialize(): Promise { diff --git a/src/lib/mailer/index.ts b/src/lib/mailer/index.ts index c5bcb3e..2dbb6ae 100644 --- a/src/lib/mailer/index.ts +++ b/src/lib/mailer/index.ts @@ -1,9 +1,8 @@ -import { readdir } from "node:fs/promises"; -import { echo } from "@atums/echo"; +import { readdirSync } from "node:fs"; +import { Echo } from "@atums/echo"; import nodemailer from "nodemailer"; import { templatesPath } from "#environment/constants"; import { mailerConfig } from "#environment/mailer"; -import { noFileLog } from "#index"; import type { EmailResult, @@ -13,6 +12,9 @@ import type { class MailerService { private transporter: nodemailer.Transporter; + private templates: string[] = []; + private static logger: Echo = new Echo({ subDirectory: "mailer" }); + private static loggerNoFile: Echo = new Echo({ disableFile: true }); constructor() { this.transporter = nodemailer.createTransport({ @@ -33,15 +35,20 @@ class MailerService { rejectUnauthorized: true, }, } as nodemailer.TransportOptions); + + this.populateTemplates(); } async verifyConnection(): Promise { try { await this.transporter.verify(); - noFileLog.info("SMTP connection verified successfully"); + MailerService.loggerNoFile.info("SMTP connection verified successfully"); return true; } catch (error) { - echo.error({ message: "SMTP connection verification failed:", error }); + MailerService.logger.error({ + message: "SMTP connection verification failed:", + error, + }); return false; } } @@ -50,15 +57,18 @@ class MailerService { this.transporter.close(); } - private async listTemplates(): Promise { + public populateTemplates(): void { try { - const files = await readdir(templatesPath, { recursive: true }); - return files + const files = readdirSync(templatesPath, { recursive: true }); + this.templates = files .filter((file) => typeof file === "string" && file.endsWith(".html")) .map((file) => (file as string).replace(/\.html$/, "")); } catch (error) { - echo.error({ message: "Failed to list templates:", error }); - return []; + MailerService.logger.error({ + message: "Failed to list templates:", + error, + }); + this.templates = []; } } @@ -66,7 +76,7 @@ class MailerService { templateName: string, variables: TemplateVariables, ): Promise { - const templates = await this.listTemplates(); + const templates = this.templates; if (!templates.includes(templateName)) { throw new Error(`Template "${templateName}" not found`); } @@ -78,34 +88,20 @@ class MailerService { const file = Bun.file(templatePath); templateContent = await file.text(); } catch (error) { - echo.error({ + MailerService.logger.error({ message: `Failed to load template "${templateName}":`, error, }); throw error; } - const rewriter = new HTMLRewriter().on("*", { - text(text) { - if (text.text) { - let modifiedText = text.text; - for (const [key, value] of Object.entries(variables)) { - const regex = new RegExp(`{{${key}}}`, "g"); - modifiedText = modifiedText.replace(regex, String(value)); - } - if (modifiedText !== text.text) { - text.replace(modifiedText); - } - } - }, - }); + let processedContent = templateContent; + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, "g"); + processedContent = processedContent.replace(regex, String(value)); + } - const response = new Response(templateContent, { - headers: { "Content-Type": "text/html" }, - }); - - const transformedResponse = rewriter.transform(response); - return await transformedResponse.text(); + return processedContent; } async sendEmail(options: SendMailOptions): Promise { @@ -155,7 +151,7 @@ class MailerService { const info = await this.transporter.sendMail(mailOptions); - echo.debug({ + MailerService.logger.debug({ message: `Email sent successfully to ${options.to}`, messageId: info.messageId, subject: options.subject, @@ -168,7 +164,7 @@ class MailerService { response: info.response, }; } catch (error) { - echo.error({ + MailerService.logger.error({ message: "Failed to send email:", error, to: options.to, diff --git a/src/routes/user/[id].ts b/src/routes/user/[id].ts index df2fe2b..c0678c7 100644 --- a/src/routes/user/[id].ts +++ b/src/routes/user/[id].ts @@ -1,5 +1,4 @@ import { echo } from "@atums/echo"; -import { sessionManager } from "#lib/auth"; import { cassandra } from "#lib/database"; import type { @@ -20,7 +19,7 @@ async function handler(request: ExtendedRequest): Promise { try { const { id: identifier } = request.params; - const session = await sessionManager.getSession(request); + const { session } = request; let userQuery: string; let queryParams: string[]; diff --git a/src/routes/user/forgot/password.ts b/src/routes/user/forgot/password.ts index a6713a2..3f9fbd7 100644 --- a/src/routes/user/forgot/password.ts +++ b/src/routes/user/forgot/password.ts @@ -103,7 +103,6 @@ async function handler( willExpire: "This link will expire in 1 hour", resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`, supportEmail: extraValues.supportEmail, - currentYear: new Date().getFullYear(), }; await mailerService.sendTemplateEmail( diff --git a/src/routes/user/logout.ts b/src/routes/user/logout.ts index b1d90a1..558d2c9 100644 --- a/src/routes/user/logout.ts +++ b/src/routes/user/logout.ts @@ -13,7 +13,7 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { try { - const session = await sessionManager.getSession(request); + const { session } = request; if (!session) { const response: LogoutResponse = { diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index 4d92d61..39eedac 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -173,7 +173,6 @@ async function handler( willExpire: "This link will expire in 3 hours", verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`, supportEmail: extraValues.supportEmail, - currentYear: new Date().getFullYear(), }; await mailerService.sendTemplateEmail( diff --git a/src/routes/user/update/email.ts b/src/routes/user/update/email.ts new file mode 100644 index 0000000..6120c2d --- /dev/null +++ b/src/routes/user/update/email.ts @@ -0,0 +1,495 @@ +import { echo } from "@atums/echo"; +import { redis } from "bun"; +import { environment } from "#environment/config"; +import { emailUpdateTimes } from "#environment/constants"; +import { extraValues } from "#environment/extra"; +import { sessionManager } from "#lib/auth"; +import { cassandra } from "#lib/database"; +import { mailerService } from "#lib/mailer"; +import { isValidEmail } from "#lib/validation"; + +import type { UserSession } from "#types/config"; +import type { + EmailChangeData, + EmailChangeRequest, + EmailChangeResponse, + ExtendedRequest, + RouteDef, + UserResponse, + UserRow, +} from "#types/server"; + +const routeDef: RouteDef = { + method: ["POST", "GET"], + accepts: ["application/json", "*/*"], + returns: "application/json", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + try { + const { session } = request; + + if (!session) { + const response: EmailChangeResponse = { + code: 401, + success: false, + error: "Not authenticated", + }; + return Response.json(response, { status: 401 }); + } + + if (request.method === "GET") { + return await handleEmailVerification(request, session); + } + + return await handleEmailChangeRequest(request, requestBody, session); + } catch (error) { + echo.error({ + message: "Email change operation failed", + error, + }); + + const response: EmailChangeResponse = { + code: 500, + success: false, + error: "Internal server error", + }; + return Response.json(response, { status: 500 }); + } +} + +async function handleEmailChangeRequest( + request: ExtendedRequest, + requestBody: unknown, + session: UserSession, +): Promise { + const { newEmail } = requestBody as EmailChangeRequest; + + if (!newEmail) { + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "New email is required", + }; + return Response.json(response, { status: 400 }); + } + + const emailValidation = isValidEmail(newEmail); + if (!emailValidation.valid) { + const response: EmailChangeResponse = { + code: 400, + success: false, + error: emailValidation.error || "Invalid email", + }; + return Response.json(response, { status: 400 }); + } + + const normalizedEmail = newEmail.trim().toLowerCase(); + + const currentUserQuery = ` + SELECT id, username, display_name, email, is_verified, created_at, updated_at + FROM users WHERE id = ? LIMIT 1 + `; + const currentUserResult = (await cassandra.execute(currentUserQuery, [ + session.id, + ])) as { rows: UserRow[] }; + + if (!currentUserResult?.rows || currentUserResult.rows.length === 0) { + await sessionManager.invalidateSession(request); + + const response: EmailChangeResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + const currentUser = currentUserResult.rows[0]; + if (!currentUser) { + const response: EmailChangeResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + if (normalizedEmail === currentUser.email) { + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "New email must be different from current email", + }; + return Response.json(response, { status: 400 }); + } + + const cooldownKey = `email-change-cooldown:${session.id}`; + const lastRequest = await redis.get(cooldownKey); + + if (lastRequest) { + const lastRequestTime = Number.parseInt(lastRequest, 10); + const timeSince = Date.now() - lastRequestTime; + const cooldownMs = emailUpdateTimes.coolDownMinutes * 60 * 1000; + + if (timeSince < cooldownMs) { + const remainingMs = cooldownMs - timeSince; + const remainingMinutes = Math.ceil(remainingMs / (60 * 1000)); + + const response: EmailChangeResponse = { + code: 429, + success: false, + error: `Please wait ${remainingMinutes} minute(s) before requesting another email change`, + cooldownRemaining: remainingMinutes, + }; + return Response.json(response, { status: 429 }); + } + } + + const existingEmailQuery = "SELECT id FROM users WHERE email = ? LIMIT 1"; + const existingEmailResult = (await cassandra.execute(existingEmailQuery, [ + normalizedEmail, + ])) as { rows: Array<{ id: string }> }; + + if (existingEmailResult.rows.length > 0) { + const response: EmailChangeResponse = { + code: 409, + success: false, + error: "Email already exists", + }; + return Response.json(response, { status: 409 }); + } + + const verificationToken = Bun.randomUUIDv7(); + const emailChangeKey = `email-change:${verificationToken}`; + const now = Date.now(); + + try { + const emailChangeData: EmailChangeData = { + userId: currentUser.id, + currentEmail: currentUser.email, + newEmail: normalizedEmail, + requestedAt: now, + }; + + await redis.set( + emailChangeKey, + JSON.stringify(emailChangeData), + "EX", + emailUpdateTimes.tokenExpiryHours * 60 * 60, + ); + + await redis.set( + cooldownKey, + now.toString(), + "EX", + emailUpdateTimes.coolDownMinutes * 60, + ); + + // send verification email to NEW email + const emailVariables = { + subject: `Email Change Verification - ${extraValues.companyName}`, + companyName: extraValues.companyName, + id: currentUser.id, + displayName: currentUser.display_name || currentUser.username, + currentEmail: currentUser.email, + newEmail: normalizedEmail, + willExpire: `This link will expire in ${emailUpdateTimes.tokenExpiryHours} hours`, + verificationUrl: `${environment.frontendFqdn}/user/email?token=${verificationToken}`, + supportEmail: extraValues.supportEmail, + }; + + await mailerService.sendTemplateEmail( + normalizedEmail, + `Email Change Verification - ${extraValues.companyName}`, + "email-change-verification", + emailVariables, + ); + + // send notification email to OLD email about the change request + const notificationVariables = { + subject: `Email Change Request - ${extraValues.companyName}`, + companyName: extraValues.companyName, + displayName: currentUser.display_name || currentUser.username, + id: currentUser.id, + currentEmail: currentUser.email, + newEmail: normalizedEmail, + requestTime: new Date().toLocaleString(), + willExpire: `This request will expire in ${emailUpdateTimes.tokenExpiryHours} hours`, + supportEmail: extraValues.supportEmail, + }; + + try { + await mailerService.sendTemplateEmail( + currentUser.email, + `Email Change Request - ${extraValues.companyName}`, + "email-change-request-notification", + notificationVariables, + ); + } catch (notificationError) { + echo.warn({ + message: "Failed to send email change notification to old address", + error: notificationError, + userId: currentUser.id, + currentEmail: currentUser.email, + }); + } + + const response: EmailChangeResponse = { + code: 200, + success: true, + message: `Email change verification sent to ${normalizedEmail}. Please check your new email to confirm the change. A notification has also been sent to your current email.`, + }; + return Response.json(response, { status: 200 }); + } catch (error) { + await redis.del(emailChangeKey).catch(() => {}); + await redis.del(cooldownKey).catch(() => {}); + + echo.error({ + message: "Failed to send email change verification", + error, + userId: currentUser.id, + currentEmail: currentUser.email, + newEmail: normalizedEmail, + }); + + const response: EmailChangeResponse = { + code: 500, + success: false, + error: "Failed to send verification email. Please try again.", + }; + return Response.json(response, { status: 500 }); + } +} + +async function handleEmailVerification( + request: ExtendedRequest, + session: UserSession, +): Promise { + const { token } = request.query; + + if (!token || typeof token !== "string" || token.trim() === "") { + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "Email change verification token is required", + }; + return Response.json(response, { status: 400 }); + } + + const emailChangeKey = `email-change:${token}`; + const emailChangeDataRaw = await redis.get(emailChangeKey); + + if (!emailChangeDataRaw) { + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "Invalid or expired email change token", + }; + return Response.json(response, { status: 400 }); + } + + let emailChangeData: EmailChangeData; + try { + emailChangeData = JSON.parse(emailChangeDataRaw); + } catch { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "Invalid email change token format", + }; + return Response.json(response, { status: 400 }); + } + + if ( + !emailChangeData.userId || + !emailChangeData.currentEmail || + !emailChangeData.newEmail + ) { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 400, + success: false, + error: "Invalid email change 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, [ + emailChangeData.userId, + ])) as { rows: UserRow[] }; + + if (!userResult?.rows || userResult.rows.length === 0) { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + const user = userResult.rows[0]; + if (!user) { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + if (user.email !== emailChangeData.currentEmail) { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 400, + success: false, + error: + "Email change token is no longer valid - current email has changed", + }; + return Response.json(response, { status: 400 }); + } + + const existingEmailQuery = "SELECT id FROM users WHERE email = ? LIMIT 1"; + const existingEmailResult = (await cassandra.execute(existingEmailQuery, [ + emailChangeData.newEmail, + ])) as { rows: Array<{ id: string }> }; + + if ( + existingEmailResult.rows.length > 0 && + existingEmailResult.rows[0]?.id !== user.id + ) { + await redis.del(emailChangeKey); + + const response: EmailChangeResponse = { + code: 409, + success: false, + error: "New email address is no longer available", + }; + return Response.json(response, { status: 409 }); + } + + const updateQuery = ` + UPDATE users + SET email = ?, is_verified = ?, updated_at = ? + WHERE id = ? + `; + await cassandra.execute(updateQuery, [ + emailChangeData.newEmail, + true, + new Date(), + user.id, + ]); + + await redis.del(emailChangeKey); + + try { + const changeNotificationVariables = { + subject: `Email Address Changed - ${extraValues.companyName}`, + companyName: extraValues.companyName, + displayName: user.display_name || user.username, + id: user.id, + oldEmail: emailChangeData.currentEmail, + newEmail: emailChangeData.newEmail, + changeTime: new Date().toLocaleString(), + supportEmail: extraValues.supportEmail, + }; + + await mailerService.sendTemplateEmail( + emailChangeData.currentEmail, + `Email Address Changed - ${extraValues.companyName}`, + "email-change-completed-notification", + changeNotificationVariables, + ); + } catch (notificationError) { + echo.warn({ + message: + "Failed to send email change completion notification to old address", + error: notificationError, + userId: user.id, + oldEmail: emailChangeData.currentEmail, + newEmail: emailChangeData.newEmail, + }); + } + + const updatedUserResult = (await cassandra.execute(userQuery, [user.id])) as { + rows: UserRow[]; + }; + + const updatedUser = updatedUserResult.rows[0]; + if (!updatedUser) { + const response: EmailChangeResponse = { + code: 500, + success: false, + error: "Failed to fetch updated user data", + }; + return Response.json(response, { status: 500 }); + } + + 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 change", + 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: EmailChangeResponse = { + code: 200, + success: true, + message: `Email successfully changed to ${updatedUser.email} and verified.`, + user: responseUser, + }; + + return Response.json(response, { + status: 200, + headers: sessionCookie ? { "Set-Cookie": sessionCookie } : {}, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/user/update/info.ts b/src/routes/user/update/info.ts index fbaeb1f..207535a 100644 --- a/src/routes/user/update/info.ts +++ b/src/routes/user/update/info.ts @@ -1,11 +1,7 @@ import { echo } from "@atums/echo"; import { sessionManager } from "#lib/auth"; import { cassandra } from "#lib/database"; -import { - isValidDisplayName, - isValidEmail, - isValidUsername, -} from "#lib/validation"; +import { isValidDisplayName, isValidUsername } from "#lib/validation"; import type { ExtendedRequest, @@ -28,7 +24,7 @@ async function handler( requestBody: unknown, ): Promise { try { - const session = await sessionManager.getSession(request); + const { session } = request; if (!session) { const response: UpdateInfoResponse = { @@ -39,18 +35,13 @@ async function handler( return Response.json(response, { status: 401 }); } - const { username, displayName, email } = requestBody as UpdateInfoRequest; + const { username, displayName } = requestBody as UpdateInfoRequest; - if ( - username === undefined && - displayName === undefined && - email === undefined - ) { + if (username === undefined && displayName === undefined) { const response: UpdateInfoResponse = { code: 400, success: false, - error: - "At least one field must be provided (username, displayName, email)", + error: "At least one field must be provided (username, displayName)", }; return Response.json(response, { status: 400 }); } @@ -88,7 +79,6 @@ async function handler( const updates: { username?: string; displayName?: string | null; - email?: string; } = {}; if (username !== undefined) { @@ -143,43 +133,6 @@ async function handler( } } - if (email !== undefined) { - const emailValidation = isValidEmail(email); - if (!emailValidation.valid) { - const response: UpdateInfoResponse = { - code: 400, - success: false, - error: emailValidation.error || "Invalid email", - }; - return Response.json(response, { status: 400 }); - } - - const normalizedEmail = email.trim().toLowerCase(); - - if (normalizedEmail !== currentUser.email) { - const existingEmailQuery = - "SELECT id FROM users WHERE email = ? LIMIT 1"; - const existingEmailResult = (await cassandra.execute( - existingEmailQuery, - [normalizedEmail], - )) as { rows: Array<{ id: string }> }; - - if ( - existingEmailResult.rows.length > 0 && - existingEmailResult.rows[0]?.id !== session.id - ) { - const response: UpdateInfoResponse = { - code: 409, - success: false, - error: "Email already exists", - }; - return Response.json(response, { status: 409 }); - } - - updates.email = normalizedEmail; - } - } - if (Object.keys(updates).length === 0) { const response: UpdateInfoResponse = { code: 200, @@ -210,16 +163,8 @@ async function handler( updateValues.push(updates.displayName); } - if (updates.email !== undefined) { - updateFields.push("email = ?"); - updateValues.push(updates.email); - updateFields.push("is_verified = ?"); - updateValues.push(false); - } - updateFields.push("updated_at = ?"); updateValues.push(new Date()); - updateValues.push(session.id); const updateQuery = ` @@ -244,47 +189,22 @@ async function handler( return Response.json(response, { status: 500 }); } - if (Object.keys(updates).length > 0) { - 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(), - }; + 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(), + }; - const sessionCookie = await sessionManager.updateSession( - request, - updatedSessionPayload, - userAgent, - ); - - 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: UpdateInfoResponse = { - code: 200, - success: true, - message: "User information updated successfully", - user: responseUser, - }; - - return Response.json(response, { - status: 200, - headers: { - "Set-Cookie": sessionCookie, - }, - }); - } + const sessionCookie = await sessionManager.updateSession( + request, + updatedSessionPayload, + userAgent, + ); const responseUser: UserResponse = { id: updatedUser.id, @@ -302,7 +222,12 @@ async function handler( user: responseUser, }; - return Response.json(response, { status: 200 }); + return Response.json(response, { + status: 200, + headers: { + "Set-Cookie": sessionCookie, + }, + }); } catch (error) { echo.error({ message: "Error updating user information", diff --git a/src/routes/user/update/password.ts b/src/routes/user/update/password.ts index e7b6fa4..e8f009e 100644 --- a/src/routes/user/update/password.ts +++ b/src/routes/user/update/password.ts @@ -23,7 +23,7 @@ async function handler( requestBody: unknown, ): Promise { try { - const session = await sessionManager.getSession(request); + const { session } = request; if (!session) { const response: UpdatePasswordResponse = { diff --git a/src/routes/user/verify.ts b/src/routes/user/verify.ts index eac0019..5cf1b28 100644 --- a/src/routes/user/verify.ts +++ b/src/routes/user/verify.ts @@ -175,7 +175,7 @@ async function handler(request: ExtendedRequest): Promise { return Response.json(response, { status: 500 }); } - const session = await sessionManager.getSession(request); + const { session } = request; let sessionCookie: string | undefined; if (session && session.id === user.id) { diff --git a/src/server.ts b/src/server.ts index b76237e..6e3980a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { type Server, } from "bun"; +import { sessionManager } from "#lib/auth"; import type { ExtendedRequest, RouteModule } from "#types/server"; class ServerHandler { @@ -249,6 +250,8 @@ class ServerHandler { extendedRequest.params = params; extendedRequest.query = query; + extendedRequest.session = await sessionManager.getSession(request); + response = await routeModule.handler( extendedRequest, requestBody, diff --git a/types/server/requests/user/update/email.ts b/types/server/requests/user/update/email.ts new file mode 100644 index 0000000..aba8168 --- /dev/null +++ b/types/server/requests/user/update/email.ts @@ -0,0 +1,23 @@ +import type { UserResponse } from "../base"; + +interface EmailChangeRequest { + newEmail: string; +} + +interface EmailChangeData { + userId: string; + currentEmail: string; + newEmail: string; + requestedAt: number; +} + +interface EmailChangeResponse { + code: number; + success: boolean; + error?: string; + message?: string; + user?: UserResponse; + cooldownRemaining?: number; +} + +export type { EmailChangeRequest, EmailChangeData, EmailChangeResponse }; diff --git a/types/server/requests/user/update/index.ts b/types/server/requests/user/update/index.ts index 24c894c..56d0cc5 100644 --- a/types/server/requests/user/update/index.ts +++ b/types/server/requests/user/update/index.ts @@ -1,2 +1,3 @@ export * from "./info"; export * from "./password"; +export * from "./email"; diff --git a/types/server/requests/user/update/info.ts b/types/server/requests/user/update/info.ts index 3429cfc..1531e0a 100644 --- a/types/server/requests/user/update/info.ts +++ b/types/server/requests/user/update/info.ts @@ -4,7 +4,6 @@ import type { UserResponse } from "../base"; interface UpdateInfoRequest { username?: string; displayName?: string | null; - email?: string; } interface UpdateInfoResponse extends BaseResponse { diff --git a/types/server/server.ts b/types/server/server.ts index 3eb18d0..9845ed8 100644 --- a/types/server/server.ts +++ b/types/server/server.ts @@ -1,3 +1,5 @@ +import type { UserSession } from "#types/config"; + type Query = Record; type Params = Record; @@ -5,6 +7,7 @@ interface ExtendedRequest extends Request { startPerf: number; query: Query; params: Params; + session?: UserSession | null; } export type { ExtendedRequest, Query, Params };