From 421043c9b59e9b1710a3eb4578b902b450c43e42 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 10 Jun 2025 13:42:39 -0400 Subject: [PATCH] first commit --- .editorconfig | 12 + .forgejo/workflows/biomejs.yml | 24 ++ .gitattributes | 1 + .gitignore | 4 + LICENSE | 28 ++ README.md | 1 + biome.json | 54 +++ bun.lock | 84 +++++ environment/config.ts | 69 ++++ environment/constants/database/index.ts | 5 + environment/constants/index.ts | 3 + environment/constants/server.ts | 6 + environment/constants/validation.ts | 37 ++ environment/database/cassandra.ts | 114 +++++++ environment/database/index.ts | 1 + .../migrations/up/001_create_users.sql | 14 + environment/jwt.ts | 28 ++ logger.json | 39 +++ package.json | 25 ++ src/commands.ts | 120 +++++++ src/index.ts | 27 ++ src/lib/auth/cookies.ts | 52 +++ src/lib/auth/index.ts | 3 + src/lib/auth/jwt.ts | 34 ++ src/lib/auth/session.ts | 167 +++++++++ src/lib/database/cassandra.ts | 304 +++++++++++++++++ src/lib/database/index.ts | 2 + src/lib/database/migrations.ts | 183 ++++++++++ src/lib/utils/idGenerator.ts | 16 + src/lib/utils/index.ts | 2 + src/lib/utils/jwt.ts | 35 ++ src/lib/validation/email.ts | 18 + src/lib/validation/index.ts | 4 + src/lib/validation/jwt.ts | 81 +++++ src/lib/validation/name.ts | 81 +++++ src/lib/validation/password.ts | 38 +++ src/routes/health.ts | 30 ++ src/routes/index.ts | 24 ++ src/routes/user/[id].ts | 163 +++++++++ src/routes/user/login.ts | 178 ++++++++++ src/routes/user/logout.ts | 57 ++++ src/routes/user/register.ts | 169 +++++++++ src/routes/user/update/info.ts | 321 ++++++++++++++++++ src/routes/user/update/password.ts | 215 ++++++++++++ src/server.ts | 302 ++++++++++++++++ src/websocket.ts | 30 ++ tsconfig.json | 29 ++ types/config/auth.ts | 32 ++ types/config/database.ts | 25 ++ types/config/environment.ts | 7 + types/config/index.ts | 3 + types/index.ts | 3 + types/lib/index.ts | 1 + types/lib/validation.ts | 13 + types/server/index.ts | 3 + types/server/requests/index.ts | 1 + types/server/requests/user/base.ts | 28 ++ types/server/requests/user/index.ts | 6 + types/server/requests/user/login.ts | 12 + types/server/requests/user/password.ts | 7 + types/server/requests/user/register.ts | 14 + types/server/requests/user/responses.ts | 7 + types/server/requests/user/update/index.ts | 2 + types/server/requests/user/update/info.ts | 13 + types/server/requests/user/update/password.ts | 14 + types/server/routes.ts | 20 ++ types/server/server.ts | 10 + 67 files changed, 3455 insertions(+) create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/biomejs.yml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 environment/config.ts create mode 100644 environment/constants/database/index.ts create mode 100644 environment/constants/index.ts create mode 100644 environment/constants/server.ts create mode 100644 environment/constants/validation.ts create mode 100644 environment/database/cassandra.ts create mode 100644 environment/database/index.ts create mode 100644 environment/database/migrations/up/001_create_users.sql create mode 100644 environment/jwt.ts create mode 100644 logger.json create mode 100644 package.json create mode 100644 src/commands.ts create mode 100644 src/index.ts create mode 100644 src/lib/auth/cookies.ts create mode 100644 src/lib/auth/index.ts create mode 100644 src/lib/auth/jwt.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/database/cassandra.ts create mode 100644 src/lib/database/index.ts create mode 100644 src/lib/database/migrations.ts create mode 100644 src/lib/utils/idGenerator.ts create mode 100644 src/lib/utils/index.ts create mode 100644 src/lib/utils/jwt.ts create mode 100644 src/lib/validation/email.ts create mode 100644 src/lib/validation/index.ts create mode 100644 src/lib/validation/jwt.ts create mode 100644 src/lib/validation/name.ts create mode 100644 src/lib/validation/password.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/user/[id].ts create mode 100644 src/routes/user/login.ts create mode 100644 src/routes/user/logout.ts create mode 100644 src/routes/user/register.ts create mode 100644 src/routes/user/update/info.ts create mode 100644 src/routes/user/update/password.ts create mode 100644 src/server.ts create mode 100644 src/websocket.ts create mode 100644 tsconfig.json create mode 100644 types/config/auth.ts create mode 100644 types/config/database.ts create mode 100644 types/config/environment.ts create mode 100644 types/config/index.ts create mode 100644 types/index.ts create mode 100644 types/lib/index.ts create mode 100644 types/lib/validation.ts create mode 100644 types/server/index.ts create mode 100644 types/server/requests/index.ts create mode 100644 types/server/requests/user/base.ts create mode 100644 types/server/requests/user/index.ts create mode 100644 types/server/requests/user/login.ts create mode 100644 types/server/requests/user/password.ts create mode 100644 types/server/requests/user/register.ts create mode 100644 types/server/requests/user/responses.ts create mode 100644 types/server/requests/user/update/index.ts create mode 100644 types/server/requests/user/update/info.ts create mode 100644 types/server/requests/user/update/password.ts create mode 100644 types/server/routes.ts create mode 100644 types/server/server.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..980ef21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml new file mode 100644 index 0000000..15c990c --- /dev/null +++ b/.forgejo/workflows/biomejs.yml @@ -0,0 +1,24 @@ +name: Code quality checks + +on: + push: + pull_request: + +jobs: + biome: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + echo "$BUN_INSTALL/bin" >> $GITHUB_PATH + + - name: Install Dependencies + run: bun install + + - name: Run Biome with verbose output + run: bunx biome ci . --verbose diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64b6aa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +logs +/custom +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d93a942 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, creations.works + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7619a74 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# void.backend diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..032f6fa --- /dev/null +++ b/biome.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": ["dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noConsole": "error" + }, + "style": { + "useConst": "error", + "noVar": "error", + "useImportType": "error" + } + }, + "ignore": ["types"] + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..400d2ac --- /dev/null +++ b/bun.lock @@ -0,0 +1,84 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "void.backend", + "dependencies": { + "@atums/echo": "latest", + "cassandra-driver": "latest", + "fast-jwt": "latest", + "pika-id": "latest", + }, + "devDependencies": { + "@biomejs/biome": "latest", + "@types/bun": "latest", + }, + }, + }, + "trustedDependencies": [ + "@biomejs/biome", + ], + "packages": { + "@atums/echo": ["@atums/echo@1.0.3", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-WQ2d4oWTaE+6VeLIu2FepmZipdwUrM+SiiO5moHhSsP4P+MaQCjq5qp34nwB/vOHv2jd9UcBzy27iUziTffCjg=="], + + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + + "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + + "@types/node": ["@types/node@18.19.111", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw=="], + + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + + "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + + "cassandra-driver": ["cassandra-driver@4.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "adm-zip": "~0.5.10", "long": "~5.2.3" } }, "sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "fast-jwt": ["fast-jwt@6.0.2", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", "mnemonist": "^0.40.0" } }, "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "long": ["long@5.2.5", "", {}, "sha512-e0r9YBBgNCq1D1o5Dp8FMH0N5hsFtXDBiVa0qoJPHpakvZkmDKPRoGffZJII/XsHvj9An9blm+cRJ01yQqU+Dw=="], + + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "mnemonist": ["mnemonist@0.40.3", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="], + + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + + "pika-id": ["pika-id@1.1.3", "", {}, "sha512-+82ue4qBu3GipX0ulJOd7lBlNccJuXnt6zquhF6ekk4WiIO98fV54fkUU3NCienmvKrYu97Cqpk5T3jYOtJRVA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + } +} diff --git a/environment/config.ts b/environment/config.ts new file mode 100644 index 0000000..28fb633 --- /dev/null +++ b/environment/config.ts @@ -0,0 +1,69 @@ +import { echo } from "@atums/echo"; +import { validateJWTConfig } from "#lib/validation"; +import { cassandraConfig, validateCassandraConfig } from "./database/cassandra"; +import { jwt } from "./jwt"; + +import type { Environment } from "#types/config"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), +}; + +function verifyRequiredVariables(): void { + const requiredVariables = [ + "HOST", + "PORT", + + "REDIS_URL", + "REDIS_TTL", + + "CASSANDRA_HOST", + "CASSANDRA_PORT", + "CASSANDRA_CONTACT_POINTS", + "CASSANDRA_AUTH_ENABLED", + "CASSANDRA_DATACENTER", + + "JWT_SECRET", + "JWT_EXPIRATION", + "JWT_ISSUER", + + "FRONTEND_FQDN", + ]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + echo.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + const validateCassandra = validateCassandraConfig(cassandraConfig); + + if (!validateCassandra.isValid) { + echo.error("Cassandra configuration validation failed:"); + for (const error of validateCassandra.errors) { + echo.error(`- ${error}`); + } + hasError = true; + } + + const validateJWT = validateJWTConfig(jwt); + + if (!validateJWT.valid) { + echo.error("JWT configuration validation failed:"); + echo.error(`- ${validateJWT.error}`); + hasError = true; + } + + if (hasError) { + process.exit(1); + } +} + +export { environment, verifyRequiredVariables }; diff --git a/environment/constants/database/index.ts b/environment/constants/database/index.ts new file mode 100644 index 0000000..691ce82 --- /dev/null +++ b/environment/constants/database/index.ts @@ -0,0 +1,5 @@ +import { resolve } from "node:path"; + +const migrationsPath = resolve("environment", "database", "migrations"); + +export { migrationsPath }; diff --git a/environment/constants/index.ts b/environment/constants/index.ts new file mode 100644 index 0000000..49ee918 --- /dev/null +++ b/environment/constants/index.ts @@ -0,0 +1,3 @@ +export * from "./server"; +export * from "./validation"; +export * from "./database"; diff --git a/environment/constants/server.ts b/environment/constants/server.ts new file mode 100644 index 0000000..2ff4a9f --- /dev/null +++ b/environment/constants/server.ts @@ -0,0 +1,6 @@ +const reqLoggerIgnores = { + ignoredStartsWith: ["/public"], + ignoredPaths: [""], +}; + +export { reqLoggerIgnores }; diff --git a/environment/constants/validation.ts b/environment/constants/validation.ts new file mode 100644 index 0000000..4cdb432 --- /dev/null +++ b/environment/constants/validation.ts @@ -0,0 +1,37 @@ +import type { genericValidation } from "#types/lib"; + +const nameRestrictions: genericValidation = { + length: { min: 3, max: 20 }, + regex: /^[\p{L}\p{N}._-]+$/u, +}; + +const displayNameRestrictions: genericValidation = { + length: { min: 1, max: 32 }, + regex: /^[\p{L}\p{N}\p{M}\p{S}\p{P}\s]+$/u, +}; + +const forbiddenDisplayNamePatterns = [ + /[\r\n\t]/, + /\s{3,}/, + /^\s|\s$/, + /#everyone|#here/i, + /\p{Cf}/u, + /\p{Cc}/u, +]; + +const passwordRestrictions: genericValidation = { + length: { min: 12, max: 64 }, + regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u, +}; + +const emailRestrictions: { regex: RegExp } = { + regex: /^[^\s#]+#[^\s#]+\.[^\s#]+$/, +}; + +export { + nameRestrictions, + displayNameRestrictions, + forbiddenDisplayNamePatterns, + passwordRestrictions, + emailRestrictions, +}; diff --git a/environment/database/cassandra.ts b/environment/database/cassandra.ts new file mode 100644 index 0000000..41254cc --- /dev/null +++ b/environment/database/cassandra.ts @@ -0,0 +1,114 @@ +import type { CassandraConfig } from "#types/config"; + +function isValidHost(host: string): boolean { + if (!host || host.trim().length === 0) return false; + + if (host === "localhost") return true; + + const ipv4Regex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (ipv4Regex.test(host)) return true; + + const hostnameRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return hostnameRegex.test(host); +} + +function isValidPort(port: number): boolean { + return Number.isInteger(port) && port > 0 && port <= 65535; +} + +function isValidKeyspace(keyspace: string): boolean { + if (!keyspace || keyspace.trim().length === 0) return false; + + const keyspaceRegex = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + return keyspaceRegex.test(keyspace); +} + +function isValidContactPoints(contactPoints: string[]): boolean { + if (!Array.isArray(contactPoints) || contactPoints.length === 0) return false; + + return contactPoints.every((point) => { + const trimmed = point.trim(); + return trimmed.length > 0 && isValidHost(trimmed); + }); +} + +function isValidCredentials( + username: string, + password: string, + authEnabled: boolean, +): boolean { + if (!authEnabled) return true; + + return username.trim().length > 0 && password.trim().length > 0; +} + +function isValidDatacenter(datacenter: string, authEnabled: boolean): boolean { + if (!authEnabled) return true; + + return datacenter.trim().length > 0; +} + +function validateCassandraConfig(config: CassandraConfig): { + isValid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (!isValidHost(config.host)) { + errors.push(`Invalid host: ${config.host}`); + } + + if (!isValidPort(config.port)) { + errors.push( + `Invalid port: ${config.port}. Port must be between 1 and 65535`, + ); + } + + if (!isValidKeyspace(config.keyspace)) { + errors.push( + `Invalid keyspace: ${config.keyspace}. Must start with letter, contain only alphanumeric and underscores, max 48 chars`, + ); + } + + if (!isValidContactPoints(config.contactPoints)) { + errors.push( + `Invalid contact points: ${config.contactPoints.join(", ")}. All contact points must be valid hosts`, + ); + } + + if ( + !isValidCredentials(config.username, config.password, config.authEnabled) + ) { + errors.push( + "Invalid credentials: Username and password are required when authentication is enabled", + ); + } + + if (!isValidDatacenter(config.datacenter, config.authEnabled)) { + errors.push( + "Invalid datacenter: Datacenter is required when authentication is enabled", + ); + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +const rawConfig: CassandraConfig = { + host: process.env.CASSANDRA_HOST || "localhost", + port: Number.parseInt(process.env.CASSANDRA_PORT || "9042", 10), + keyspace: process.env.CASSANDRA_KEYSPACE || "void_db", + username: process.env.CASSANDRA_USERNAME || "", + password: process.env.CASSANDRA_PASSWORD || "", + datacenter: process.env.CASSANDRA_DATACENTER || "", + contactPoints: (process.env.CASSANDRA_CONTACT_POINTS || "localhost") + .split(",") + .map((point) => point.trim()), + authEnabled: process.env.CASSANDRA_AUTH_ENABLED !== "false", +}; + +export { rawConfig as cassandraConfig, validateCassandraConfig }; diff --git a/environment/database/index.ts b/environment/database/index.ts new file mode 100644 index 0000000..a0382f4 --- /dev/null +++ b/environment/database/index.ts @@ -0,0 +1 @@ +export * from "./cassandra"; diff --git a/environment/database/migrations/up/001_create_users.sql b/environment/database/migrations/up/001_create_users.sql new file mode 100644 index 0000000..c39d425 --- /dev/null +++ b/environment/database/migrations/up/001_create_users.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT, + display_name TEXT, + email TEXT, + password TEXT, + avatar_url TEXT, + is_verified BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS users_username_idx ON users (username); +CREATE INDEX IF NOT EXISTS users_email_idx ON users (email); diff --git a/environment/jwt.ts b/environment/jwt.ts new file mode 100644 index 0000000..e9fa186 --- /dev/null +++ b/environment/jwt.ts @@ -0,0 +1,28 @@ +import { getExpirationInSeconds } from "#lib/utils"; +import { validateJWTConfig } from "#lib/validation"; + +import type { JWTConfig } from "#types/config"; + +function createJWTConfig(): JWTConfig { + const jwtSecret = process.env.JWT_SECRET || ""; + const jwtExpiration = process.env.JWT_EXPIRATION || "1h"; + const jwtIssuer = process.env.JWT_ISSUER || ""; + const jwtAlgorithm = process.env.JWT_ALGORITHM || "HS256"; + + const configForValidation: JWTConfig = { + secret: jwtSecret, + expiration: getExpirationInSeconds(jwtExpiration), + issuer: jwtIssuer, + algorithm: jwtAlgorithm, + }; + + const validation = validateJWTConfig(configForValidation); + + if (!validation.valid) { + throw new Error(`JWT Configuration Error: ${validation.error}`); + } + + return configForValidation; +} + +export const jwt = createJWTConfig(); diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..521b3bc --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a63730 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "void.backend", + "module": "src/index.ts", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --hot src/index.ts --dev", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --fix", + "cleanup": "rm -rf logs node_modules bun.lock" + }, + "devDependencies": { + "@biomejs/biome": "latest", + "@types/bun": "latest" + }, + "dependencies": { + "@atums/echo": "latest", + "cassandra-driver": "latest", + "fast-jwt": "latest", + "pika-id": "latest" + }, + "trustedDependencies": [ + "@biomejs/biome" + ] +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..f9354d4 --- /dev/null +++ b/src/commands.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b137066 --- /dev/null +++ b/src/index.ts @@ -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 { + 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 }; diff --git a/src/lib/auth/cookies.ts b/src/lib/auth/cookies.ts new file mode 100644 index 0000000..d1bd3a0 --- /dev/null +++ b/src/lib/auth/cookies.ts @@ -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): 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 }; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..acc5520 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export * from "./jwt"; +export * from "./cookies"; +export * from "./session"; diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts new file mode 100644 index 0000000..ec583fd --- /dev/null +++ b/src/lib/auth/jwt.ts @@ -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 }; diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..ebbe05c --- /dev/null +++ b/src/lib/auth/session.ts @@ -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 { + 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 { + const token = cookieService.extractToken(request); + if (!token) return null; + + return this.getSessionByToken(token); + } + + async getSessionByToken(token: string): Promise { + 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 { + 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 { + 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 { + 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 { + return jwtService.decode(token); + } + + async invalidateSession(request: Request): Promise { + const token = cookieService.extractToken(request); + if (!token) return; + + await this.invalidateSessionByToken(token); + } + + async invalidateSessionByToken(token: string): Promise { + 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 { + 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 { + 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 { + 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 }; diff --git a/src/lib/database/cassandra.ts b/src/lib/database/cassandra.ts new file mode 100644 index 0000000..5b3cbe1 --- /dev/null +++ b/src/lib/database/cassandra.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; diff --git a/src/lib/database/index.ts b/src/lib/database/index.ts new file mode 100644 index 0000000..c0126c0 --- /dev/null +++ b/src/lib/database/index.ts @@ -0,0 +1,2 @@ +export * from "./cassandra"; +export * from "./migrations"; diff --git a/src/lib/database/migrations.ts b/src/lib/database/migrations.ts new file mode 100644 index 0000000..7f86ffa --- /dev/null +++ b/src/lib/database/migrations.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/lib/utils/idGenerator.ts b/src/lib/utils/idGenerator.ts new file mode 100644 index 0000000..b8d9625 --- /dev/null +++ b/src/lib/utils/idGenerator.ts @@ -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 }; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..ed3b52d --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./idGenerator"; +export * from "./jwt"; diff --git a/src/lib/utils/jwt.ts b/src/lib/utils/jwt.ts new file mode 100644 index 0000000..4fbbc33 --- /dev/null +++ b/src/lib/utils/jwt.ts @@ -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 }; diff --git a/src/lib/validation/email.ts b/src/lib/validation/email.ts new file mode 100644 index 0000000..c1b75dc --- /dev/null +++ b/src/lib/validation/email.ts @@ -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 }; diff --git a/src/lib/validation/index.ts b/src/lib/validation/index.ts new file mode 100644 index 0000000..29b453e --- /dev/null +++ b/src/lib/validation/index.ts @@ -0,0 +1,4 @@ +export * from "./name"; +export * from "./password"; +export * from "./email"; +export * from "./jwt"; diff --git a/src/lib/validation/jwt.ts b/src/lib/validation/jwt.ts new file mode 100644 index 0000000..c2fef1d --- /dev/null +++ b/src/lib/validation/jwt.ts @@ -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, +}; diff --git a/src/lib/validation/name.ts b/src/lib/validation/name.ts new file mode 100644 index 0000000..bebaa06 --- /dev/null +++ b/src/lib/validation/name.ts @@ -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 }; diff --git a/src/lib/validation/password.ts b/src/lib/validation/password.ts new file mode 100644 index 0000000..0901c98 --- /dev/null +++ b/src/lib/validation/password.ts @@ -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 }; diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..c4d7e49 --- /dev/null +++ b/src/routes/health.ts @@ -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 { + 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 }; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..36ae55c --- /dev/null +++ b/src/routes/index.ts @@ -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 { + const endPerf: number = Date.now(); + const perf: number = endPerf - request.startPerf; + + const { query, params } = request; + + const response: Record = { + perf, + query, + params, + }; + + return Response.json(response); +} + +export { handler, routeDef }; diff --git a/src/routes/user/[id].ts b/src/routes/user/[id].ts new file mode 100644 index 0000000..53766cb --- /dev/null +++ b/src/routes/user/[id].ts @@ -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 { + 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 }; diff --git a/src/routes/user/login.ts b/src/routes/user/login.ts new file mode 100644 index 0000000..5b809e2 --- /dev/null +++ b/src/routes/user/login.ts @@ -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 { + 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 }; diff --git a/src/routes/user/logout.ts b/src/routes/user/logout.ts new file mode 100644 index 0000000..b1d90a1 --- /dev/null +++ b/src/routes/user/logout.ts @@ -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 { + 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 }; diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts new file mode 100644 index 0000000..8c7d549 --- /dev/null +++ b/src/routes/user/register.ts @@ -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 { + 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 }; diff --git a/src/routes/user/update/info.ts b/src/routes/user/update/info.ts new file mode 100644 index 0000000..fbaeb1f --- /dev/null +++ b/src/routes/user/update/info.ts @@ -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 { + 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 }; diff --git a/src/routes/user/update/password.ts b/src/routes/user/update/password.ts new file mode 100644 index 0000000..315b780 --- /dev/null +++ b/src/routes/user/update/password.ts @@ -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 { + 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 }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..806ce57 --- /dev/null +++ b/src/server.ts @@ -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 { + 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 { + 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 }; diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..1cbe704 --- /dev/null +++ b/src/websocket.ts @@ -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 }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0ed7a0d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "#*": ["src/*"], + "#environment/*": ["environment/*"], + "#types/*": ["types/*"] + }, + "typeRoots": ["./node_modules/@types"], + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": false, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src", "environment"] +} diff --git a/types/config/auth.ts b/types/config/auth.ts new file mode 100644 index 0000000..bdce12c --- /dev/null +++ b/types/config/auth.ts @@ -0,0 +1,32 @@ +type JWTConfig = { + secret: string; + expiration: string | number; + issuer: string; + algorithm: string; +}; + +interface CookieOptions { + secure?: boolean; + httpOnly?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + path?: string; + domain?: string; +} + +interface UserSession { + id: string; + username: string; + email: string; + isVerified: boolean; + displayName: string | null; + createdAt: string; // ISO date string + updatedAt?: string; // ISO date string, optional + iat?: number; // issued at (added by JWT libs) + exp?: number; // expiration (added by JWT libs) +} + +interface SessionData extends UserSession { + userAgent: string; +} + +export type { JWTConfig, CookieOptions, UserSession, SessionData }; diff --git a/types/config/database.ts b/types/config/database.ts new file mode 100644 index 0000000..18b10c7 --- /dev/null +++ b/types/config/database.ts @@ -0,0 +1,25 @@ +type CassandraConfig = { + host: string; + port: number; + keyspace: string; + username: string; + password: string; + datacenter: string; + contactPoints: string[]; + authEnabled: boolean; +}; + +type SqlMigration = { + id: string; + name: string; + upSql: string; + downSql?: string | undefined; +}; + +type ConnectionOptions = { + withKeyspace?: boolean; + timeout?: number; + logging?: boolean; +}; + +export type { CassandraConfig, SqlMigration, ConnectionOptions }; diff --git a/types/config/environment.ts b/types/config/environment.ts new file mode 100644 index 0000000..990a762 --- /dev/null +++ b/types/config/environment.ts @@ -0,0 +1,7 @@ +type Environment = { + port: number; + host: string; + development: boolean; +}; + +export type { Environment }; diff --git a/types/config/index.ts b/types/config/index.ts new file mode 100644 index 0000000..168f0c1 --- /dev/null +++ b/types/config/index.ts @@ -0,0 +1,3 @@ +export * from "./environment"; +export * from "./database"; +export * from "./auth"; diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..88c1726 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,3 @@ +export * from "./server"; +export * from "./config"; +export * from "./lib"; diff --git a/types/lib/index.ts b/types/lib/index.ts new file mode 100644 index 0000000..99455b8 --- /dev/null +++ b/types/lib/index.ts @@ -0,0 +1 @@ +export * from "./validation"; diff --git a/types/lib/validation.ts b/types/lib/validation.ts new file mode 100644 index 0000000..c65bf6c --- /dev/null +++ b/types/lib/validation.ts @@ -0,0 +1,13 @@ +type genericValidation = { + length: { min: number; max: number }; + regex: RegExp; +}; + +type validationResult = { + valid: boolean; + error?: string; + username?: string; + name?: string; +}; + +export type { genericValidation, validationResult }; diff --git a/types/server/index.ts b/types/server/index.ts new file mode 100644 index 0000000..66bdd6d --- /dev/null +++ b/types/server/index.ts @@ -0,0 +1,3 @@ +export * from "./server"; +export * from "./routes"; +export * from "./requests"; diff --git a/types/server/requests/index.ts b/types/server/requests/index.ts new file mode 100644 index 0000000..7616f9e --- /dev/null +++ b/types/server/requests/index.ts @@ -0,0 +1 @@ +export * from "./user"; diff --git a/types/server/requests/user/base.ts b/types/server/requests/user/base.ts new file mode 100644 index 0000000..b386e59 --- /dev/null +++ b/types/server/requests/user/base.ts @@ -0,0 +1,28 @@ +interface BaseResponse { + code: number; + success: boolean; + error?: string; + message?: string; +} + +interface UserResponse { + id: string; + username: string; + email: string; + isVerified: boolean; + createdAt: string; + displayName: string | null; +} + +interface UserRow { + id: string; + username: string; + display_name: string | null; + email: string; + password: string; + is_verified: boolean; + created_at: Date; + updated_at: Date; +} + +export type { BaseResponse, UserResponse, UserRow }; diff --git a/types/server/requests/user/index.ts b/types/server/requests/user/index.ts new file mode 100644 index 0000000..844adaf --- /dev/null +++ b/types/server/requests/user/index.ts @@ -0,0 +1,6 @@ +export * from "./base"; +export * from "./responses"; +export * from "./register"; +export * from "./login"; + +export * from "./update"; diff --git a/types/server/requests/user/login.ts b/types/server/requests/user/login.ts new file mode 100644 index 0000000..ad6fd62 --- /dev/null +++ b/types/server/requests/user/login.ts @@ -0,0 +1,12 @@ +import type { BaseResponse, UserResponse } from "./base"; + +interface LoginRequest { + identifier: string; // Username or email + password: string; +} + +interface LoginResponse extends BaseResponse { + user?: UserResponse; +} + +export type { LoginRequest, LoginResponse }; diff --git a/types/server/requests/user/password.ts b/types/server/requests/user/password.ts new file mode 100644 index 0000000..dde9e1c --- /dev/null +++ b/types/server/requests/user/password.ts @@ -0,0 +1,7 @@ +interface UpdatePasswordRequest { + currentPassword: string; + newPassword: string; + logoutAllSessions?: boolean; // defaults to false +} + +export type { UpdatePasswordRequest }; diff --git a/types/server/requests/user/register.ts b/types/server/requests/user/register.ts new file mode 100644 index 0000000..21ff477 --- /dev/null +++ b/types/server/requests/user/register.ts @@ -0,0 +1,14 @@ +import type { BaseResponse, UserResponse } from "./base"; + +interface RegisterRequest { + username: string; + displayName: string | null; + email: string; + password: string; +} + +interface RegisterResponse extends BaseResponse { + user?: UserResponse; +} + +export type { RegisterRequest, RegisterResponse }; diff --git a/types/server/requests/user/responses.ts b/types/server/requests/user/responses.ts new file mode 100644 index 0000000..72bbd9c --- /dev/null +++ b/types/server/requests/user/responses.ts @@ -0,0 +1,7 @@ +import type { BaseResponse, UserResponse } from "./base"; + +interface UserInfoResponse extends BaseResponse { + user?: UserResponse; +} + +export type { UserInfoResponse }; diff --git a/types/server/requests/user/update/index.ts b/types/server/requests/user/update/index.ts new file mode 100644 index 0000000..24c894c --- /dev/null +++ b/types/server/requests/user/update/index.ts @@ -0,0 +1,2 @@ +export * from "./info"; +export * from "./password"; diff --git a/types/server/requests/user/update/info.ts b/types/server/requests/user/update/info.ts new file mode 100644 index 0000000..fac594f --- /dev/null +++ b/types/server/requests/user/update/info.ts @@ -0,0 +1,13 @@ +import type { BaseResponse, UserResponse } from "../base"; + +interface UpdateInfoRequest { + username?: string; + displayName?: string | null; + email?: string; +} + +interface UpdateInfoResponse extends BaseResponse { + user?: UserResponse; +} + +export type { UpdateInfoRequest, UpdateInfoResponse }; diff --git a/types/server/requests/user/update/password.ts b/types/server/requests/user/update/password.ts new file mode 100644 index 0000000..3cd637a --- /dev/null +++ b/types/server/requests/user/update/password.ts @@ -0,0 +1,14 @@ +import type { BaseResponse, UserResponse } from "../base"; + +interface UpdatePasswordRequest { + currentPassword: string; + newPassword: string; + logoutAllSessions?: boolean; // defaults to false +} + +interface UpdatePasswordResponse extends BaseResponse { + user?: UserResponse; + loggedOutSessions?: number; +} + +export type { UpdatePasswordRequest, UpdatePasswordResponse }; diff --git a/types/server/routes.ts b/types/server/routes.ts new file mode 100644 index 0000000..d325488 --- /dev/null +++ b/types/server/routes.ts @@ -0,0 +1,20 @@ +import type { Server } from "bun"; +import type { ExtendedRequest } from "./server"; + +type RouteDef = { + method: string | string[]; + accepts: string | null | string[]; + returns: string; + needsBody?: "multipart" | "json"; +}; + +type RouteModule = { + handler: ( + request: Request | ExtendedRequest, + requestBody: unknown, + server: Server, + ) => Promise | Response; + routeDef: RouteDef; +}; + +export type { RouteDef, RouteModule }; diff --git a/types/server/server.ts b/types/server/server.ts new file mode 100644 index 0000000..3eb18d0 --- /dev/null +++ b/types/server/server.ts @@ -0,0 +1,10 @@ +type Query = Record; +type Params = Record; + +interface ExtendedRequest extends Request { + startPerf: number; + query: Query; + params: Params; +} + +export type { ExtendedRequest, Query, Params };