This commit is contained in:
commit
421043c9b5
67 changed files with 3455 additions and 0 deletions
120
src/commands.ts
Normal file
120
src/commands.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { Echo } from "@atums/echo";
|
||||
import { redis } from "bun";
|
||||
import { verifyRequiredVariables } from "#environment/config";
|
||||
import { cassandra } from "#lib/database";
|
||||
|
||||
const echo = new Echo({
|
||||
disableFile: true,
|
||||
});
|
||||
|
||||
async function resetCassandra(): Promise<void> {
|
||||
echo.info("Resetting Cassandra database...");
|
||||
|
||||
try {
|
||||
verifyRequiredVariables();
|
||||
|
||||
await cassandra.connect({ withKeyspace: false, logging: true });
|
||||
|
||||
await cassandra.dropEverything();
|
||||
|
||||
echo.info("Cassandra database reset complete");
|
||||
echo.info(
|
||||
"Restart your server to recreate the database and run migrations",
|
||||
);
|
||||
} catch (error) {
|
||||
echo.error({ message: "Failed to reset Cassandra:", error });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetRedis(): Promise<void> {
|
||||
echo.info("Resetting Redis database...");
|
||||
try {
|
||||
verifyRequiredVariables();
|
||||
|
||||
const keys = await redis.keys("*");
|
||||
|
||||
if (keys.length > 0) {
|
||||
echo.info(`Found ${keys.length} keys to delete`);
|
||||
|
||||
let deletedCount = 0;
|
||||
for (const key of keys) {
|
||||
await redis.del(key);
|
||||
deletedCount++;
|
||||
|
||||
if (deletedCount % 100 === 0) {
|
||||
echo.info(`Deleted ${deletedCount}/${keys.length} keys...`);
|
||||
}
|
||||
}
|
||||
|
||||
echo.info(`Deleted ${deletedCount} keys`);
|
||||
} else {
|
||||
echo.info("No keys found - Redis is already empty");
|
||||
}
|
||||
|
||||
echo.info("Redis database reset complete");
|
||||
echo.info("All Redis data has been cleared");
|
||||
} catch (error) {
|
||||
echo.error({ message: "Failed to reset Redis:", error });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAll(): Promise<void> {
|
||||
echo.info("Resetting all databases...");
|
||||
|
||||
try {
|
||||
await resetCassandra();
|
||||
await resetRedis();
|
||||
|
||||
echo.info("All databases reset complete");
|
||||
} catch (error) {
|
||||
echo.error({ message: "Failed to reset databases:", error });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(): void {
|
||||
echo.info("Available commands:");
|
||||
echo.info(" --reset cassandra Reset Cassandra database (drops keyspace)");
|
||||
echo.info(" --reset redis Reset Redis database (flush all data)");
|
||||
echo.info(" --reset all Reset both databases");
|
||||
echo.info(" --help Show this help message");
|
||||
echo.info("");
|
||||
echo.info("Examples:");
|
||||
echo.info(" bun run src/index.ts --reset cassandra");
|
||||
echo.info(" bun run src/index.ts --reset redis");
|
||||
echo.info(" bun run src/index.ts --reset all");
|
||||
}
|
||||
|
||||
export async function handleCommands(): Promise<boolean> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const resetIndex = args.indexOf("--reset");
|
||||
if (resetIndex !== -1) {
|
||||
const resetTarget = args[resetIndex + 1];
|
||||
|
||||
switch (resetTarget) {
|
||||
case "cassandra":
|
||||
await resetCassandra();
|
||||
return true;
|
||||
case "redis":
|
||||
await resetRedis();
|
||||
return true;
|
||||
case "all":
|
||||
await resetAll();
|
||||
return true;
|
||||
default:
|
||||
echo.error(`Unknown reset target: ${resetTarget}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
showHelp();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
27
src/index.ts
Normal file
27
src/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Echo, echo } from "@atums/echo";
|
||||
import { handleCommands } from "#commands";
|
||||
|
||||
import { verifyRequiredVariables } from "#environment/config";
|
||||
import { migrationRunner } from "#lib/database";
|
||||
import { serverHandler } from "#server";
|
||||
|
||||
const noFileLog = new Echo({
|
||||
disableFile: true,
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const commandHandled = await handleCommands();
|
||||
if (commandHandled) process.exit(0);
|
||||
|
||||
verifyRequiredVariables();
|
||||
|
||||
await migrationRunner.initialize();
|
||||
serverHandler.initialize();
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
echo.error({ message: "Error initializing the server:", error });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export { noFileLog };
|
52
src/lib/auth/cookies.ts
Normal file
52
src/lib/auth/cookies.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { environment } from "#environment/config";
|
||||
import { jwt } from "#environment/jwt";
|
||||
|
||||
import type { CookieOptions } from "#types/config";
|
||||
|
||||
class CookieService {
|
||||
extractToken(request: Request): string | null {
|
||||
return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
|
||||
}
|
||||
|
||||
generateCookie(
|
||||
token: string,
|
||||
maxAge = jwt.expiration,
|
||||
options?: CookieOptions,
|
||||
): string {
|
||||
const {
|
||||
secure = !environment.development,
|
||||
httpOnly = true,
|
||||
sameSite = environment.development ? "Lax" : "None",
|
||||
path = "/",
|
||||
domain,
|
||||
} = options || {};
|
||||
|
||||
let cookie = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`;
|
||||
|
||||
if (httpOnly) cookie += "; HttpOnly";
|
||||
if (secure) cookie += "; Secure";
|
||||
if (sameSite) cookie += `; SameSite=${sameSite}`;
|
||||
if (domain) cookie += `; Domain=${domain}`;
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
clearCookie(options?: Omit<CookieOptions, "httpOnly" | "secure">): string {
|
||||
const {
|
||||
sameSite = environment.development ? "Lax" : "None",
|
||||
path = "/",
|
||||
domain,
|
||||
} = options || {};
|
||||
|
||||
let cookie = `session=; Path=${path}; Max-Age=0; HttpOnly`;
|
||||
|
||||
if (!environment.development) cookie += "; Secure";
|
||||
if (sameSite) cookie += `; SameSite=${sameSite}`;
|
||||
if (domain) cookie += `; Domain=${domain}`;
|
||||
|
||||
return cookie;
|
||||
}
|
||||
}
|
||||
|
||||
const cookieService = new CookieService();
|
||||
export { CookieService, cookieService };
|
3
src/lib/auth/index.ts
Normal file
3
src/lib/auth/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./jwt";
|
||||
export * from "./cookies";
|
||||
export * from "./session";
|
34
src/lib/auth/jwt.ts
Normal file
34
src/lib/auth/jwt.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { createDecoder, createSigner, createVerifier } from "fast-jwt";
|
||||
import { jwt } from "#environment/jwt";
|
||||
|
||||
import type { UserSession } from "#types/config";
|
||||
|
||||
class JWTService {
|
||||
private readonly signer;
|
||||
private readonly verifier;
|
||||
private readonly decoder;
|
||||
|
||||
constructor() {
|
||||
this.signer = createSigner({
|
||||
key: jwt.secret,
|
||||
expiresIn: jwt.expiration,
|
||||
});
|
||||
this.verifier = createVerifier({ key: jwt.secret });
|
||||
this.decoder = createDecoder();
|
||||
}
|
||||
|
||||
sign(payload: UserSession): string {
|
||||
return this.signer(payload);
|
||||
}
|
||||
|
||||
verify(token: string): UserSession {
|
||||
return this.verifier(token);
|
||||
}
|
||||
|
||||
decode(token: string): UserSession {
|
||||
return this.decoder(token);
|
||||
}
|
||||
}
|
||||
|
||||
export const jwtService = new JWTService();
|
||||
export { JWTService };
|
167
src/lib/auth/session.ts
Normal file
167
src/lib/auth/session.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { jwt } from "#environment/jwt";
|
||||
import { cookieService } from "#lib/auth/cookies";
|
||||
import { jwtService } from "#lib/auth/jwt";
|
||||
|
||||
import { redis } from "bun";
|
||||
|
||||
import type { CookieOptions, SessionData, UserSession } from "#types/config";
|
||||
|
||||
class SessionManager {
|
||||
async createSession(
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
cookieOptions?: CookieOptions,
|
||||
): Promise<string> {
|
||||
const token = jwtService.sign(payload);
|
||||
const sessionKey = this.getSessionKey(payload.id, token);
|
||||
const sessionData: SessionData = { ...payload, userAgent };
|
||||
|
||||
await redis.set(sessionKey, JSON.stringify(sessionData));
|
||||
await redis.expire(sessionKey, jwt.expiration as number);
|
||||
|
||||
return cookieService.generateCookie(
|
||||
token,
|
||||
jwt.expiration as number,
|
||||
cookieOptions,
|
||||
);
|
||||
}
|
||||
|
||||
async getSession(request: Request): Promise<UserSession | null> {
|
||||
const token = cookieService.extractToken(request);
|
||||
if (!token) return null;
|
||||
|
||||
return this.getSessionByToken(token);
|
||||
}
|
||||
|
||||
async getSessionByToken(token: string): Promise<UserSession | null> {
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) return null;
|
||||
|
||||
const sessionKey = keys[0];
|
||||
if (!sessionKey) return null;
|
||||
|
||||
const raw = await redis.get(sessionKey);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const sessionData: SessionData = JSON.parse(raw);
|
||||
const { userAgent, ...userSession } = sessionData;
|
||||
return userSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
request: Request,
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
cookieOptions?: CookieOptions,
|
||||
): Promise<string> {
|
||||
const token = cookieService.extractToken(request);
|
||||
if (!token) throw new Error("Session token not found");
|
||||
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) throw new Error("Session not found or expired");
|
||||
|
||||
const sessionKey = keys[0];
|
||||
if (!sessionKey) throw new Error("Session not found or expired");
|
||||
|
||||
const sessionData: SessionData = { ...payload, userAgent };
|
||||
await redis.set(sessionKey, JSON.stringify(sessionData));
|
||||
await redis.expire(sessionKey, jwt.expiration as number);
|
||||
|
||||
return cookieService.generateCookie(
|
||||
token,
|
||||
jwt.expiration as number,
|
||||
cookieOptions,
|
||||
);
|
||||
}
|
||||
|
||||
async refreshSession(
|
||||
request: Request,
|
||||
cookieOptions?: CookieOptions,
|
||||
): Promise<string | null> {
|
||||
const token = cookieService.extractToken(request);
|
||||
if (!token) return null;
|
||||
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) return null;
|
||||
|
||||
const sessionKey = keys[0];
|
||||
if (!sessionKey) return null;
|
||||
|
||||
await redis.expire(sessionKey, jwt.expiration as number);
|
||||
return cookieService.generateCookie(
|
||||
token,
|
||||
jwt.expiration as number,
|
||||
cookieOptions,
|
||||
);
|
||||
}
|
||||
|
||||
async verifySession(token: string): Promise<UserSession> {
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) throw new Error("Session not found or expired");
|
||||
|
||||
return jwtService.verify(token);
|
||||
}
|
||||
|
||||
async decodeSession(token: string): Promise<UserSession> {
|
||||
return jwtService.decode(token);
|
||||
}
|
||||
|
||||
async invalidateSession(request: Request): Promise<void> {
|
||||
const token = cookieService.extractToken(request);
|
||||
if (!token) return;
|
||||
|
||||
await this.invalidateSessionByToken(token);
|
||||
}
|
||||
|
||||
async invalidateSessionByToken(token: string): Promise<void> {
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) return;
|
||||
|
||||
const sessionKey = keys[0];
|
||||
if (!sessionKey) return;
|
||||
|
||||
await redis.del(sessionKey);
|
||||
}
|
||||
|
||||
async invalidateSessionById(sessionId: string): Promise<boolean> {
|
||||
const keys = await redis.keys(`session:*:${sessionId}`);
|
||||
if (!keys.length) return false;
|
||||
|
||||
const sessionKey = keys[0];
|
||||
if (!sessionKey) return false;
|
||||
|
||||
await redis.del(sessionKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async invalidateAllSessionsForUser(userId: string): Promise<number> {
|
||||
const keys = await redis.keys(`session:${userId}:*`);
|
||||
if (keys.length === 0) return 0;
|
||||
|
||||
for (const key of keys) {
|
||||
await redis.del(key);
|
||||
}
|
||||
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
async getActiveSessionsForUser(userId: string): Promise<string[]> {
|
||||
const keys = await redis.keys(`session:${userId}:*`);
|
||||
return keys.flatMap((key) => {
|
||||
const token = key.split(":")[2];
|
||||
return token ? [token] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private getSessionKey(userId: string, token: string): string {
|
||||
return `session:${userId}:${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionManager = new SessionManager();
|
||||
export { SessionManager, sessionManager };
|
304
src/lib/database/cassandra.ts
Normal file
304
src/lib/database/cassandra.ts
Normal file
|
@ -0,0 +1,304 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { cassandraConfig as config } from "#environment/database";
|
||||
import { noFileLog } from "#index";
|
||||
|
||||
import {
|
||||
Client,
|
||||
type DseClientOptions,
|
||||
type QueryOptions,
|
||||
auth,
|
||||
} from "cassandra-driver";
|
||||
import { environment } from "#environment/config";
|
||||
import type { ConnectionOptions } from "#types/config";
|
||||
|
||||
class CassandraService {
|
||||
private static instance: Client | null = null;
|
||||
private static isConnecting = false;
|
||||
private static connectionPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getClient(): Client {
|
||||
if (!CassandraService.instance) {
|
||||
throw new Error(
|
||||
"Cassandra client is not initialized. Call connect() first.",
|
||||
);
|
||||
}
|
||||
return CassandraService.instance;
|
||||
}
|
||||
|
||||
public static isConnected(): boolean {
|
||||
return (
|
||||
CassandraService.instance !== null &&
|
||||
CassandraService.instance.getState().getConnectedHosts().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private static buildClientOptions(
|
||||
options: ConnectionOptions,
|
||||
): DseClientOptions {
|
||||
const { withKeyspace = true, timeout = 30000 } = options;
|
||||
|
||||
const authProvider = config.authEnabled
|
||||
? new auth.PlainTextAuthProvider(config.username, config.password)
|
||||
: undefined;
|
||||
|
||||
const clientOptions: DseClientOptions = {
|
||||
contactPoints: config.contactPoints,
|
||||
localDataCenter: config.datacenter,
|
||||
protocolOptions: {
|
||||
port: config.port,
|
||||
},
|
||||
socketOptions: {
|
||||
connectTimeout: timeout,
|
||||
readTimeout: timeout,
|
||||
},
|
||||
};
|
||||
|
||||
if (authProvider) {
|
||||
clientOptions.authProvider = authProvider;
|
||||
}
|
||||
|
||||
if (withKeyspace && config.keyspace) {
|
||||
clientOptions.keyspace = config.keyspace;
|
||||
}
|
||||
|
||||
return clientOptions;
|
||||
}
|
||||
|
||||
public static async connect(options: ConnectionOptions = {}): Promise<void> {
|
||||
if (
|
||||
CassandraService.instance &&
|
||||
CassandraService.instance.getState().getConnectedHosts().length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (CassandraService.isConnecting && CassandraService.connectionPromise) {
|
||||
return CassandraService.connectionPromise;
|
||||
}
|
||||
|
||||
CassandraService.isConnecting = true;
|
||||
|
||||
try {
|
||||
CassandraService.connectionPromise =
|
||||
CassandraService.performConnection(options);
|
||||
await CassandraService.connectionPromise;
|
||||
} finally {
|
||||
CassandraService.isConnecting = false;
|
||||
CassandraService.connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async performConnection(
|
||||
options: ConnectionOptions,
|
||||
): Promise<void> {
|
||||
const clientOptions = CassandraService.buildClientOptions(options);
|
||||
|
||||
if (options.logging !== false) {
|
||||
noFileLog.info({
|
||||
message: "Connecting to Cassandra...",
|
||||
contactPoints: config.contactPoints,
|
||||
datacenter: config.datacenter,
|
||||
keyspace: clientOptions.keyspace || "none",
|
||||
authEnabled: config.authEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new Client(clientOptions);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const hosts = client.getState().getConnectedHosts();
|
||||
const hostCount = hosts.length;
|
||||
|
||||
if (options.logging !== false) {
|
||||
noFileLog.info(
|
||||
`Connected to Cassandra successfully. Active hosts: ${hostCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
CassandraService.instance = client;
|
||||
|
||||
if (options.logging !== false) {
|
||||
client.on(
|
||||
"log",
|
||||
(level: string, className: string, message: string) => {
|
||||
if (level === "error") {
|
||||
echo.error(`Cassandra ${className}: ${message}`);
|
||||
} else if (level === "warning") {
|
||||
echo.warn(`Cassandra ${className}: ${message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
echo.error({ message: "Failed to connect to Cassandra:", error });
|
||||
await client.shutdown().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async createKeyspaceIfNotExists(): Promise<void> {
|
||||
if (!config.keyspace) {
|
||||
throw new Error("No keyspace configured");
|
||||
}
|
||||
|
||||
const client = CassandraService.getClient();
|
||||
|
||||
const query = `
|
||||
CREATE KEYSPACE IF NOT EXISTS ${config.keyspace}
|
||||
WITH REPLICATION = {
|
||||
'class': 'SimpleStrategy',
|
||||
'replication_factor': 1
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
await client.execute(query);
|
||||
noFileLog.debug(`Keyspace '${config.keyspace}' ensured to exist`);
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Failed to create keyspace '${config.keyspace}':`,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async execute(
|
||||
query: string,
|
||||
params?: unknown[],
|
||||
options?: QueryOptions,
|
||||
): Promise<unknown> {
|
||||
const client = CassandraService.getClient();
|
||||
|
||||
try {
|
||||
const result = await client.execute(query, params, options);
|
||||
return result;
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Cassandra query failed:",
|
||||
query: query.substring(0, 100) + (query.length > 100 ? "..." : ""),
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async shutdown(disableLogging = false): Promise<void> {
|
||||
if (CassandraService.instance) {
|
||||
try {
|
||||
await CassandraService.instance.shutdown();
|
||||
if (!disableLogging) {
|
||||
noFileLog.info("Cassandra client shut down gracefully");
|
||||
}
|
||||
} catch (error) {
|
||||
echo.error({ message: "Error during Cassandra shutdown:", error });
|
||||
} finally {
|
||||
CassandraService.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static getHealthStatus(): {
|
||||
connected: boolean;
|
||||
hosts: number;
|
||||
} {
|
||||
if (!CassandraService.instance) {
|
||||
return { connected: false, hosts: 0 };
|
||||
}
|
||||
|
||||
const hosts = CassandraService.instance.getState().getConnectedHosts();
|
||||
return {
|
||||
connected: hosts.length > 0,
|
||||
hosts: hosts.length,
|
||||
};
|
||||
}
|
||||
|
||||
public static async dropEverything(): Promise<void> {
|
||||
if (!config.keyspace) {
|
||||
throw new Error("No keyspace configured");
|
||||
}
|
||||
|
||||
if (!environment.development)
|
||||
throw new Error(
|
||||
"Drop operation is only allowed in development environment",
|
||||
);
|
||||
|
||||
const client = CassandraService.getClient();
|
||||
|
||||
try {
|
||||
const tablesQuery = `
|
||||
SELECT table_name FROM system_schema.tables
|
||||
WHERE keyspace_name = ?
|
||||
`;
|
||||
const tablesResult = await client.execute(tablesQuery, [config.keyspace]);
|
||||
|
||||
const tableNames = tablesResult.rows.map((row) => {
|
||||
const tableRow = row as unknown as { table_name: string };
|
||||
return tableRow.table_name;
|
||||
});
|
||||
|
||||
if (tableNames.length > 0) {
|
||||
noFileLog.warn(
|
||||
`About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
noFileLog.info(
|
||||
`Keyspace '${config.keyspace}' is empty or doesn't exist`,
|
||||
);
|
||||
}
|
||||
|
||||
const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`;
|
||||
await client.execute(dropQuery);
|
||||
|
||||
noFileLog.info(`Keyspace '${config.keyspace}' dropped successfully`);
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Failed to drop keyspace '${config.keyspace}':`,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (CassandraService.instance) {
|
||||
try {
|
||||
await CassandraService.shutdown(true);
|
||||
} catch (shutdownError) {
|
||||
noFileLog.warn({
|
||||
message: "Error during shutdown after drop:",
|
||||
error: shutdownError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
CassandraService.instance = null;
|
||||
CassandraService.isConnecting = false;
|
||||
CassandraService.connectionPromise = null;
|
||||
|
||||
noFileLog.info("Cassandra client state reset after dropping keyspace");
|
||||
}
|
||||
|
||||
public static async resetDatabase(): Promise<void> {
|
||||
if (!environment.development)
|
||||
throw new Error(
|
||||
"Reset operation is only allowed in development environment",
|
||||
);
|
||||
|
||||
noFileLog.info("Starting database reset...");
|
||||
|
||||
await CassandraService.dropEverything();
|
||||
|
||||
await CassandraService.connect({ withKeyspace: false, logging: true });
|
||||
await CassandraService.createKeyspaceIfNotExists();
|
||||
await CassandraService.shutdown(true);
|
||||
|
||||
noFileLog.info(
|
||||
"Database reset complete. Restart your application to run migrations.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { CassandraService as cassandra };
|
2
src/lib/database/index.ts
Normal file
2
src/lib/database/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./cassandra";
|
||||
export * from "./migrations";
|
183
src/lib/database/migrations.ts
Normal file
183
src/lib/database/migrations.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
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[] = [];
|
||||
|
||||
async loadMigrations(): Promise<void> {
|
||||
try {
|
||||
const upPath = resolve(migrationsPath, "up");
|
||||
const downPath = resolve(migrationsPath, "down");
|
||||
|
||||
const upFiles = await readdir(upPath);
|
||||
const sqlFiles = upFiles.filter((file) => file.endsWith(".sql")).sort();
|
||||
|
||||
for (const sqlFile of sqlFiles) {
|
||||
try {
|
||||
const baseName = sqlFile.replace(".sql", "");
|
||||
const parts = baseName.split("_");
|
||||
const id = parts[0];
|
||||
const nameParts = parts.slice(1);
|
||||
const name = nameParts.join("_") || "migration";
|
||||
|
||||
if (!id || id.trim() === "") {
|
||||
noFileLog.debug(
|
||||
`Skipping migration file with invalid ID: ${sqlFile}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const upSql = await readFile(resolve(upPath, sqlFile), "utf-8");
|
||||
let downSql: string | undefined;
|
||||
|
||||
try {
|
||||
downSql = await readFile(resolve(downPath, sqlFile), "utf-8");
|
||||
} catch {
|
||||
// down is optional
|
||||
}
|
||||
|
||||
this.migrations.push({
|
||||
id,
|
||||
name,
|
||||
upSql: upSql.trim(),
|
||||
...(downSql && { downSql: downSql.trim() }),
|
||||
});
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Failed to load migration ${sqlFile}:`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
noFileLog.debug(`Loaded ${this.migrations.length} migrations`);
|
||||
} catch (error) {
|
||||
noFileLog.debug({
|
||||
message: "No migrations directory found or error reading:",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createMigrationsTable(): Promise<void> {
|
||||
const query = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
executed_at TIMESTAMP,
|
||||
checksum TEXT
|
||||
)
|
||||
`;
|
||||
await cassandra.execute(query);
|
||||
noFileLog.debug("Schema migrations table ready");
|
||||
}
|
||||
|
||||
private async getExecutedMigrations(): Promise<Set<string>> {
|
||||
try {
|
||||
const result = (await cassandra.execute(
|
||||
"SELECT id FROM schema_migrations",
|
||||
)) as { rows: Array<{ id: string }> };
|
||||
return new Set(result.rows.map((row) => row.id));
|
||||
} catch (error) {
|
||||
noFileLog.debug({
|
||||
message: "Could not fetch executed migrations:",
|
||||
error,
|
||||
});
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
private async markMigrationExecuted(migration: SqlMigration): Promise<void> {
|
||||
const query = `
|
||||
INSERT INTO schema_migrations (id, name, executed_at, checksum)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`;
|
||||
const checksum = this.generateChecksum(migration.upSql);
|
||||
await cassandra.execute(query, [
|
||||
migration.id,
|
||||
migration.name,
|
||||
new Date(),
|
||||
checksum,
|
||||
]);
|
||||
}
|
||||
|
||||
private generateChecksum(input: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
private async executeSql(sql: string): Promise<void> {
|
||||
const statements = sql
|
||||
.split(";")
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter((stmt) => stmt.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await cassandra.execute(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
if (this.migrations.length === 0) {
|
||||
noFileLog.debug("No migrations to run");
|
||||
return;
|
||||
}
|
||||
await this.createMigrationsTable();
|
||||
const executedMigrations = await this.getExecutedMigrations();
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => !executedMigrations.has(migration.id),
|
||||
);
|
||||
if (pendingMigrations.length === 0) {
|
||||
noFileLog.debug("All migrations are up to date");
|
||||
return;
|
||||
}
|
||||
noFileLog.debug(
|
||||
`Running ${pendingMigrations.length} pending migrations...`,
|
||||
);
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
noFileLog.debug(
|
||||
`Running migration: ${migration.id} - ${migration.name}`,
|
||||
);
|
||||
await this.executeSql(migration.upSql);
|
||||
await this.markMigrationExecuted(migration);
|
||||
noFileLog.debug(`Migration ${migration.id} completed`);
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Failed to run migration ${migration.id}:`,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
noFileLog.debug("All migrations completed successfully");
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await cassandra.connect({
|
||||
withKeyspace: false,
|
||||
logging: environment.development,
|
||||
});
|
||||
await cassandra.createKeyspaceIfNotExists();
|
||||
await cassandra.shutdown(!environment.development);
|
||||
await cassandra.connect({ withKeyspace: true });
|
||||
await this.loadMigrations();
|
||||
await this.runMigrations();
|
||||
}
|
||||
}
|
||||
|
||||
export const migrationRunner = new MigrationRunner();
|
16
src/lib/utils/idGenerator.ts
Normal file
16
src/lib/utils/idGenerator.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import Pika from "pika-id";
|
||||
|
||||
const pika = new Pika([
|
||||
"user",
|
||||
{
|
||||
prefix: "user",
|
||||
description: "User ID",
|
||||
},
|
||||
"session",
|
||||
{
|
||||
prefix: "sess",
|
||||
description: "Session ID",
|
||||
},
|
||||
]);
|
||||
|
||||
export { pika };
|
2
src/lib/utils/index.ts
Normal file
2
src/lib/utils/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./idGenerator";
|
||||
export * from "./jwt";
|
35
src/lib/utils/jwt.ts
Normal file
35
src/lib/utils/jwt.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
function getExpirationInSeconds(expiration: string): number {
|
||||
const match = expiration.match(/^(\d+)([smhdwy])$/);
|
||||
if (!match) throw new Error("Invalid expiresIn format in jwt config");
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = Number(value);
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return num;
|
||||
case "m":
|
||||
return num * 60;
|
||||
case "h":
|
||||
return num * 3600;
|
||||
case "d":
|
||||
return num * 86400;
|
||||
case "w":
|
||||
return num * 604800; // 7 days
|
||||
case "y":
|
||||
return num * 31536000; // 365 days
|
||||
default:
|
||||
throw new Error("Invalid time unit in expiresIn");
|
||||
}
|
||||
}
|
||||
|
||||
function formatSecondsToTimeString(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`;
|
||||
if (seconds < 31536000) return `${Math.floor(seconds / 604800)}w`;
|
||||
return `${Math.floor(seconds / 31536000)}y`;
|
||||
}
|
||||
|
||||
export { getExpirationInSeconds, formatSecondsToTimeString };
|
18
src/lib/validation/email.ts
Normal file
18
src/lib/validation/email.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { emailRestrictions } from "#environment/constants";
|
||||
import type { validationResult } from "#types/lib";
|
||||
|
||||
function isValidEmail(rawEmail: string): validationResult {
|
||||
const email = rawEmail.trim();
|
||||
|
||||
if (!email) {
|
||||
return { valid: false, error: "Email is required" };
|
||||
}
|
||||
|
||||
if (!emailRestrictions.regex.test(email)) {
|
||||
return { valid: false, error: "Invalid email address" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export { emailRestrictions, isValidEmail };
|
4
src/lib/validation/index.ts
Normal file
4
src/lib/validation/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./name";
|
||||
export * from "./password";
|
||||
export * from "./email";
|
||||
export * from "./jwt";
|
81
src/lib/validation/jwt.ts
Normal file
81
src/lib/validation/jwt.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import type { JWTConfig } from "#types/config";
|
||||
import type { validationResult } from "#types/lib";
|
||||
|
||||
function isValidSecret(secret: string): boolean {
|
||||
if (!secret || secret.trim().length === 0) return false;
|
||||
return secret.length >= 32;
|
||||
}
|
||||
|
||||
function isValidExpiration(expiration: string): boolean {
|
||||
if (!expiration || expiration.trim().length === 0) return false;
|
||||
const timeFormatRegex = /^(\d+)([smhdwy])$/;
|
||||
return timeFormatRegex.test(expiration.toLowerCase());
|
||||
}
|
||||
|
||||
function isValidIssuer(issuer: string): boolean {
|
||||
if (!issuer || issuer.trim().length === 0) return false;
|
||||
const issuerRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-_.])*[a-zA-Z0-9]$/;
|
||||
return issuer.length <= 255 && issuerRegex.test(issuer);
|
||||
}
|
||||
|
||||
function isValidAlgorithm(algorithm: string): boolean {
|
||||
const supportedAlgorithms = [
|
||||
"HS256",
|
||||
"HS384",
|
||||
"HS512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
];
|
||||
return supportedAlgorithms.includes(algorithm);
|
||||
}
|
||||
|
||||
function validateJWTConfig(config: JWTConfig): validationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isValidSecret(config.secret)) {
|
||||
errors.push("Invalid JWT secret: Must be at least 32 characters long");
|
||||
}
|
||||
|
||||
const expirationStr =
|
||||
typeof config.expiration === "number"
|
||||
? `${config.expiration}s`
|
||||
: config.expiration;
|
||||
|
||||
if (!isValidExpiration(expirationStr)) {
|
||||
errors.push(
|
||||
"Invalid JWT expiration: Must be in format like '1h', '30m', '7d', '1y'",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIssuer(config.issuer)) {
|
||||
errors.push(
|
||||
"Invalid JWT issuer: Must be a valid identifier (domain, URL, or app name)",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidAlgorithm(config.algorithm)) {
|
||||
errors.push(
|
||||
`Invalid JWT algorithm: ${config.algorithm}. Must be one of: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384, PS512`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
...(errors.length > 0 && { error: errors.join("; ") }),
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
isValidSecret,
|
||||
isValidExpiration,
|
||||
isValidIssuer,
|
||||
isValidAlgorithm,
|
||||
validateJWTConfig,
|
||||
};
|
81
src/lib/validation/name.ts
Normal file
81
src/lib/validation/name.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
displayNameRestrictions,
|
||||
forbiddenDisplayNamePatterns,
|
||||
nameRestrictions,
|
||||
} from "#environment/constants";
|
||||
|
||||
import type { validationResult } from "#types/lib";
|
||||
|
||||
function isValidUsername(rawUsername: string): validationResult {
|
||||
if (typeof rawUsername !== "string") {
|
||||
return { valid: false, error: "Username must be a string" };
|
||||
}
|
||||
|
||||
const username = rawUsername.trim().normalize("NFC");
|
||||
|
||||
if (!username) return { valid: false, error: "Username is required" };
|
||||
|
||||
if (username.length < nameRestrictions.length.min)
|
||||
return { valid: false, error: "Username is too short" };
|
||||
|
||||
if (username.length > nameRestrictions.length.max)
|
||||
return { valid: false, error: "Username is too long" };
|
||||
|
||||
if (!nameRestrictions.regex.test(username))
|
||||
return { valid: false, error: "Username contains invalid characters" };
|
||||
|
||||
if (/^[._-]|[._-]$/.test(username))
|
||||
return {
|
||||
valid: false,
|
||||
error: "Username can't start or end with special characters",
|
||||
};
|
||||
|
||||
return { valid: true, username };
|
||||
}
|
||||
|
||||
function isValidDisplayName(rawDisplayName: string): validationResult {
|
||||
if (typeof rawDisplayName !== "string") {
|
||||
return { valid: false, error: "Display name must be a string" };
|
||||
}
|
||||
|
||||
const displayName = rawDisplayName.normalize("NFC");
|
||||
|
||||
if (!displayName) {
|
||||
return { valid: false, error: "Display name is required" };
|
||||
}
|
||||
|
||||
if (displayName.length < displayNameRestrictions.length.min) {
|
||||
return { valid: false, error: "Display name is too short" };
|
||||
}
|
||||
|
||||
if (displayName.length > displayNameRestrictions.length.max) {
|
||||
return { valid: false, error: "Display name is too long" };
|
||||
}
|
||||
|
||||
for (const pattern of forbiddenDisplayNamePatterns) {
|
||||
if (pattern.test(displayName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Display name contains invalid characters or patterns",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayNameRestrictions.regex.test(displayName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Display name contains invalid characters",
|
||||
};
|
||||
}
|
||||
|
||||
if (displayName.trim().length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Display name cannot be only whitespace",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, name: displayName };
|
||||
}
|
||||
|
||||
export { isValidUsername, isValidDisplayName };
|
38
src/lib/validation/password.ts
Normal file
38
src/lib/validation/password.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { passwordRestrictions } from "#environment/constants";
|
||||
import type { validationResult } from "#types/lib";
|
||||
|
||||
function isValidPassword(rawPassword: string): validationResult {
|
||||
if (typeof rawPassword !== "string") {
|
||||
return { valid: false, error: "Password must be a string" };
|
||||
}
|
||||
|
||||
if (!rawPassword) {
|
||||
return { valid: false, error: "Password is required" };
|
||||
}
|
||||
|
||||
if (rawPassword.length < passwordRestrictions.length.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password must be at least ${passwordRestrictions.length.min} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (rawPassword.length > passwordRestrictions.length.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password must be at most ${passwordRestrictions.length.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!passwordRestrictions.regex.test(rawPassword)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Password must contain at least one uppercase, one lowercase, one digit, and one special character",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export { passwordRestrictions, isValidPassword };
|
30
src/routes/health.ts
Normal file
30
src/routes/health.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { redis } from "bun";
|
||||
import { cassandra } from "#lib/database";
|
||||
|
||||
import type { ExtendedRequest, RouteDef } from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
const cassandraHealth = cassandra.getHealthStatus();
|
||||
const redisHealth = await redis
|
||||
.connect()
|
||||
.then(() => "healthy")
|
||||
.catch(() => "unhealthy");
|
||||
|
||||
return Response.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
requestTime: `${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||
services: {
|
||||
cassandra: cassandraHealth,
|
||||
redis: redisHealth,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
24
src/routes/index.ts
Normal file
24
src/routes/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { ExtendedRequest, RouteDef } from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
const endPerf: number = Date.now();
|
||||
const perf: number = endPerf - request.startPerf;
|
||||
|
||||
const { query, params } = request;
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
perf,
|
||||
query,
|
||||
params,
|
||||
};
|
||||
|
||||
return Response.json(response);
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
163
src/routes/user/[id].ts
Normal file
163
src/routes/user/[id].ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { sessionManager } from "#lib/auth";
|
||||
import { cassandra } from "#lib/database";
|
||||
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
RouteDef,
|
||||
UserInfoResponse,
|
||||
UserResponse,
|
||||
UserRow,
|
||||
} from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
try {
|
||||
const { id: identifier } = request.params;
|
||||
|
||||
const session = await sessionManager.getSession(request);
|
||||
|
||||
let userQuery: string;
|
||||
let queryParams: string[];
|
||||
let targetUser: UserRow | null = null;
|
||||
|
||||
if (!identifier) {
|
||||
if (!session) {
|
||||
const response: UserInfoResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
userQuery = `
|
||||
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||
FROM users WHERE id = ? LIMIT 1
|
||||
`;
|
||||
queryParams = [session.id];
|
||||
} else {
|
||||
const isLikelyId = identifier.startsWith("user_");
|
||||
|
||||
if (isLikelyId) {
|
||||
userQuery = `
|
||||
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||
FROM users WHERE id = ? LIMIT 1
|
||||
`;
|
||||
queryParams = [identifier];
|
||||
} else {
|
||||
userQuery = `
|
||||
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||
FROM users WHERE username = ? LIMIT 1
|
||||
`;
|
||||
queryParams = [identifier];
|
||||
}
|
||||
}
|
||||
|
||||
const userResult = (await cassandra.execute(userQuery, queryParams)) as {
|
||||
rows: UserRow[];
|
||||
};
|
||||
|
||||
if (!userResult?.rows || !Array.isArray(userResult.rows)) {
|
||||
const response: UserInfoResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Database query failed",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
if (identifier?.startsWith("user_")) {
|
||||
const usernameQuery = `
|
||||
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||
FROM users WHERE username = ? LIMIT 1
|
||||
`;
|
||||
|
||||
const usernameResult = (await cassandra.execute(usernameQuery, [
|
||||
identifier,
|
||||
])) as {
|
||||
rows: UserRow[];
|
||||
};
|
||||
|
||||
if (usernameResult.rows.length > 0) {
|
||||
targetUser = usernameResult.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
const response: UserInfoResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: identifier ? "User not found" : "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
} else {
|
||||
targetUser = userResult.rows[0] || null;
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
const response: UserInfoResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwnProfile = session?.id === targetUser.id;
|
||||
|
||||
let responseUser: UserResponse;
|
||||
|
||||
if (isOwnProfile) {
|
||||
responseUser = {
|
||||
id: targetUser.id,
|
||||
username: targetUser.username,
|
||||
displayName: targetUser.display_name,
|
||||
email: targetUser.email,
|
||||
isVerified: targetUser.is_verified,
|
||||
createdAt: targetUser.created_at.toISOString(),
|
||||
};
|
||||
} else {
|
||||
responseUser = {
|
||||
id: targetUser.id,
|
||||
username: targetUser.username,
|
||||
displayName: targetUser.display_name,
|
||||
email: "",
|
||||
isVerified: targetUser.is_verified,
|
||||
createdAt: targetUser.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const response: UserInfoResponse = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message: isOwnProfile
|
||||
? "User information retrieved successfully"
|
||||
: "Public user information retrieved successfully",
|
||||
user: responseUser,
|
||||
};
|
||||
|
||||
return Response.json(response, { status: 200 });
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Error retrieving user information",
|
||||
error,
|
||||
});
|
||||
|
||||
const response: UserInfoResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
178
src/routes/user/login.ts
Normal file
178
src/routes/user/login.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { sessionManager } from "#lib/auth";
|
||||
import { cassandra } from "#lib/database";
|
||||
import { isValidEmail, isValidUsername } from "#lib/validation";
|
||||
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RouteDef,
|
||||
UserRow,
|
||||
} from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
accepts: "application/json",
|
||||
returns: "application/json",
|
||||
needsBody: "json",
|
||||
};
|
||||
|
||||
async function handler(
|
||||
request: ExtendedRequest,
|
||||
requestBody: unknown,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { identifier, password } = requestBody as LoginRequest;
|
||||
const { force } = request.query;
|
||||
|
||||
if (force !== "true" && force !== "1") {
|
||||
const existingSession = await sessionManager.getSession(request);
|
||||
if (existingSession) {
|
||||
const response: LoginResponse = {
|
||||
code: 409,
|
||||
success: false,
|
||||
error: "User already logged in",
|
||||
};
|
||||
return Response.json(response, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!identifier || !password) {
|
||||
const response: LoginResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error:
|
||||
"Missing required fields: identifier (username or email), password",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const isEmail = isValidEmail(identifier).valid;
|
||||
const isUsername = isValidUsername(identifier).valid;
|
||||
|
||||
if (!isEmail && !isUsername) {
|
||||
const response: LoginResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: "Invalid identifier format - must be a valid username or email",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
let userQuery: string;
|
||||
let queryParams: string[];
|
||||
|
||||
if (isEmail) {
|
||||
userQuery = `
|
||||
SELECT id, username, display_name, email, password, is_verified, created_at, updated_at
|
||||
FROM users WHERE email = ? LIMIT 1
|
||||
`;
|
||||
queryParams = [identifier.trim().toLowerCase()];
|
||||
} else {
|
||||
userQuery = `
|
||||
SELECT id, username, display_name, email, password, is_verified, created_at, updated_at
|
||||
FROM users WHERE username = ? LIMIT 1
|
||||
`;
|
||||
queryParams = [identifier.trim()];
|
||||
}
|
||||
|
||||
const userResult = (await cassandra.execute(userQuery, queryParams)) as {
|
||||
rows: UserRow[];
|
||||
};
|
||||
|
||||
if (!userResult?.rows || !Array.isArray(userResult.rows)) {
|
||||
const response: LoginResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Database query failed",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
const response: LoginResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Invalid credentials",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user) {
|
||||
const response: LoginResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Invalid credentials",
|
||||
};
|
||||
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const isPasswordValid = await Bun.password.verify(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
const response: LoginResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Invalid credentials",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
||||
const sessionPayload = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isVerified: user.is_verified,
|
||||
displayName: user.display_name,
|
||||
createdAt: user.created_at.toISOString(),
|
||||
updatedAt: user.updated_at.toISOString(),
|
||||
};
|
||||
|
||||
const sessionCookie = await sessionManager.createSession(
|
||||
sessionPayload,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
const responseUser: LoginResponse["user"] = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
email: user.email,
|
||||
isVerified: user.is_verified,
|
||||
createdAt: user.created_at.toISOString(),
|
||||
};
|
||||
|
||||
const response: LoginResponse = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: responseUser,
|
||||
};
|
||||
|
||||
return Response.json(response, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": sessionCookie,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Error during user login",
|
||||
error,
|
||||
});
|
||||
|
||||
const response: LoginResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
57
src/routes/user/logout.ts
Normal file
57
src/routes/user/logout.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { cookieService, sessionManager } from "#lib/auth";
|
||||
|
||||
import type { BaseResponse, ExtendedRequest, RouteDef } from "#types/server";
|
||||
|
||||
interface LogoutResponse extends BaseResponse {}
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: ["POST", "DELETE"],
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
try {
|
||||
const session = await sessionManager.getSession(request);
|
||||
|
||||
if (!session) {
|
||||
const response: LogoutResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
await sessionManager.invalidateSession(request);
|
||||
const clearCookie = cookieService.clearCookie();
|
||||
|
||||
const response: LogoutResponse = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message: "Logged out successfully",
|
||||
};
|
||||
|
||||
return Response.json(response, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": clearCookie,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Error during user logout",
|
||||
error,
|
||||
});
|
||||
|
||||
const response: LogoutResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
169
src/routes/user/register.ts
Normal file
169
src/routes/user/register.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { cassandra } from "#lib/database";
|
||||
import { pika } from "#lib/utils";
|
||||
import {
|
||||
isValidDisplayName,
|
||||
isValidEmail,
|
||||
isValidPassword,
|
||||
isValidUsername,
|
||||
} from "#lib/validation";
|
||||
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RouteDef,
|
||||
} from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
accepts: "application/json",
|
||||
returns: "application/json",
|
||||
needsBody: "json",
|
||||
};
|
||||
|
||||
async function handler(
|
||||
_request: ExtendedRequest,
|
||||
requestBody: unknown,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { username, displayName, email, password } =
|
||||
requestBody as RegisterRequest;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
const response: RegisterResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: "Missing required fields: username, email, password",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const usernameValidation = isValidUsername(username);
|
||||
if (!usernameValidation.valid || !usernameValidation.username) {
|
||||
const response: RegisterResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: usernameValidation.error || "Invalid username",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
let validatedDisplayName: string | null = null;
|
||||
if (displayName?.trim()) {
|
||||
const displayNameValidation = isValidDisplayName(displayName);
|
||||
if (!displayNameValidation.valid) {
|
||||
const response: RegisterResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: displayNameValidation.error || "Invalid display name",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
validatedDisplayName = displayNameValidation.name || null;
|
||||
}
|
||||
|
||||
const emailValidation = isValidEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
const response: RegisterResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: emailValidation.error || "Invalid email",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordValidation = isValidPassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
const response: RegisterResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: passwordValidation.error || "Invalid password",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUsernameQuery =
|
||||
"SELECT id FROM users WHERE username = ? LIMIT 1";
|
||||
const existingUsernameResult = (await cassandra.execute(
|
||||
existingUsernameQuery,
|
||||
[usernameValidation.username],
|
||||
)) as { rows: Array<{ id: string }> };
|
||||
|
||||
if (existingUsernameResult.rows.length > 0) {
|
||||
const response: RegisterResponse = {
|
||||
code: 409,
|
||||
success: false,
|
||||
error: "Username already exists",
|
||||
};
|
||||
return Response.json(response, { status: 409 });
|
||||
}
|
||||
|
||||
const existingEmailQuery = "SELECT id FROM users WHERE email = ? LIMIT 1";
|
||||
const existingEmailResult = (await cassandra.execute(existingEmailQuery, [
|
||||
email.trim().toLowerCase(),
|
||||
])) as { rows: Array<{ id: string }> };
|
||||
|
||||
if (existingEmailResult.rows.length > 0) {
|
||||
const response: RegisterResponse = {
|
||||
code: 409,
|
||||
success: false,
|
||||
error: "Email already exists",
|
||||
};
|
||||
return Response.json(response, { status: 409 });
|
||||
}
|
||||
|
||||
const userId = pika.gen("user");
|
||||
|
||||
const hashedPassword = await Bun.password.hash(password, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 4096,
|
||||
timeCost: 3,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const insertUserQuery = `
|
||||
INSERT INTO users (
|
||||
id, username, display_name, email, password,
|
||||
is_verified, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
await cassandra.execute(insertUserQuery, [
|
||||
userId,
|
||||
usernameValidation.username,
|
||||
validatedDisplayName,
|
||||
email.trim().toLowerCase(),
|
||||
hashedPassword,
|
||||
false,
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
|
||||
const responseUser: RegisterResponse["user"] = {
|
||||
id: userId,
|
||||
username: usernameValidation.username,
|
||||
displayName: validatedDisplayName,
|
||||
email: email.trim().toLowerCase(),
|
||||
isVerified: false,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
const response: RegisterResponse = {
|
||||
code: 201,
|
||||
success: true,
|
||||
message: "User registered successfully",
|
||||
user: responseUser,
|
||||
};
|
||||
|
||||
return Response.json(response, { status: 201 });
|
||||
} catch {
|
||||
const response: RegisterResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
321
src/routes/user/update/info.ts
Normal file
321
src/routes/user/update/info.ts
Normal file
|
@ -0,0 +1,321 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { sessionManager } from "#lib/auth";
|
||||
import { cassandra } from "#lib/database";
|
||||
import {
|
||||
isValidDisplayName,
|
||||
isValidEmail,
|
||||
isValidUsername,
|
||||
} from "#lib/validation";
|
||||
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
RouteDef,
|
||||
UpdateInfoRequest,
|
||||
UpdateInfoResponse,
|
||||
UserResponse,
|
||||
UserRow,
|
||||
} from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: ["PUT", "PATCH"],
|
||||
accepts: "application/json",
|
||||
returns: "application/json",
|
||||
needsBody: "json",
|
||||
};
|
||||
|
||||
async function handler(
|
||||
request: ExtendedRequest,
|
||||
requestBody: unknown,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const session = await sessionManager.getSession(request);
|
||||
|
||||
if (!session) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const { username, displayName, email } = requestBody as UpdateInfoRequest;
|
||||
|
||||
if (
|
||||
username === undefined &&
|
||||
displayName === undefined &&
|
||||
email === undefined
|
||||
) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error:
|
||||
"At least one field must be provided (username, displayName, email)",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
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: UpdateInfoResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUser = currentUserResult.rows[0];
|
||||
if (!currentUser) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
||||
const updates: {
|
||||
username?: string;
|
||||
displayName?: string | null;
|
||||
email?: string;
|
||||
} = {};
|
||||
|
||||
if (username !== undefined) {
|
||||
const usernameValidation = isValidUsername(username);
|
||||
if (!usernameValidation.valid || !usernameValidation.username) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: usernameValidation.error || "Invalid username",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
if (usernameValidation.username !== currentUser.username) {
|
||||
const existingUsernameQuery =
|
||||
"SELECT id FROM users WHERE username = ? LIMIT 1";
|
||||
const existingUsernameResult = (await cassandra.execute(
|
||||
existingUsernameQuery,
|
||||
[usernameValidation.username],
|
||||
)) as { rows: Array<{ id: string }> };
|
||||
|
||||
if (
|
||||
existingUsernameResult.rows.length > 0 &&
|
||||
existingUsernameResult.rows[0]?.id !== session.id
|
||||
) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 409,
|
||||
success: false,
|
||||
error: "Username already exists",
|
||||
};
|
||||
return Response.json(response, { status: 409 });
|
||||
}
|
||||
|
||||
updates.username = usernameValidation.username;
|
||||
}
|
||||
}
|
||||
|
||||
if (displayName !== undefined) {
|
||||
if (displayName === null || displayName.trim() === "") {
|
||||
updates.displayName = null;
|
||||
} else {
|
||||
const displayNameValidation = isValidDisplayName(displayName);
|
||||
if (!displayNameValidation.valid) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: displayNameValidation.error || "Invalid display name",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
updates.displayName = displayNameValidation.name || null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
success: true,
|
||||
message: "No changes required",
|
||||
user: {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
displayName: currentUser.display_name,
|
||||
email: currentUser.email,
|
||||
isVerified: currentUser.is_verified,
|
||||
createdAt: currentUser.created_at.toISOString(),
|
||||
},
|
||||
};
|
||||
return Response.json(response, { status: 200 });
|
||||
}
|
||||
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: unknown[] = [];
|
||||
|
||||
if (updates.username !== undefined) {
|
||||
updateFields.push("username = ?");
|
||||
updateValues.push(updates.username);
|
||||
}
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
updateFields.push("display_name = ?");
|
||||
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 = `
|
||||
UPDATE users
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await cassandra.execute(updateQuery, updateValues);
|
||||
|
||||
const updatedUserResult = (await cassandra.execute(currentUserQuery, [
|
||||
session.id,
|
||||
])) as { rows: UserRow[] };
|
||||
|
||||
const updatedUser = updatedUserResult.rows[0];
|
||||
if (!updatedUser) {
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Failed to fetch updated user data",
|
||||
};
|
||||
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 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 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 });
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Error updating user information",
|
||||
error,
|
||||
});
|
||||
|
||||
const response: UpdateInfoResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
215
src/routes/user/update/password.ts
Normal file
215
src/routes/user/update/password.ts
Normal file
|
@ -0,0 +1,215 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { sessionManager } from "#lib/auth";
|
||||
import { cassandra } from "#lib/database";
|
||||
import { isValidPassword } from "#lib/validation";
|
||||
|
||||
import type {
|
||||
ExtendedRequest,
|
||||
RouteDef,
|
||||
UpdatePasswordRequest,
|
||||
UpdatePasswordResponse,
|
||||
UserRow,
|
||||
} from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: ["PUT", "PATCH"],
|
||||
accepts: "application/json",
|
||||
returns: "application/json",
|
||||
needsBody: "json",
|
||||
};
|
||||
|
||||
async function handler(
|
||||
request: ExtendedRequest,
|
||||
requestBody: unknown,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const session = await sessionManager.getSession(request);
|
||||
|
||||
if (!session) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword, logoutAllSessions } =
|
||||
requestBody as UpdatePasswordRequest;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: "Both currentPassword and newPassword are required",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordValidation = isValidPassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: passwordValidation.error || "Invalid new password",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 400,
|
||||
success: false,
|
||||
error: "New password must be different from current password",
|
||||
};
|
||||
return Response.json(response, { status: 400 });
|
||||
}
|
||||
|
||||
const userQuery = `
|
||||
SELECT id, username, email, password, is_verified, created_at, updated_at
|
||||
FROM users WHERE id = ? LIMIT 1
|
||||
`;
|
||||
|
||||
const userResult = (await cassandra.execute(userQuery, [session.id])) as {
|
||||
rows: UserRow[];
|
||||
};
|
||||
|
||||
if (!userResult?.rows || userResult.rows.length === 0) {
|
||||
await sessionManager.invalidateSession(request);
|
||||
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
if (!user) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await Bun.password.verify(
|
||||
currentPassword,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isCurrentPasswordValid) {
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 401,
|
||||
success: false,
|
||||
error: "Current password is incorrect",
|
||||
};
|
||||
return Response.json(response, { status: 401 });
|
||||
}
|
||||
|
||||
const hashedNewPassword = await Bun.password.hash(newPassword, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 4096,
|
||||
timeCost: 3,
|
||||
});
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET password = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await cassandra.execute(updateQuery, [
|
||||
hashedNewPassword,
|
||||
new Date(),
|
||||
session.id,
|
||||
]);
|
||||
|
||||
if (logoutAllSessions === true) {
|
||||
const invalidatedCount =
|
||||
await sessionManager.invalidateAllSessionsForUser(session.id);
|
||||
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message: `Password updated successfully. Logged out from ${invalidatedCount} sessions.`,
|
||||
loggedOutSessions: invalidatedCount,
|
||||
};
|
||||
|
||||
return Response.json(response, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": "session=; Path=/; Max-Age=0; HttpOnly",
|
||||
},
|
||||
});
|
||||
}
|
||||
const allSessions = await sessionManager.getActiveSessionsForUser(
|
||||
session.id,
|
||||
);
|
||||
const currentToken = request.headers
|
||||
.get("Cookie")
|
||||
?.match(/session=([^;]+)/)?.[1];
|
||||
|
||||
let invalidatedCount = 0;
|
||||
if (currentToken) {
|
||||
for (const token of allSessions) {
|
||||
if (token !== currentToken) {
|
||||
await sessionManager.invalidateSessionByToken(token);
|
||||
invalidatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
||||
const updatedSessionPayload = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isVerified: user.is_verified,
|
||||
displayName: user.display_name,
|
||||
createdAt: user.created_at.toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const sessionCookie = await sessionManager.updateSession(
|
||||
request,
|
||||
updatedSessionPayload,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 200,
|
||||
success: true,
|
||||
message:
|
||||
invalidatedCount > 0
|
||||
? `Password updated successfully. Logged out from ${invalidatedCount} other sessions.`
|
||||
: "Password updated successfully.",
|
||||
loggedOutSessions: invalidatedCount,
|
||||
};
|
||||
|
||||
return Response.json(response, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": sessionCookie,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: "Error updating user password",
|
||||
error,
|
||||
});
|
||||
|
||||
const response: UpdatePasswordResponse = {
|
||||
code: 500,
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
};
|
||||
return Response.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
302
src/server.ts
Normal file
302
src/server.ts
Normal file
|
@ -0,0 +1,302 @@
|
|||
import { resolve } from "node:path";
|
||||
import { type Echo, echo } from "@atums/echo";
|
||||
import { environment } from "#environment/config";
|
||||
import { reqLoggerIgnores } from "#environment/constants/server";
|
||||
import { noFileLog } from "#index";
|
||||
import { webSocketHandler } from "#websocket";
|
||||
|
||||
import {
|
||||
type BunFile,
|
||||
FileSystemRouter,
|
||||
type MatchedRoute,
|
||||
type Server,
|
||||
} from "bun";
|
||||
|
||||
import type { ExtendedRequest, RouteModule } from "#types/server";
|
||||
|
||||
class ServerHandler {
|
||||
private router: FileSystemRouter;
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private host: string,
|
||||
) {
|
||||
this.router = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: resolve("src", "routes"),
|
||||
fileExtensions: [".ts"],
|
||||
origin: `http://${this.host}:${this.port}`,
|
||||
});
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const server: Server = Bun.serve({
|
||||
port: this.port,
|
||||
hostname: this.host,
|
||||
fetch: this.handleRequest.bind(this),
|
||||
websocket: {
|
||||
open: webSocketHandler.handleOpen.bind(webSocketHandler),
|
||||
message: webSocketHandler.handleMessage.bind(webSocketHandler),
|
||||
close: webSocketHandler.handleClose.bind(webSocketHandler),
|
||||
},
|
||||
});
|
||||
|
||||
noFileLog.info(
|
||||
`Server running at http://${server.hostname}:${server.port}`,
|
||||
);
|
||||
this.logRoutes(noFileLog);
|
||||
}
|
||||
|
||||
private logRoutes(echo: Echo): void {
|
||||
echo.info("Available routes:");
|
||||
|
||||
const sortedRoutes: [string, string][] = Object.entries(
|
||||
this.router.routes,
|
||||
).sort(([pathA]: [string, string], [pathB]: [string, string]) =>
|
||||
pathA.localeCompare(pathB),
|
||||
);
|
||||
|
||||
for (const [path, filePath] of sortedRoutes) {
|
||||
echo.info(`Route: ${path}, File: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async serveStaticFile(
|
||||
request: ExtendedRequest,
|
||||
pathname: string,
|
||||
ip: string,
|
||||
): Promise<Response> {
|
||||
let filePath: string;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
filePath = resolve(`.${pathname}`);
|
||||
|
||||
const file: BunFile = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
||||
const contentType: string = file.type ?? "application/octet-stream";
|
||||
|
||||
response = new Response(fileContent, {
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
} else {
|
||||
echo.warn(`File not found: ${filePath}`);
|
||||
response = new Response("Not Found", { status: 404 });
|
||||
}
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Error serving static file: ${pathname}`,
|
||||
error: error as Error,
|
||||
});
|
||||
response = new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
|
||||
this.logRequest(request, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
private logRequest(
|
||||
request: ExtendedRequest,
|
||||
response: Response,
|
||||
ip: string | undefined,
|
||||
): void {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
|
||||
const { ignoredStartsWith, ignoredPaths } = reqLoggerIgnores;
|
||||
|
||||
if (
|
||||
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
|
||||
ignoredPaths.includes(pathname)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo.custom(`${request.method}`, `${response.status}`, [
|
||||
pathname,
|
||||
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||
ip || "unknown",
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleRequest(
|
||||
request: Request,
|
||||
server: Server,
|
||||
): Promise<Response> {
|
||||
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
|
||||
extendedRequest.startPerf = performance.now();
|
||||
|
||||
const headers = request.headers;
|
||||
let ip = server.requestIP(request)?.address;
|
||||
let response: Response;
|
||||
|
||||
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
|
||||
ip =
|
||||
headers.get("CF-Connecting-IP")?.trim() ||
|
||||
headers.get("X-Real-IP")?.trim() ||
|
||||
headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
|
||||
"unknown";
|
||||
}
|
||||
|
||||
const pathname: string = new URL(request.url).pathname;
|
||||
|
||||
const baseDir = resolve("custom");
|
||||
const customPath = resolve(baseDir, pathname.slice(1));
|
||||
|
||||
if (!customPath.startsWith(baseDir)) {
|
||||
response = new Response("Forbidden", { status: 403 });
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
const customFile = Bun.file(customPath);
|
||||
if (await customFile.exists()) {
|
||||
const content = await customFile.arrayBuffer();
|
||||
const type: string = customFile.type ?? "application/octet-stream";
|
||||
response = new Response(content, {
|
||||
headers: { "Content-Type": type },
|
||||
});
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/public")) {
|
||||
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||
}
|
||||
|
||||
const match: MatchedRoute | null = this.router.match(request);
|
||||
let requestBody: unknown = {};
|
||||
|
||||
if (match) {
|
||||
const { filePath, params, query } = match;
|
||||
|
||||
try {
|
||||
const routeModule: RouteModule = await import(filePath);
|
||||
const contentType: string | null = request.headers.get("Content-Type");
|
||||
const actualContentType: string | null = contentType
|
||||
? (contentType.split(";")[0]?.trim() ?? null)
|
||||
: null;
|
||||
|
||||
if (
|
||||
routeModule.routeDef.needsBody === "json" &&
|
||||
actualContentType === "application/json"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.json();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
} else if (
|
||||
routeModule.routeDef.needsBody === "multipart" &&
|
||||
actualContentType === "multipart/form-data"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.formData();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(Array.isArray(routeModule.routeDef.method) &&
|
||||
!routeModule.routeDef.method.includes(request.method)) ||
|
||||
(!Array.isArray(routeModule.routeDef.method) &&
|
||||
routeModule.routeDef.method !== request.method)
|
||||
) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 405,
|
||||
error: `Method ${request.method} Not Allowed, expected ${
|
||||
Array.isArray(routeModule.routeDef.method)
|
||||
? routeModule.routeDef.method.join(", ")
|
||||
: routeModule.routeDef.method
|
||||
}`,
|
||||
},
|
||||
{ status: 405 },
|
||||
);
|
||||
} else {
|
||||
const expectedContentType: string | string[] | null =
|
||||
routeModule.routeDef.accepts;
|
||||
|
||||
let matchesAccepts: boolean;
|
||||
|
||||
if (Array.isArray(expectedContentType)) {
|
||||
matchesAccepts =
|
||||
expectedContentType.includes("*/*") ||
|
||||
expectedContentType.includes(actualContentType || "");
|
||||
} else {
|
||||
matchesAccepts =
|
||||
expectedContentType === "*/*" ||
|
||||
actualContentType === expectedContentType;
|
||||
}
|
||||
|
||||
if (!matchesAccepts) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 406,
|
||||
error: `Content-Type ${actualContentType} Not Acceptable, expected ${
|
||||
Array.isArray(expectedContentType)
|
||||
? expectedContentType.join(", ")
|
||||
: expectedContentType
|
||||
}`,
|
||||
},
|
||||
{ status: 406 },
|
||||
);
|
||||
} else {
|
||||
extendedRequest.params = params;
|
||||
extendedRequest.query = query;
|
||||
|
||||
response = await routeModule.handler(
|
||||
extendedRequest,
|
||||
requestBody,
|
||||
server,
|
||||
);
|
||||
|
||||
if (routeModule.routeDef.returns !== "*/*") {
|
||||
response.headers.set(
|
||||
"Content-Type",
|
||||
routeModule.routeDef.returns,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
echo.error({
|
||||
message: `Error handling route ${request.url}`,
|
||||
error: error,
|
||||
});
|
||||
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 500,
|
||||
error: "Internal Server Error",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 404,
|
||||
error: "Not Found",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const serverHandler: ServerHandler = new ServerHandler(
|
||||
environment.port,
|
||||
environment.host,
|
||||
);
|
||||
|
||||
export { serverHandler };
|
30
src/websocket.ts
Normal file
30
src/websocket.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { echo } from "@atums/echo";
|
||||
|
||||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
class WebSocketHandler {
|
||||
public handleMessage(ws: ServerWebSocket, message: string): void {
|
||||
echo.info(`WebSocket received: ${message}`);
|
||||
try {
|
||||
ws.send(`You said: ${message}`);
|
||||
} catch (error) {
|
||||
echo.error({ message: "WebSocket send error", error });
|
||||
}
|
||||
}
|
||||
|
||||
public handleOpen(ws: ServerWebSocket): void {
|
||||
echo.info("WebSocket connection opened.");
|
||||
try {
|
||||
ws.send("Welcome to the WebSocket server!");
|
||||
} catch (error) {
|
||||
echo.error({ message: "WebSocket send error", error });
|
||||
}
|
||||
}
|
||||
|
||||
public handleClose(_ws: ServerWebSocket, code: number, reason: string): void {
|
||||
echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const webSocketHandler: WebSocketHandler = new WebSocketHandler();
|
||||
export { webSocketHandler, WebSocketHandler };
|
Loading…
Add table
Add a link
Reference in a new issue