From f93cef442a7b4f55851ad871be744ee057d715ed Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 2 May 2025 17:40:37 -0400 Subject: [PATCH] rename some functions, change name to username ( username table), fix exports, add validations folder --- config/environment.ts | 10 ++-- config/setup/index.ts | 8 ++-- config/setup/tables/users.ts | 14 ++++-- package.json | 4 +- src/index.ts | 4 +- src/lib/cassandra.ts | 2 +- src/lib/http.ts | 7 +-- src/lib/pika.ts | 61 ++++++++++++++++++++++++ src/lib/validators/email.ts | 19 ++++++++ src/lib/validators/index.ts | 3 ++ src/lib/validators/name.ts | 30 ++++++++++++ src/lib/validators/password.ts | 36 ++++++++++++++ src/routes/user/register.ts | 87 +++++++++++++++++++++++++++++----- src/server.ts | 57 +++++++--------------- types/validation.d.ts | 10 ++++ 15 files changed, 280 insertions(+), 72 deletions(-) create mode 100644 src/lib/pika.ts create mode 100644 src/lib/validators/email.ts create mode 100644 src/lib/validators/index.ts create mode 100644 src/lib/validators/name.ts create mode 100644 src/lib/validators/password.ts create mode 100644 types/validation.d.ts diff --git a/config/environment.ts b/config/environment.ts index c6d357b..91ada96 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,17 +1,17 @@ import { logger } from "@creations.works/logger"; -export const environment: Environment = { +const environment: Environment = { port: Number.parseInt(process.env.PORT || "", 10), host: process.env.HOST || "0.0.0.0", development: process.env.NODE_ENV === "development" || process.argv.includes("--dev"), }; -export const redisTtl: number = process.env.REDIS_TTL +const redisTtl: number = process.env.REDIS_TTL ? Number.parseInt(process.env.REDIS_TTL, 10) : 60 * 60 * 1; // 1 hour -export const cassandra: CassandraConfig = { +const cassandra: CassandraConfig = { host: process.env.CASSANDRA_HOST || "localhost", port: Number.parseInt(process.env.CASSANDRA_PORT || "9042", 10), keyspace: process.env.CASSANDRA_KEYSPACE || "void_db", @@ -24,7 +24,7 @@ export const cassandra: CassandraConfig = { authEnabled: process.env.CASSANDRA_AUTH_ENABLED === "false", }; -export function verifyRequiredVariables(): void { +function verifyRequiredVariables(): void { const requiredVariables = [ "HOST", "PORT", @@ -51,3 +51,5 @@ export function verifyRequiredVariables(): void { process.exit(1); } } + +export { environment, cassandra, redisTtl, verifyRequiredVariables }; diff --git a/config/setup/index.ts b/config/setup/index.ts index fc9f954..ea13288 100644 --- a/config/setup/index.ts +++ b/config/setup/index.ts @@ -6,13 +6,13 @@ import { verifyRequiredVariables, } from "@config/environment"; import { logger } from "@creations.works/logger"; -import { CassandraService } from "@lib/cassandra"; +import { cassandra } from "@lib/cassandra"; async function setup(): Promise { verifyRequiredVariables(); - await CassandraService.connect({ withKeyspace: false }); + await cassandra.connect({ withKeyspace: false }); - const client = CassandraService.getClient(); + const client = cassandra.getClient(); const keyspace = cassandraConfig.keyspace; if (!keyspace) { @@ -60,7 +60,7 @@ setup() process.exit(1); }) .finally(() => { - CassandraService.shutdown().catch((error: Error) => { + cassandra.shutdown().catch((error: Error) => { logger.error(["Error shutting down Cassandra client:", error as Error]); }); diff --git a/config/setup/tables/users.ts b/config/setup/tables/users.ts index 00f62c1..e7dbb95 100644 --- a/config/setup/tables/users.ts +++ b/config/setup/tables/users.ts @@ -1,10 +1,10 @@ -import { CassandraService } from "@lib/cassandra"; +import { cassandra } from "@lib/cassandra"; async function createTable() { - await CassandraService.getClient().execute(` + await cassandra.getClient().execute(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - name TEXT, + username TEXT, display_name TEXT, email TEXT, password TEXT, @@ -14,6 +14,14 @@ async function createTable() { updated_at TIMESTAMP ); `); + + await cassandra.getClient().execute(` + CREATE INDEX IF NOT EXISTS users_username_idx ON users (username); + `); + + await cassandra.getClient().execute(` + CREATE INDEX IF NOT EXISTS users_email_idx ON users (email); + `); } export { createTable }; diff --git a/package.json b/package.json index a3f3ce1..62d5f6b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "lint": "bunx biome check", "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lockdb", - "setup": "bun run config/setup/index.ts" }, "devDependencies": { @@ -22,6 +21,7 @@ }, "dependencies": { "@creations.works/logger": "^1.0.3", - "cassandra-driver": "^4.8.0" + "cassandra-driver": "^4.8.0", + "pika-id": "^1.1.3" } } diff --git a/src/index.ts b/src/index.ts index 3d62bbc..432e6a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { serverHandler } from "@/server"; import { verifyRequiredVariables } from "@config/environment"; import { logger } from "@creations.works/logger"; -import { CassandraService } from "@lib/cassandra"; +import { cassandra } from "@lib/cassandra"; import { redis } from "bun"; async function main(): Promise { @@ -16,7 +16,7 @@ async function main(): Promise { process.exit(1); } - await CassandraService.connect(); + await cassandra.connect(); serverHandler.initialize(); } diff --git a/src/lib/cassandra.ts b/src/lib/cassandra.ts index 02a5acb..eddec13 100644 --- a/src/lib/cassandra.ts +++ b/src/lib/cassandra.ts @@ -49,4 +49,4 @@ class CassandraService { } } -export { CassandraService }; +export { CassandraService as cassandra }; diff --git a/src/lib/http.ts b/src/lib/http.ts index 3ba934b..1401874 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -9,10 +9,10 @@ const statusMessages: Record = { 500: "Internal Server Error", }; -export async function returnGenericJsonResponse( +function jsonResponse( statusCode: number, options: GenericJsonResponseOptions = {}, -): Promise { +): Response { const { headers, message, error, ...extra } = options; if (statusCode === 204) { @@ -53,8 +53,9 @@ export async function returnGenericJsonResponse( return Response.json(orderedResponse, { status: statusCode, headers: { - "Content-Type": "application/json", ...headers, }, }); } + +export { jsonResponse }; diff --git a/src/lib/pika.ts b/src/lib/pika.ts new file mode 100644 index 0000000..be8c627 --- /dev/null +++ b/src/lib/pika.ts @@ -0,0 +1,61 @@ +import Pika from "pika-id"; + +const pika = new Pika([ + "user", + { + prefix: "user", + description: "User ID", + }, + "guild", + { + prefix: "guild", + description: "Guild ID", + }, + "message", + { + prefix: "msg", + description: "Message ID", + }, + "channel", + { + prefix: "ch", + description: "Channel ID", + }, + "role", + { + prefix: "role", + description: "Role ID", + }, + "emoji", + { + prefix: "emoji", + description: "Emoji ID", + }, + "webhook", + { + prefix: "wh", + description: "Webhook ID", + }, + "application", + { + prefix: "app", + description: "Application ID", + }, + "invite", + { + prefix: "inv", + description: "Invite ID", + }, + "sticker", + { + prefix: "sticker", + description: "Sticker ID", + }, + "session", + { + prefix: "sess", + description: "Session ID", + }, +]); + +export { pika }; diff --git a/src/lib/validators/email.ts b/src/lib/validators/email.ts new file mode 100644 index 0000000..9fe4e2e --- /dev/null +++ b/src/lib/validators/email.ts @@ -0,0 +1,19 @@ +const emailRestrictions: { regex: RegExp } = { + regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +}; + +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 }; diff --git a/src/lib/validators/index.ts b/src/lib/validators/index.ts new file mode 100644 index 0000000..555cc17 --- /dev/null +++ b/src/lib/validators/index.ts @@ -0,0 +1,3 @@ +export * from "@lib/validators/name"; +export * from "@lib/validators/password"; +export * from "@lib/validators/email"; diff --git a/src/lib/validators/name.ts b/src/lib/validators/name.ts new file mode 100644 index 0000000..6025f95 --- /dev/null +++ b/src/lib/validators/name.ts @@ -0,0 +1,30 @@ +// ? should support non english characters but won't mess up the url +const nameRestrictions: genericValidation = { + length: { min: 3, max: 20 }, + regex: /^[\p{L}\p{N}._-]+$/u, +}; + +function isValidUsername(rawUsername: string): validationResult { + 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 }; +} + +export { nameRestrictions, isValidUsername }; diff --git a/src/lib/validators/password.ts b/src/lib/validators/password.ts new file mode 100644 index 0000000..26b2ee4 --- /dev/null +++ b/src/lib/validators/password.ts @@ -0,0 +1,36 @@ +const passwordRestrictions: genericValidation = { + length: { min: 12, max: 64 }, + regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u, +}; + +function isValidPassword(rawPassword: string): validationResult { + 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 }; diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index 9acd265..815ecd5 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -1,33 +1,94 @@ -import { returnGenericJsonResponse } from "@lib/http"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import { pika } from "@lib/pika"; +import { + isValidEmail, + isValidPassword, + isValidUsername, +} from "@lib/validators"; const routeDef: RouteDef = { method: "POST", accepts: "application/json", - returns: "application/json", + returns: "application/json;charset=utf-8", needsBody: "json", }; async function handler( - request: ExtendedRequest, + _request: ExtendedRequest, requestBody: unknown, ): Promise { const { username, password, email } = requestBody as { - username: string; - password: string; - email: string; + username?: string; + password?: string; + email?: string; }; - if (!username || !password || !email) { - return returnGenericJsonResponse(400, { - message: "Missing required fields: username, password, email", + const fields = { username, password, email }; + + const validators = { + username: isValidUsername, + password: isValidPassword, + email: isValidEmail, + }; + + const errors: string[] = []; + + for (const [key, validate] of Object.entries(validators)) { + const value = fields[key as keyof typeof fields]; + if (typeof value !== "string" || value.trim() === "") { + errors.push(`${key} is required`); + continue; + } + + const result = validate(value); + if (!result.valid) errors.push(`${key}: ${result.error}`); + } + + if (errors.length > 0) { + return jsonResponse(400, { + message: "Validation failed", + error: errors.join("; "), }); } - return returnGenericJsonResponse(200, { + const usernameResult = await cassandra + .getClient() + .execute("SELECT id FROM users WHERE username = ?", [username], { + prepare: true, + }); + + const emailResult = await cassandra + .getClient() + .execute("SELECT id FROM users WHERE email = ?", [email], { + prepare: true, + }); + + if (usernameResult.rowLength > 0 || emailResult.rowLength > 0) { + const errorMessages: string[] = []; + + if (usernameResult.rowLength > 0) { + errorMessages.push("Username has already been taken"); + } + if (emailResult.rowLength > 0) { + errorMessages.push("Email has already been taken"); + } + + return jsonResponse(400, { + message: "Validation failed", + error: errorMessages.join("; "), + }); + } + + const userId = pika.gen("user"); + + return jsonResponse(200, { message: "User registered successfully", - username: username, - password, - email, + data: { + username, + email, + id: userId, + }, }); } diff --git a/src/server.ts b/src/server.ts index 5646636..d4b6ba1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { environment } from "@config/environment"; import { logger } from "@creations.works/logger"; +import { jsonResponse } from "@lib/http"; import { type BunFile, FileSystemRouter, @@ -138,18 +139,13 @@ class ServerHandler { (!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 }, - ); + response = jsonResponse(405, { + error: `Method ${request.method} Not Allowed, expected ${ + Array.isArray(routeModule.routeDef.method) + ? routeModule.routeDef.method.join(", ") + : routeModule.routeDef.method + }`, + }); } else { const expectedContentType: string | string[] | null = routeModule.routeDef.accepts; @@ -167,18 +163,13 @@ class ServerHandler { } if (!matchesAccepts) { - response = Response.json( - { - success: false, - code: 406, - error: `Content-Type ${actualContentType} Not Acceptable, expected ${ - Array.isArray(expectedContentType) - ? expectedContentType.join(", ") - : expectedContentType - }`, - }, - { status: 406 }, - ); + response = jsonResponse(406, { + error: `Content-Type ${actualContentType} Not Acceptable, expected ${ + Array.isArray(expectedContentType) + ? expectedContentType.join(", ") + : expectedContentType + }`, + }); } else { extendedRequest.params = params; extendedRequest.query = query; @@ -200,24 +191,10 @@ class ServerHandler { } catch (error: unknown) { logger.error([`Error handling route ${request.url}:`, error as Error]); - response = Response.json( - { - success: false, - code: 500, - error: "Internal Server Error", - }, - { status: 500 }, - ); + response = jsonResponse(500); } } else { - response = Response.json( - { - success: false, - code: 404, - error: "Not Found", - }, - { status: 404 }, - ); + response = jsonResponse(404); } const headers = request.headers; diff --git a/types/validation.d.ts b/types/validation.d.ts new file mode 100644 index 0000000..93ca84b --- /dev/null +++ b/types/validation.d.ts @@ -0,0 +1,10 @@ +type genericValidation = { + length: { min: number; max: number }; + regex: RegExp; +}; + +type validationResult = { + valid: boolean; + error?: string; + username?: string; +};