move the database and mailer to own log sub dir, add all email change logic

This commit is contained in:
creations 2025-06-13 20:33:29 -04:00
parent ddd00e3f85
commit a783a0e663
Signed by: creations
GPG key ID: 8F553AA4320FC711
26 changed files with 808 additions and 225 deletions

View file

@ -5,15 +5,15 @@
"name": "void.backend", "name": "void.backend",
"dependencies": { "dependencies": {
"@atums/echo": "latest", "@atums/echo": "latest",
"@types/nodemailer": "^6.4.17",
"cassandra-driver": "latest", "cassandra-driver": "latest",
"fast-jwt": "latest", "fast-jwt": "latest",
"nodemailer": "^7.0.3", "nodemailer": "latest",
"pika-id": "latest", "pika-id": "latest",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/bun": "latest", "@types/bun": "latest",
"@types/nodemailer": "latest",
}, },
}, },
}, },
@ -21,7 +21,7 @@
"@biomejs/biome", "@biomejs/biome",
], ],
"packages": { "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=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="],

View file

@ -12,14 +12,14 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/bun": "latest" "@types/bun": "latest",
"@types/nodemailer": "latest"
}, },
"dependencies": { "dependencies": {
"@atums/echo": "latest", "@atums/echo": "latest",
"@types/nodemailer": "^6.4.17",
"cassandra-driver": "latest", "cassandra-driver": "latest",
"fast-jwt": "latest", "fast-jwt": "latest",
"nodemailer": "^7.0.3", "nodemailer": "latest",
"pika-id": "latest" "pika-id": "latest"
}, },
"trustedDependencies": ["@biomejs/biome"] "trustedDependencies": ["@biomejs/biome"]

View file

@ -31,3 +31,4 @@ export * from "./server";
export * from "./validation"; export * from "./validation";
export * from "./database"; export * from "./database";
export * from "./mailer"; export * from "./mailer";
export * from "./user";

View file

@ -0,0 +1 @@
export * from "./update";

View file

@ -0,0 +1,6 @@
const emailUpdateTimes = {
coolDownMinutes: 5,
tokenExpiryHours: 3,
};
export { emailUpdateTimes };

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
a {
color: #0066cc;
}
</style>
</head>
<body>
<h1>Email Address Changed - {{companyName}}</h1>
<p>Hi {{displayName}},</p>
<p><strong>Your email address has been successfully changed.</strong></p>
<p><strong>Change Details:</strong></p>
<p>Previous Email: {{oldEmail}} (this address)<br>
New Email: {{newEmail}}<br>
Changed On: {{changeTime}}</p>
<p><strong>Important:</strong> You will no longer receive account emails at this address ({{oldEmail}}). All future communications will be sent to {{newEmail}}.</p>
<p><strong>For future logins, please use:</strong></p>
<p>Email: {{newEmail}}<br>
Password: (unchanged)</p>
<p><strong>If this change was not authorized by you:</strong> Contact our support team immediately at {{supportEmail}}. Your account may have been compromised and we will help you recover it.</p>
<hr>
<p><small>User ID: {{id}} | {{companyName}}</small></p>
</body>
</html>

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
a {
color: #0066cc;
}
</style>
</head>
<body>
<h1>Email Change Request - {{companyName}}</h1>
<p>Hi {{displayName}},</p>
<p><strong>Security Notice:</strong> Someone requested to change your account email address.</p>
<p><strong>Change Details:</strong></p>
<p>Current Email: {{currentEmail}}<br>
Requested New Email: {{newEmail}}<br>
Request Time: {{requestTime}}</p>
<p>A verification email has been sent to <strong>{{newEmail}}</strong>.</p>
<p>{{willExpire}} if not completed.</p>
<p><strong>If this was you:</strong> Check your new email ({{newEmail}}) and click the verification link to complete the change.</p>
<p><strong>If this was NOT you:</strong> Your account may be compromised. Please change your password immediately and contact our support team at {{supportEmail}}.</p>
<p>Questions? Contact {{supportEmail}}</p>
<hr>
<p><small>User ID: {{id}} | {{companyName}}</small></p>
</body>
</html>

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
.verify-button {
display: inline-block;
background-color: #0066cc;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 3px;
margin: 15px 0;
}
</style>
</head>
<body>
<h1>Email Change Verification - {{companyName}}</h1>
<p>Hi {{displayName}},</p>
<p>You requested to change your email address from <strong>{{currentEmail}}</strong> to <strong>{{newEmail}}</strong>.</p>
<p>To complete this email change, please click the button below:</p>
<a href="{{verificationUrl}}" class="verify-button">Verify Email Change</a>
<p>{{willExpire}} for security reasons.</p>
<p><strong>Important:</strong> After verification, your account email will be changed to this address and you'll need to use it for future logins.</p>
<p>If you did not request this email change, contact {{supportEmail}} immediately.</p>
<p>Questions? Contact {{supportEmail}}</p>
<hr>
<p><small>User ID: {{id}} | {{companyName}}</small></p>
</body>
</html>

View file

@ -1,6 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<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">
@ -13,35 +12,28 @@
padding: 20px; padding: 20px;
line-height: 1.5; line-height: 1.5;
} }
.reset-button {
a { display: inline-block;
color: #0066cc; background-color: #0066cc;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 3px;
margin: 15px 0;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>Password Reset - {{companyName}}</h1> <h1>Password Reset - {{companyName}}</h1>
<p>Hi {{displayName}},</p> <p>Hi {{displayName}},</p>
<p>You requested a password reset for your account. Click the button below to reset your password:</p>
<p>You requested a password reset for your account. Click the link below to reset your password:</p> <a href="{{resetUrl}}" class="reset-button">Reset Password</a>
<p><a href="{{resetUrl}}">{{resetUrl}}</a></p>
<p>{{willExpire}} for security reasons.</p> <p>{{willExpire}} for security reasons.</p>
<p>If you did not request this password reset, please contact {{supportEmail}} immediately. Your password will remain unchanged.</p>
<p>If you did not request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>Questions? Contact {{supportEmail}}</p> <p>Questions? Contact {{supportEmail}}</p>
<p>Best regards,<br>
The {{companyName}} team</p>
<hr> <hr>
<p><small>User ID: {{id}} | {{companyName}}</small></p>
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
</body> </body>
</html> </html>

View file

@ -1,6 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<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">
@ -13,33 +12,29 @@
padding: 20px; padding: 20px;
line-height: 1.5; line-height: 1.5;
} }
.verify-button {
a { display: inline-block;
color: #0066cc; background-color: #0066cc;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 3px;
margin: 15px 0;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>Welcome to {{companyName}}!</h1> <h1>Welcome to {{companyName}}!</h1>
<p>Hi {{displayName}},</p> <p>Hi {{displayName}},</p>
<p>Please verify your email address:</p> <p>Please verify your email address:</p>
<p><a href="{{verificationUrl}}">{{verificationUrl}}</a></p> <a href="{{verificationUrl}}" class="verify-button">Verify Email</a>
<p>{{willExpire}} for security reasons.</p> <p>{{willExpire}} for security reasons.</p>
<p>Questions? Contact {{supportEmail}}</p> <p>Questions? Contact {{supportEmail}}</p>
<p>Best regards,<br> <p>Best regards,<br>
The {{companyName}} team</p> The {{companyName}} team</p>
<hr> <hr>
<p><small>User ID: {{id}} | {{companyName}}</small></p>
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
</body> </body>
</html> </html>

View file

@ -1,6 +1,5 @@
import { echo } from "@atums/echo"; import { Echo } from "@atums/echo";
import { cassandraConfig as config } from "#environment/database"; import { cassandraConfig as config } from "#environment/database";
import { noFileLog } from "#index";
import { import {
Client, Client,
@ -15,8 +14,10 @@ class CassandraService {
private static instance: Client | null = null; private static instance: Client | null = null;
private static isConnecting = false; private static isConnecting = false;
private static connectionPromise: Promise<void> | null = null; private static connectionPromise: Promise<void> | 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 { public static getClient(): Client {
if (!CassandraService.instance) { if (!CassandraService.instance) {
@ -96,7 +97,7 @@ class CassandraService {
const clientOptions = CassandraService.buildClientOptions(options); const clientOptions = CassandraService.buildClientOptions(options);
if (options.logging !== false) { if (options.logging !== false) {
noFileLog.info({ CassandraService.loggerNoFile.info({
message: "Connecting to Cassandra...", message: "Connecting to Cassandra...",
contactPoints: config.contactPoints, contactPoints: config.contactPoints,
datacenter: config.datacenter, datacenter: config.datacenter,
@ -114,7 +115,7 @@ class CassandraService {
const hostCount = hosts.length; const hostCount = hosts.length;
if (options.logging !== false) { if (options.logging !== false) {
noFileLog.info( CassandraService.loggerNoFile.info(
`Connected to Cassandra successfully. Active hosts: ${hostCount}`, `Connected to Cassandra successfully. Active hosts: ${hostCount}`,
); );
} }
@ -126,16 +127,23 @@ class CassandraService {
"log", "log",
(level: string, className: string, message: string) => { (level: string, className: string, message: string) => {
if (level === "error") { if (level === "error") {
echo.error(`Cassandra ${className}: ${message}`); CassandraService.logger.error(
`Cassandra ${className}: ${message}`,
);
} else if (level === "warning") { } else if (level === "warning") {
echo.warn(`Cassandra ${className}: ${message}`); CassandraService.logger.warn(
`Cassandra ${className}: ${message}`,
);
} }
}, },
); );
} }
} catch (error) { } catch (error) {
echo.error({ message: "Failed to connect to Cassandra:", error }); CassandraService.logger.error({
await client.shutdown().catch(() => {}); message: "Failed to connect to Cassandra:",
error,
});
await client.shutdown().catch(() => { });
throw error; throw error;
} }
} }
@ -157,9 +165,11 @@ class CassandraService {
try { try {
await client.execute(query); await client.execute(query);
noFileLog.debug(`Keyspace '${config.keyspace}' ensured to exist`); CassandraService.loggerNoFile.debug(
`Keyspace '${config.keyspace}' ensured to exist`,
);
} catch (error) { } catch (error) {
echo.error({ CassandraService.logger.error({
message: `Failed to create keyspace '${config.keyspace}':`, message: `Failed to create keyspace '${config.keyspace}':`,
error, error,
}); });
@ -178,7 +188,7 @@ class CassandraService {
const result = await client.execute(query, params, options); const result = await client.execute(query, params, options);
return result; return result;
} catch (error) { } catch (error) {
echo.error({ CassandraService.logger.error({
message: "Cassandra query failed:", message: "Cassandra query failed:",
query: query.substring(0, 100) + (query.length > 100 ? "..." : ""), query: query.substring(0, 100) + (query.length > 100 ? "..." : ""),
error, error,
@ -192,10 +202,15 @@ class CassandraService {
try { try {
await CassandraService.instance.shutdown(); await CassandraService.instance.shutdown();
if (!disableLogging) { if (!disableLogging) {
noFileLog.info("Cassandra client shut down gracefully"); CassandraService.loggerNoFile.info(
"Cassandra client shut down gracefully",
);
} }
} catch (error) { } catch (error) {
echo.error({ message: "Error during Cassandra shutdown:", error }); CassandraService.logger.error({
message: "Error during Cassandra shutdown:",
error,
});
} finally { } finally {
CassandraService.instance = null; CassandraService.instance = null;
} }
@ -242,11 +257,11 @@ class CassandraService {
}); });
if (tableNames.length > 0) { if (tableNames.length > 0) {
noFileLog.warn( CassandraService.loggerNoFile.warn(
`About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`, `About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`,
); );
} else { } else {
noFileLog.info( CassandraService.loggerNoFile.info(
`Keyspace '${config.keyspace}' is empty or doesn't exist`, `Keyspace '${config.keyspace}' is empty or doesn't exist`,
); );
} }
@ -254,9 +269,11 @@ class CassandraService {
const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`; const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`;
await client.execute(dropQuery); await client.execute(dropQuery);
noFileLog.info(`Keyspace '${config.keyspace}' dropped successfully`); CassandraService.loggerNoFile.info(
`Keyspace '${config.keyspace}' dropped successfully`,
);
} catch (error) { } catch (error) {
echo.error({ CassandraService.logger.error({
message: `Failed to drop keyspace '${config.keyspace}':`, message: `Failed to drop keyspace '${config.keyspace}':`,
error, error,
}); });
@ -267,7 +284,7 @@ class CassandraService {
try { try {
await CassandraService.shutdown(true); await CassandraService.shutdown(true);
} catch (shutdownError) { } catch (shutdownError) {
noFileLog.warn({ CassandraService.loggerNoFile.warn({
message: "Error during shutdown after drop:", message: "Error during shutdown after drop:",
error: shutdownError, error: shutdownError,
}); });
@ -278,7 +295,9 @@ class CassandraService {
CassandraService.isConnecting = false; CassandraService.isConnecting = false;
CassandraService.connectionPromise = null; 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<void> { public static async resetDatabase(): Promise<void> {
@ -287,7 +306,7 @@ class CassandraService {
"Reset operation is only allowed in development environment", "Reset operation is only allowed in development environment",
); );
noFileLog.info("Starting database reset..."); CassandraService.loggerNoFile.info("Starting database reset...");
await CassandraService.dropEverything(); await CassandraService.dropEverything();
@ -295,7 +314,7 @@ class CassandraService {
await CassandraService.createKeyspaceIfNotExists(); await CassandraService.createKeyspaceIfNotExists();
await CassandraService.shutdown(true); await CassandraService.shutdown(true);
noFileLog.info( CassandraService.loggerNoFile.info(
"Database reset complete. Restart your application to run migrations.", "Database reset complete. Restart your application to run migrations.",
); );
} }

View file

@ -1,15 +1,16 @@
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { echo } from "@atums/echo"; import { Echo } from "@atums/echo";
import { environment } from "#environment/config"; import { environment } from "#environment/config";
import { migrationsPath } from "#environment/constants"; import { migrationsPath } from "#environment/constants";
import { noFileLog } from "#index";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import type { SqlMigration } from "#types/config"; import type { SqlMigration } from "#types/config";
class MigrationRunner { class MigrationRunner {
private migrations: SqlMigration[] = []; private migrations: SqlMigration[] = [];
private static logger: Echo = new Echo({ subDirectory: "migrations" });
private static loggerNoFile: Echo = new Echo({ disableFile: true });
async loadMigrations(): Promise<void> { async loadMigrations(): Promise<void> {
try { try {
@ -28,7 +29,7 @@ class MigrationRunner {
const name = nameParts.join("_") || "migration"; const name = nameParts.join("_") || "migration";
if (!id || id.trim() === "") { if (!id || id.trim() === "") {
noFileLog.debug( MigrationRunner.loggerNoFile.debug(
`Skipping migration file with invalid ID: ${sqlFile}`, `Skipping migration file with invalid ID: ${sqlFile}`,
); );
continue; continue;
@ -50,16 +51,18 @@ class MigrationRunner {
...(downSql && { downSql: downSql.trim() }), ...(downSql && { downSql: downSql.trim() }),
}); });
} catch (error) { } catch (error) {
echo.error({ MigrationRunner.logger.error({
message: `Failed to load migration ${sqlFile}:`, message: `Failed to load migration ${sqlFile}:`,
error, error,
}); });
} }
} }
noFileLog.debug(`Loaded ${this.migrations.length} migrations`); MigrationRunner.loggerNoFile.debug(
`Loaded ${this.migrations.length} migrations`,
);
} catch (error) { } catch (error) {
noFileLog.debug({ MigrationRunner.loggerNoFile.debug({
message: "No migrations directory found or error reading:", message: "No migrations directory found or error reading:",
error, error,
}); });
@ -76,7 +79,7 @@ class MigrationRunner {
) )
`; `;
await cassandra.execute(query); await cassandra.execute(query);
noFileLog.debug("Schema migrations table ready"); MigrationRunner.loggerNoFile.debug("Schema migrations table ready");
} }
private async getExecutedMigrations(): Promise<Set<string>> { private async getExecutedMigrations(): Promise<Set<string>> {
@ -86,7 +89,7 @@ class MigrationRunner {
)) as { rows: Array<{ id: string }> }; )) as { rows: Array<{ id: string }> };
return new Set(result.rows.map((row) => row.id)); return new Set(result.rows.map((row) => row.id));
} catch (error) { } catch (error) {
noFileLog.debug({ MigrationRunner.loggerNoFile.debug({
message: "Could not fetch executed migrations:", message: "Could not fetch executed migrations:",
error, error,
}); });
@ -133,7 +136,7 @@ class MigrationRunner {
async runMigrations(): Promise<void> { async runMigrations(): Promise<void> {
if (this.migrations.length === 0) { if (this.migrations.length === 0) {
noFileLog.debug("No migrations to run"); MigrationRunner.loggerNoFile.debug("No migrations to run");
return; return;
} }
await this.createMigrationsTable(); await this.createMigrationsTable();
@ -142,29 +145,31 @@ class MigrationRunner {
(migration) => !executedMigrations.has(migration.id), (migration) => !executedMigrations.has(migration.id),
); );
if (pendingMigrations.length === 0) { if (pendingMigrations.length === 0) {
noFileLog.debug("All migrations are up to date"); MigrationRunner.loggerNoFile.debug("All migrations are up to date");
return; return;
} }
noFileLog.debug( MigrationRunner.loggerNoFile.debug(
`Running ${pendingMigrations.length} pending migrations...`, `Running ${pendingMigrations.length} pending migrations...`,
); );
for (const migration of pendingMigrations) { for (const migration of pendingMigrations) {
try { try {
noFileLog.debug( MigrationRunner.loggerNoFile.debug(
`Running migration: ${migration.id} - ${migration.name}`, `Running migration: ${migration.id} - ${migration.name}`,
); );
await this.executeSql(migration.upSql); await this.executeSql(migration.upSql);
await this.markMigrationExecuted(migration); await this.markMigrationExecuted(migration);
noFileLog.debug(`Migration ${migration.id} completed`); MigrationRunner.loggerNoFile.debug(
`Migration ${migration.id} completed`,
);
} catch (error) { } catch (error) {
echo.error({ MigrationRunner.logger.error({
message: `Failed to run migration ${migration.id}:`, message: `Failed to run migration ${migration.id}:`,
error, error,
}); });
throw error; throw error;
} }
} }
noFileLog.debug("All migrations completed successfully"); MigrationRunner.loggerNoFile.debug("All migrations completed successfully");
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {

View file

@ -1,9 +1,8 @@
import { readdir } from "node:fs/promises"; import { readdirSync } from "node:fs";
import { echo } from "@atums/echo"; import { Echo } from "@atums/echo";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { templatesPath } from "#environment/constants"; import { templatesPath } from "#environment/constants";
import { mailerConfig } from "#environment/mailer"; import { mailerConfig } from "#environment/mailer";
import { noFileLog } from "#index";
import type { import type {
EmailResult, EmailResult,
@ -13,6 +12,9 @@ import type {
class MailerService { class MailerService {
private transporter: nodemailer.Transporter; private transporter: nodemailer.Transporter;
private templates: string[] = [];
private static logger: Echo = new Echo({ subDirectory: "mailer" });
private static loggerNoFile: Echo = new Echo({ disableFile: true });
constructor() { constructor() {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
@ -33,15 +35,20 @@ class MailerService {
rejectUnauthorized: true, rejectUnauthorized: true,
}, },
} as nodemailer.TransportOptions); } as nodemailer.TransportOptions);
this.populateTemplates();
} }
async verifyConnection(): Promise<boolean> { async verifyConnection(): Promise<boolean> {
try { try {
await this.transporter.verify(); await this.transporter.verify();
noFileLog.info("SMTP connection verified successfully"); MailerService.loggerNoFile.info("SMTP connection verified successfully");
return true; return true;
} catch (error) { } catch (error) {
echo.error({ message: "SMTP connection verification failed:", error }); MailerService.logger.error({
message: "SMTP connection verification failed:",
error,
});
return false; return false;
} }
} }
@ -50,15 +57,18 @@ class MailerService {
this.transporter.close(); this.transporter.close();
} }
private async listTemplates(): Promise<string[]> { public populateTemplates(): void {
try { try {
const files = await readdir(templatesPath, { recursive: true }); const files = readdirSync(templatesPath, { recursive: true });
return files this.templates = files
.filter((file) => typeof file === "string" && file.endsWith(".html")) .filter((file) => typeof file === "string" && file.endsWith(".html"))
.map((file) => (file as string).replace(/\.html$/, "")); .map((file) => (file as string).replace(/\.html$/, ""));
} catch (error) { } catch (error) {
echo.error({ message: "Failed to list templates:", error }); MailerService.logger.error({
return []; message: "Failed to list templates:",
error,
});
this.templates = [];
} }
} }
@ -66,7 +76,7 @@ class MailerService {
templateName: string, templateName: string,
variables: TemplateVariables, variables: TemplateVariables,
): Promise<string> { ): Promise<string> {
const templates = await this.listTemplates(); const templates = this.templates;
if (!templates.includes(templateName)) { if (!templates.includes(templateName)) {
throw new Error(`Template "${templateName}" not found`); throw new Error(`Template "${templateName}" not found`);
} }
@ -78,34 +88,20 @@ class MailerService {
const file = Bun.file(templatePath); const file = Bun.file(templatePath);
templateContent = await file.text(); templateContent = await file.text();
} catch (error) { } catch (error) {
echo.error({ MailerService.logger.error({
message: `Failed to load template "${templateName}":`, message: `Failed to load template "${templateName}":`,
error, error,
}); });
throw error; throw error;
} }
const rewriter = new HTMLRewriter().on("*", { let processedContent = templateContent;
text(text) { for (const [key, value] of Object.entries(variables)) {
if (text.text) { const regex = new RegExp(`{{${key}}}`, "g");
let modifiedText = text.text; processedContent = processedContent.replace(regex, String(value));
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);
}
}
},
});
const response = new Response(templateContent, { return processedContent;
headers: { "Content-Type": "text/html" },
});
const transformedResponse = rewriter.transform(response);
return await transformedResponse.text();
} }
async sendEmail(options: SendMailOptions): Promise<EmailResult> { async sendEmail(options: SendMailOptions): Promise<EmailResult> {
@ -155,7 +151,7 @@ class MailerService {
const info = await this.transporter.sendMail(mailOptions); const info = await this.transporter.sendMail(mailOptions);
echo.debug({ MailerService.logger.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,
@ -168,7 +164,7 @@ class MailerService {
response: info.response, response: info.response,
}; };
} catch (error) { } catch (error) {
echo.error({ MailerService.logger.error({
message: "Failed to send email:", message: "Failed to send email:",
error, error,
to: options.to, to: options.to,

View file

@ -1,5 +1,4 @@
import { echo } from "@atums/echo"; import { echo } from "@atums/echo";
import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import type { import type {
@ -20,7 +19,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const { id: identifier } = request.params; const { id: identifier } = request.params;
const session = await sessionManager.getSession(request); const { session } = request;
let userQuery: string; let userQuery: string;
let queryParams: string[]; let queryParams: string[];

View file

@ -103,7 +103,6 @@ async function handler(
willExpire: "This link will expire in 1 hour", willExpire: "This link will expire in 1 hour",
resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`, resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`,
supportEmail: extraValues.supportEmail, supportEmail: extraValues.supportEmail,
currentYear: new Date().getFullYear(),
}; };
await mailerService.sendTemplateEmail( await mailerService.sendTemplateEmail(

View file

@ -13,7 +13,7 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const session = await sessionManager.getSession(request); const { session } = request;
if (!session) { if (!session) {
const response: LogoutResponse = { const response: LogoutResponse = {

View file

@ -173,7 +173,6 @@ async function handler(
willExpire: "This link will expire in 3 hours", willExpire: "This link will expire in 3 hours",
verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`, verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`,
supportEmail: extraValues.supportEmail, supportEmail: extraValues.supportEmail,
currentYear: new Date().getFullYear(),
}; };
await mailerService.sendTemplateEmail( await mailerService.sendTemplateEmail(

View file

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

View file

@ -1,11 +1,7 @@
import { echo } from "@atums/echo"; import { echo } from "@atums/echo";
import { sessionManager } from "#lib/auth"; import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import { import { isValidDisplayName, isValidUsername } from "#lib/validation";
isValidDisplayName,
isValidEmail,
isValidUsername,
} from "#lib/validation";
import type { import type {
ExtendedRequest, ExtendedRequest,
@ -28,7 +24,7 @@ async function handler(
requestBody: unknown, requestBody: unknown,
): Promise<Response> { ): Promise<Response> {
try { try {
const session = await sessionManager.getSession(request); const { session } = request;
if (!session) { if (!session) {
const response: UpdateInfoResponse = { const response: UpdateInfoResponse = {
@ -39,18 +35,13 @@ async function handler(
return Response.json(response, { status: 401 }); return Response.json(response, { status: 401 });
} }
const { username, displayName, email } = requestBody as UpdateInfoRequest; const { username, displayName } = requestBody as UpdateInfoRequest;
if ( if (username === undefined && displayName === undefined) {
username === undefined &&
displayName === undefined &&
email === undefined
) {
const response: UpdateInfoResponse = { const response: UpdateInfoResponse = {
code: 400, code: 400,
success: false, success: false,
error: error: "At least one field must be provided (username, displayName)",
"At least one field must be provided (username, displayName, email)",
}; };
return Response.json(response, { status: 400 }); return Response.json(response, { status: 400 });
} }
@ -88,7 +79,6 @@ async function handler(
const updates: { const updates: {
username?: string; username?: string;
displayName?: string | null; displayName?: string | null;
email?: string;
} = {}; } = {};
if (username !== undefined) { 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) { if (Object.keys(updates).length === 0) {
const response: UpdateInfoResponse = { const response: UpdateInfoResponse = {
code: 200, code: 200,
@ -210,16 +163,8 @@ async function handler(
updateValues.push(updates.displayName); 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 = ?"); updateFields.push("updated_at = ?");
updateValues.push(new Date()); updateValues.push(new Date());
updateValues.push(session.id); updateValues.push(session.id);
const updateQuery = ` const updateQuery = `
@ -244,47 +189,22 @@ async function handler(
return Response.json(response, { status: 500 }); return Response.json(response, { status: 500 });
} }
if (Object.keys(updates).length > 0) { const userAgent = request.headers.get("User-Agent") || "Unknown";
const userAgent = request.headers.get("User-Agent") || "Unknown"; const updatedSessionPayload = {
const updatedSessionPayload = { id: updatedUser.id,
id: updatedUser.id, username: updatedUser.username,
username: updatedUser.username, email: updatedUser.email,
email: updatedUser.email, isVerified: updatedUser.is_verified,
isVerified: updatedUser.is_verified, displayName: updatedUser.display_name,
displayName: updatedUser.display_name, createdAt: updatedUser.created_at.toISOString(),
createdAt: updatedUser.created_at.toISOString(), updatedAt: updatedUser.updated_at.toISOString(),
updatedAt: updatedUser.updated_at.toISOString(), };
};
const sessionCookie = await sessionManager.updateSession( const sessionCookie = await sessionManager.updateSession(
request, request,
updatedSessionPayload, updatedSessionPayload,
userAgent, 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 responseUser: UserResponse = { const responseUser: UserResponse = {
id: updatedUser.id, id: updatedUser.id,
@ -302,7 +222,12 @@ async function handler(
user: responseUser, user: responseUser,
}; };
return Response.json(response, { status: 200 }); return Response.json(response, {
status: 200,
headers: {
"Set-Cookie": sessionCookie,
},
});
} catch (error) { } catch (error) {
echo.error({ echo.error({
message: "Error updating user information", message: "Error updating user information",

View file

@ -23,7 +23,7 @@ async function handler(
requestBody: unknown, requestBody: unknown,
): Promise<Response> { ): Promise<Response> {
try { try {
const session = await sessionManager.getSession(request); const { session } = request;
if (!session) { if (!session) {
const response: UpdatePasswordResponse = { const response: UpdatePasswordResponse = {

View file

@ -175,7 +175,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
return Response.json(response, { status: 500 }); return Response.json(response, { status: 500 });
} }
const session = await sessionManager.getSession(request); const { session } = request;
let sessionCookie: string | undefined; let sessionCookie: string | undefined;
if (session && session.id === user.id) { if (session && session.id === user.id) {

View file

@ -12,6 +12,7 @@ import {
type Server, type Server,
} from "bun"; } from "bun";
import { sessionManager } from "#lib/auth";
import type { ExtendedRequest, RouteModule } from "#types/server"; import type { ExtendedRequest, RouteModule } from "#types/server";
class ServerHandler { class ServerHandler {
@ -249,6 +250,8 @@ class ServerHandler {
extendedRequest.params = params; extendedRequest.params = params;
extendedRequest.query = query; extendedRequest.query = query;
extendedRequest.session = await sessionManager.getSession(request);
response = await routeModule.handler( response = await routeModule.handler(
extendedRequest, extendedRequest,
requestBody, requestBody,

View file

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

View file

@ -1,2 +1,3 @@
export * from "./info"; export * from "./info";
export * from "./password"; export * from "./password";
export * from "./email";

View file

@ -4,7 +4,6 @@ import type { UserResponse } from "../base";
interface UpdateInfoRequest { interface UpdateInfoRequest {
username?: string; username?: string;
displayName?: string | null; displayName?: string | null;
email?: string;
} }
interface UpdateInfoResponse extends BaseResponse { interface UpdateInfoResponse extends BaseResponse {

View file

@ -1,3 +1,5 @@
import type { UserSession } from "#types/config";
type Query = Record<string, string>; type Query = Record<string, string>;
type Params = Record<string, string>; type Params = Record<string, string>;
@ -5,6 +7,7 @@ interface ExtendedRequest extends Request {
startPerf: number; startPerf: number;
query: Query; query: Query;
params: Params; params: Params;
session?: UserSession | null;
} }
export type { ExtendedRequest, Query, Params }; export type { ExtendedRequest, Query, Params };