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