move environment to src/environment add smtp env vars, move some other items
Some checks failed
Code quality checks / biome (push) Failing after 13s
Some checks failed
Code quality checks / biome (push) Failing after 13s
This commit is contained in:
parent
421043c9b5
commit
00a7417936
30 changed files with 470 additions and 42 deletions
3
bun.lock
3
bun.lock
|
@ -7,6 +7,7 @@
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"pika-id": "latest",
|
"pika-id": "latest",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -71,6 +72,8 @@
|
||||||
|
|
||||||
"mnemonist": ["mnemonist@0.40.3", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="],
|
"mnemonist": ["mnemonist@0.40.3", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="],
|
||||||
|
|
||||||
|
"nodemailer": ["nodemailer@7.0.3", "", {}, "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw=="],
|
||||||
|
|
||||||
"obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="],
|
"obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="],
|
||||||
|
|
||||||
"pika-id": ["pika-id@1.1.3", "", {}, "sha512-+82ue4qBu3GipX0ulJOd7lBlNccJuXnt6zquhF6ekk4WiIO98fV54fkUU3NCienmvKrYu97Cqpk5T3jYOtJRVA=="],
|
"pika-id": ["pika-id@1.1.3", "", {}, "sha512-+82ue4qBu3GipX0ulJOd7lBlNccJuXnt6zquhF6ekk4WiIO98fV54fkUU3NCienmvKrYu97Cqpk5T3jYOtJRVA=="],
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
const migrationsPath = resolve("environment", "database", "migrations");
|
|
||||||
|
|
||||||
export { migrationsPath };
|
|
|
@ -4,6 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
"command": "bun run src/command.ts",
|
||||||
"dev": "bun run --hot src/index.ts --dev",
|
"dev": "bun run --hot src/index.ts --dev",
|
||||||
"lint": "bunx biome check",
|
"lint": "bunx biome check",
|
||||||
"lint:fix": "bunx biome check --fix",
|
"lint:fix": "bunx biome check --fix",
|
||||||
|
@ -17,9 +18,8 @@
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"pika-id": "latest"
|
"pika-id": "latest"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": ["@biomejs/biome"]
|
||||||
"@biomejs/biome"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,9 +82,9 @@ function showHelp(): void {
|
||||||
echo.info(" --help Show this help message");
|
echo.info(" --help Show this help message");
|
||||||
echo.info("");
|
echo.info("");
|
||||||
echo.info("Examples:");
|
echo.info("Examples:");
|
||||||
echo.info(" bun run src/index.ts --reset cassandra");
|
echo.info(" bun run command --reset cassandra");
|
||||||
echo.info(" bun run src/index.ts --reset redis");
|
echo.info(" bun run command --reset redis");
|
||||||
echo.info(" bun run src/index.ts --reset all");
|
echo.info(" bun run command --reset all");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCommands(): Promise<boolean> {
|
export async function handleCommands(): Promise<boolean> {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { echo } from "@atums/echo";
|
import { echo } from "@atums/echo";
|
||||||
import { validateJWTConfig } from "#lib/validation";
|
import { validateJWTConfig, validateMailerConfig } from "#lib/validation";
|
||||||
|
import { isValidUrl } from "#lib/validation/url";
|
||||||
import { cassandraConfig, validateCassandraConfig } from "./database/cassandra";
|
import { cassandraConfig, validateCassandraConfig } from "./database/cassandra";
|
||||||
import { jwt } from "./jwt";
|
import { jwt } from "./jwt";
|
||||||
|
import { mailerConfig } from "./mailer";
|
||||||
|
|
||||||
import type { Environment } from "#types/config";
|
import type { Environment } from "#types/config";
|
||||||
|
|
||||||
|
@ -10,6 +12,7 @@ const environment: Environment = {
|
||||||
host: process.env.HOST || "0.0.0.0",
|
host: process.env.HOST || "0.0.0.0",
|
||||||
development:
|
development:
|
||||||
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
||||||
|
fqdn: process.env.FRONTEND_FQDN || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function verifyRequiredVariables(): void {
|
function verifyRequiredVariables(): void {
|
||||||
|
@ -44,7 +47,6 @@ function verifyRequiredVariables(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateCassandra = validateCassandraConfig(cassandraConfig);
|
const validateCassandra = validateCassandraConfig(cassandraConfig);
|
||||||
|
|
||||||
if (!validateCassandra.isValid) {
|
if (!validateCassandra.isValid) {
|
||||||
echo.error("Cassandra configuration validation failed:");
|
echo.error("Cassandra configuration validation failed:");
|
||||||
for (const error of validateCassandra.errors) {
|
for (const error of validateCassandra.errors) {
|
||||||
|
@ -54,13 +56,35 @@ function verifyRequiredVariables(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateJWT = validateJWTConfig(jwt);
|
const validateJWT = validateJWTConfig(jwt);
|
||||||
|
|
||||||
if (!validateJWT.valid) {
|
if (!validateJWT.valid) {
|
||||||
echo.error("JWT configuration validation failed:");
|
echo.error("JWT configuration validation failed:");
|
||||||
echo.error(`- ${validateJWT.error}`);
|
echo.error(`- ${validateJWT.error}`);
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateMailer = validateMailerConfig(mailerConfig);
|
||||||
|
if (!validateMailer.isValid) {
|
||||||
|
echo.error("Mailer configuration validation failed:");
|
||||||
|
for (const error of validateMailer.errors) {
|
||||||
|
echo.error(`- ${error}`);
|
||||||
|
}
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlValidation = isValidUrl(environment.fqdn, {
|
||||||
|
requireProtocol: true,
|
||||||
|
failOnTrailingSlash: true,
|
||||||
|
allowedProtocols: ["http", "https"],
|
||||||
|
allowLocalhost: environment.development,
|
||||||
|
allowIP: environment.development,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!urlValidation.valid) {
|
||||||
|
echo.error("FRONTEND_FQDN validation failed:");
|
||||||
|
echo.error(`- ${urlValidation.error}`);
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
5
src/environment/constants/database/index.ts
Normal file
5
src/environment/constants/database/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const migrationsPath = resolve("src", "environment", "database", "migrations");
|
||||||
|
|
||||||
|
export { migrationsPath };
|
|
@ -1,5 +1,4 @@
|
||||||
import { getExpirationInSeconds } from "#lib/utils";
|
import { getExpirationInSeconds } from "#lib/utils";
|
||||||
import { validateJWTConfig } from "#lib/validation";
|
|
||||||
|
|
||||||
import type { JWTConfig } from "#types/config";
|
import type { JWTConfig } from "#types/config";
|
||||||
|
|
||||||
|
@ -16,12 +15,6 @@ function createJWTConfig(): JWTConfig {
|
||||||
algorithm: jwtAlgorithm,
|
algorithm: jwtAlgorithm,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validation = validateJWTConfig(configForValidation);
|
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new Error(`JWT Configuration Error: ${validation.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return configForValidation;
|
return configForValidation;
|
||||||
}
|
}
|
||||||
|
|
39
src/environment/mailer.ts
Normal file
39
src/environment/mailer.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import type { MailerConfig } from "#types/config";
|
||||||
|
|
||||||
|
function createMailerConfig(): MailerConfig {
|
||||||
|
const smtpAddress = process.env.SMTP_ADDRESS || "";
|
||||||
|
const smtpPort = process.env.SMTP_PORT || "587";
|
||||||
|
const smtpFrom = process.env.SMTP_FROM || "";
|
||||||
|
const smtpUsername = process.env.SMTP_USERNAME || "";
|
||||||
|
const smtpPassword = process.env.SMTP_PASSWORD || "";
|
||||||
|
const smtpSecure = process.env.SMTP_SECURE || "false";
|
||||||
|
const smtpPool = process.env.SMTP_POOL || "true";
|
||||||
|
|
||||||
|
const configForValidation: MailerConfig = {
|
||||||
|
address: smtpAddress,
|
||||||
|
port: Number.parseInt(smtpPort, 10),
|
||||||
|
from: smtpFrom,
|
||||||
|
username: smtpUsername,
|
||||||
|
password: smtpPassword,
|
||||||
|
secure: smtpSecure === "true",
|
||||||
|
pool: smtpPool === "true",
|
||||||
|
connectionTimeout: Number.parseInt(
|
||||||
|
process.env.SMTP_CONNECTION_TIMEOUT || "30000",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
socketTimeout: Number.parseInt(
|
||||||
|
process.env.SMTP_SOCKET_TIMEOUT || "30000",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
maxConnections: Number.parseInt(
|
||||||
|
process.env.SMTP_MAX_CONNECTIONS || "5",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
maxMessages: Number.parseInt(process.env.SMTP_MAX_MESSAGES || "100", 10),
|
||||||
|
replyTo: process.env.SMTP_REPLY_TO || smtpFrom,
|
||||||
|
};
|
||||||
|
|
||||||
|
return configForValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailerConfig = createMailerConfig();
|
99
src/lib/validation/general.ts
Normal file
99
src/lib/validation/general.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
function isValidPort(port: number): boolean {
|
||||||
|
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPAddress(hostname: string): boolean {
|
||||||
|
// IPv4
|
||||||
|
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]?)$/;
|
||||||
|
|
||||||
|
// IPv6 (simplified)
|
||||||
|
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/;
|
||||||
|
|
||||||
|
return ipv4Regex.test(hostname) || ipv6Regex.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHostname(hostname: string): boolean {
|
||||||
|
if (!hostname || hostname.length === 0 || hostname.length > 253) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for valid hostname format
|
||||||
|
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])?)*$/;
|
||||||
|
|
||||||
|
if (!hostnameRegex.test(hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname.startsWith("-") || hostname.endsWith("-")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = hostname.split(".");
|
||||||
|
for (const label of labels) {
|
||||||
|
if (label.startsWith("-") || label.endsWith("-") || label.length > 63) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSuspiciousPatterns(url: string): boolean {
|
||||||
|
// check for multiple dots in a row
|
||||||
|
if (/\.{2,}/.test(url)) return true;
|
||||||
|
|
||||||
|
// check for suspicious URL encoding
|
||||||
|
if (/%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/.test(url)) return true;
|
||||||
|
|
||||||
|
// check for double slashes in path (excluding protocol)
|
||||||
|
if (/(?<!:)\/\//.test(url)) return true;
|
||||||
|
|
||||||
|
// check for control characters using character codes
|
||||||
|
for (let i = 0; i < url.length; i++) {
|
||||||
|
const charCode = url.charCodeAt(i);
|
||||||
|
|
||||||
|
// C0 control characters (0x00-0x1F) and DEL + C1 control characters (0x7F-0x9F)
|
||||||
|
if (
|
||||||
|
(charCode >= 0x00 && charCode <= 0x1f) ||
|
||||||
|
(charCode >= 0x7f && charCode <= 0x9f)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unicode zero-width and formatting characters
|
||||||
|
if (
|
||||||
|
charCode === 0x200b ||
|
||||||
|
charCode === 0x200c ||
|
||||||
|
charCode === 0x200d ||
|
||||||
|
charCode === 0xfeff
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLength(value: string, min: number, max: number): boolean {
|
||||||
|
return value.length >= min && value.length <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(value: string): boolean {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value: string): string {
|
||||||
|
return value.trim().normalize("NFC");
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidPort,
|
||||||
|
isIPAddress,
|
||||||
|
isValidHostname,
|
||||||
|
hasSuspiciousPatterns,
|
||||||
|
isValidLength,
|
||||||
|
isNonEmptyString,
|
||||||
|
normalizeString,
|
||||||
|
};
|
|
@ -2,3 +2,6 @@ export * from "./name";
|
||||||
export * from "./password";
|
export * from "./password";
|
||||||
export * from "./email";
|
export * from "./email";
|
||||||
export * from "./jwt";
|
export * from "./jwt";
|
||||||
|
export * from "./url";
|
||||||
|
export * from "./general";
|
||||||
|
export * from "./mailer";
|
||||||
|
|
50
src/lib/validation/mailer.ts
Normal file
50
src/lib/validation/mailer.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { isValidEmail } from "./email";
|
||||||
|
import { isValidHostname, isValidPort } from "./general";
|
||||||
|
|
||||||
|
import type { MailerConfig } from "#types/config";
|
||||||
|
|
||||||
|
function validateMailerConfig(config: MailerConfig): {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const isValidSMTPAddress = isValidHostname(config.address);
|
||||||
|
|
||||||
|
if (!isValidSMTPAddress) {
|
||||||
|
errors.push(`Invalid SMTP address: ${config.address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPort(config.port)) {
|
||||||
|
errors.push(
|
||||||
|
`Invalid SMTP port: ${config.port}. Port must be between 1 and 65535`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(config.from)) {
|
||||||
|
errors.push(`Invalid from email address: ${config.from}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(config.username)) {
|
||||||
|
errors.push(`Invalid username email address: ${config.username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.password || config.password.trim().length === 0) {
|
||||||
|
errors.push("SMTP password is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.port === 465 && !config.secure) {
|
||||||
|
errors.push("Port 465 requires SMTP_SECURE=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.port === 587 && config.secure) {
|
||||||
|
errors.push("Port 587 typically uses SMTP_SECURE=false with STARTTLS");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isValidPort, isValidEmail, validateMailerConfig };
|
113
src/lib/validation/url.ts
Normal file
113
src/lib/validation/url.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { hasSuspiciousPatterns, isIPAddress, isValidHostname } from "./general";
|
||||||
|
|
||||||
|
import type { UrlValidationOptions, UrlValidationResult } from "#types/lib";
|
||||||
|
|
||||||
|
function isValidUrl(
|
||||||
|
rawUrl: string,
|
||||||
|
options: UrlValidationOptions = {},
|
||||||
|
): UrlValidationResult {
|
||||||
|
const {
|
||||||
|
failOnTrailingSlash = false,
|
||||||
|
removeTrailingSlash = false,
|
||||||
|
allowedProtocols = ["http", "https"],
|
||||||
|
requireProtocol = true,
|
||||||
|
allowLocalhost = true,
|
||||||
|
allowIP = true,
|
||||||
|
maxLength = 2048,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (typeof rawUrl !== "string") {
|
||||||
|
return { valid: false, error: "URL must be a string" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = rawUrl.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return { valid: false, error: "URL is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.length > maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `URL exceeds maximum length of ${maxLength} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlToValidate = url;
|
||||||
|
|
||||||
|
// add protocol if missing and required
|
||||||
|
if (requireProtocol && !url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:/)) {
|
||||||
|
urlToValidate = `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(urlToValidate);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: "Invalid URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = parsedUrl.protocol.slice(0, -1);
|
||||||
|
if (!allowedProtocols.includes(protocol)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid protocol: ${protocol}. Allowed protocols: ${allowedProtocols.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsedUrl.hostname.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!allowLocalhost &&
|
||||||
|
(hostname === "localhost" || hostname === "127.0.0.1")
|
||||||
|
) {
|
||||||
|
return { valid: false, error: "Localhost URLs are not allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowIP && isIPAddress(hostname)) {
|
||||||
|
return { valid: false, error: "IP addresses are not allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidHostname(hostname) && !isIPAddress(hostname)) {
|
||||||
|
return { valid: false, error: "Invalid hostname format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSuspiciousPatterns(url)) {
|
||||||
|
return { valid: false, error: "URL contains suspicious patterns" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHasTrailingSlash =
|
||||||
|
urlToValidate.endsWith("/") && !urlToValidate.endsWith("://");
|
||||||
|
|
||||||
|
if (originalHasTrailingSlash && failOnTrailingSlash) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "URL must not end with a trailing slash",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUrl = parsedUrl.toString();
|
||||||
|
|
||||||
|
const normalizedHasTrailingSlash =
|
||||||
|
normalizedUrl.endsWith("/") && normalizedUrl !== `${parsedUrl.origin}/`;
|
||||||
|
|
||||||
|
if (normalizedHasTrailingSlash && removeTrailingSlash) {
|
||||||
|
normalizedUrl = normalizedUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
url: urlToValidate,
|
||||||
|
normalizedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(
|
||||||
|
url: string,
|
||||||
|
options: UrlValidationOptions = {},
|
||||||
|
): string | null {
|
||||||
|
const result = isValidUrl(url, options);
|
||||||
|
return result.valid ? result.normalizedUrl || result.url || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isValidUrl, normalizeUrl };
|
|
@ -1,7 +1,7 @@
|
||||||
import { redis } from "bun";
|
import { redis } from "bun";
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
|
|
||||||
import type { ExtendedRequest, RouteDef } from "#types/server";
|
import type { ExtendedRequest, HealthResponse, RouteDef } from "#types/server";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -9,21 +9,42 @@ const routeDef: RouteDef = {
|
||||||
returns: "application/json",
|
returns: "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function checkRedisHealth(): Promise<string> {
|
||||||
|
try {
|
||||||
|
await redis.exists("__health_check__");
|
||||||
|
return "healthy";
|
||||||
|
} catch (error) {
|
||||||
|
return "unhealthy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const cassandraHealth = cassandra.getHealthStatus();
|
const cassandraHealth = cassandra.getHealthStatus();
|
||||||
const redisHealth = await redis
|
const redisHealth = await checkRedisHealth();
|
||||||
.connect()
|
|
||||||
.then(() => "healthy")
|
|
||||||
.catch(() => "unhealthy");
|
|
||||||
|
|
||||||
return Response.json({
|
const isHealthy = cassandraHealth.connected && redisHealth === "healthy";
|
||||||
status: "healthy",
|
|
||||||
|
const response: HealthResponse = {
|
||||||
|
code: isHealthy ? 200 : 503,
|
||||||
|
success: isHealthy,
|
||||||
|
message: isHealthy
|
||||||
|
? "All services are healthy"
|
||||||
|
: "One or more services are unhealthy",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
requestTime: `${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
requestTime: `${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||||
services: {
|
services: {
|
||||||
cassandra: cassandraHealth,
|
cassandra: {
|
||||||
redis: redisHealth,
|
status: cassandraHealth.connected ? "healthy" : "unhealthy",
|
||||||
|
hosts: cassandraHealth.hosts,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
status: redisHealth,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(response, {
|
||||||
|
status: isHealthy ? 200 : 503,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const response: UserInfoResponse = {
|
const response: UserInfoResponse = {
|
||||||
code: 404,
|
code: 404,
|
||||||
success: false,
|
success: false,
|
||||||
error: identifier ? "User not found" : "User not found",
|
error: "User not found",
|
||||||
};
|
};
|
||||||
return Response.json(response, { status: 404 });
|
return Response.json(response, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { type Echo, echo } from "@atums/echo";
|
import { type Echo, echo } from "@atums/echo";
|
||||||
import { environment } from "#environment/config";
|
import { environment } from "#environment/config";
|
||||||
import { reqLoggerIgnores } from "#environment/constants/server";
|
import { reqLoggerIgnores } from "#environment/constants";
|
||||||
import { noFileLog } from "#index";
|
import { noFileLog } from "#index";
|
||||||
import { webSocketHandler } from "#websocket";
|
import { webSocketHandler } from "#websocket";
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"#*": ["src/*"],
|
"#*": ["src/*"],
|
||||||
"#environment/*": ["environment/*"],
|
|
||||||
"#types/*": ["types/*"]
|
"#types/*": ["types/*"]
|
||||||
},
|
},
|
||||||
"typeRoots": ["./node_modules/@types"],
|
"typeRoots": ["./node_modules/@types"],
|
||||||
|
|
|
@ -2,6 +2,7 @@ type Environment = {
|
||||||
port: number;
|
port: number;
|
||||||
host: string;
|
host: string;
|
||||||
development: boolean;
|
development: boolean;
|
||||||
|
fqdn: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { Environment };
|
export type { Environment };
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./environment";
|
export * from "./environment";
|
||||||
export * from "./database";
|
export * from "./database";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./mailer";
|
||||||
|
|
42
types/config/mailer.ts
Normal file
42
types/config/mailer.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
type MailerConfig = {
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
from: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
secure: boolean;
|
||||||
|
pool: boolean;
|
||||||
|
connectionTimeout: number;
|
||||||
|
socketTimeout: number;
|
||||||
|
maxConnections: number;
|
||||||
|
maxMessages: number;
|
||||||
|
replyTo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailOptions = {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
from?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
cc?: string | string[];
|
||||||
|
bcc?: string | string[];
|
||||||
|
attachments?: EmailAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailAttachment = {
|
||||||
|
filename: string;
|
||||||
|
content?: Buffer | string;
|
||||||
|
path?: string;
|
||||||
|
contentType?: string;
|
||||||
|
cid?: string; // content-ID for embedded images
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailResult = {
|
||||||
|
success: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { MailerConfig, EmailOptions, EmailAttachment, EmailResult };
|
|
@ -10,4 +10,24 @@ type validationResult = {
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { genericValidation, validationResult };
|
interface UrlValidationOptions {
|
||||||
|
failOnTrailingSlash?: boolean;
|
||||||
|
removeTrailingSlash?: boolean;
|
||||||
|
allowedProtocols?: string[];
|
||||||
|
requireProtocol?: boolean;
|
||||||
|
allowLocalhost?: boolean;
|
||||||
|
allowIP?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UrlValidationResult extends validationResult {
|
||||||
|
url?: string;
|
||||||
|
normalizedUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
genericValidation,
|
||||||
|
validationResult,
|
||||||
|
UrlValidationOptions,
|
||||||
|
UrlValidationResult,
|
||||||
|
};
|
||||||
|
|
8
types/server/requests/base.ts
Normal file
8
types/server/requests/base.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
interface BaseResponse {
|
||||||
|
code: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { BaseResponse };
|
17
types/server/requests/health.ts
Normal file
17
types/server/requests/health.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import type { BaseResponse } from "./base";
|
||||||
|
|
||||||
|
interface HealthResponse extends BaseResponse {
|
||||||
|
timestamp?: string;
|
||||||
|
requestTime?: string;
|
||||||
|
services?: {
|
||||||
|
cassandra: {
|
||||||
|
status: string;
|
||||||
|
hosts: number;
|
||||||
|
};
|
||||||
|
redis: {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { HealthResponse };
|
|
@ -1 +1,3 @@
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
export * from "./health";
|
||||||
|
export * from "./base";
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
interface BaseResponse {
|
|
||||||
code: number;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserResponse {
|
interface UserResponse {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -25,4 +18,4 @@ interface UserRow {
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { BaseResponse, UserResponse, UserRow };
|
export type { UserResponse, UserRow };
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue