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
|
@ -82,9 +82,9 @@ function showHelp(): void {
|
|||
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");
|
||||
echo.info(" bun run command --reset cassandra");
|
||||
echo.info(" bun run command --reset redis");
|
||||
echo.info(" bun run command --reset all");
|
||||
}
|
||||
|
||||
export async function handleCommands(): Promise<boolean> {
|
||||
|
|
93
src/environment/config.ts
Normal file
93
src/environment/config.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import { validateJWTConfig, validateMailerConfig } from "#lib/validation";
|
||||
import { isValidUrl } from "#lib/validation/url";
|
||||
import { cassandraConfig, validateCassandraConfig } from "./database/cassandra";
|
||||
import { jwt } from "./jwt";
|
||||
import { mailerConfig } from "./mailer";
|
||||
|
||||
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"),
|
||||
fqdn: process.env.FRONTEND_FQDN || "",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export { environment, verifyRequiredVariables };
|
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 };
|
3
src/environment/constants/index.ts
Normal file
3
src/environment/constants/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./server";
|
||||
export * from "./validation";
|
||||
export * from "./database";
|
6
src/environment/constants/server.ts
Normal file
6
src/environment/constants/server.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const reqLoggerIgnores = {
|
||||
ignoredStartsWith: ["/public"],
|
||||
ignoredPaths: [""],
|
||||
};
|
||||
|
||||
export { reqLoggerIgnores };
|
37
src/environment/constants/validation.ts
Normal file
37
src/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
src/environment/database/cassandra.ts
Normal file
114
src/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
src/environment/database/index.ts
Normal file
1
src/environment/database/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./cassandra";
|
14
src/environment/database/migrations/up/001_create_users.sql
Normal file
14
src/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);
|
21
src/environment/jwt.ts
Normal file
21
src/environment/jwt.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { getExpirationInSeconds } from "#lib/utils";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return configForValidation;
|
||||
}
|
||||
|
||||
export const jwt = createJWTConfig();
|
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 "./email";
|
||||
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 { cassandra } from "#lib/database";
|
||||
|
||||
import type { ExtendedRequest, RouteDef } from "#types/server";
|
||||
import type { ExtendedRequest, HealthResponse, RouteDef } from "#types/server";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -9,21 +9,42 @@ const routeDef: RouteDef = {
|
|||
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> {
|
||||
const cassandraHealth = cassandra.getHealthStatus();
|
||||
const redisHealth = await redis
|
||||
.connect()
|
||||
.then(() => "healthy")
|
||||
.catch(() => "unhealthy");
|
||||
const redisHealth = await checkRedisHealth();
|
||||
|
||||
return Response.json({
|
||||
status: "healthy",
|
||||
const isHealthy = cassandraHealth.connected && redisHealth === "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(),
|
||||
requestTime: `${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||
services: {
|
||||
cassandra: cassandraHealth,
|
||||
redis: redisHealth,
|
||||
cassandra: {
|
||||
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 = {
|
||||
code: 404,
|
||||
success: false,
|
||||
error: identifier ? "User not found" : "User not found",
|
||||
error: "User not found",
|
||||
};
|
||||
return Response.json(response, { status: 404 });
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { resolve } from "node:path";
|
||||
import { type Echo, echo } from "@atums/echo";
|
||||
import { environment } from "#environment/config";
|
||||
import { reqLoggerIgnores } from "#environment/constants/server";
|
||||
import { reqLoggerIgnores } from "#environment/constants";
|
||||
import { noFileLog } from "#index";
|
||||
import { webSocketHandler } from "#websocket";
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue