re-order alot, move to bun redis, generalized
All checks were successful
Code quality checks / biome (push) Successful in 8s
All checks were successful
Code quality checks / biome (push) Successful in 8s
This commit is contained in:
parent
a646607597
commit
8a9499be85
51 changed files with 559 additions and 916 deletions
10
.env.example
10
.env.example
|
@ -2,16 +2,18 @@
|
|||
HOST=0.0.0.0
|
||||
PORT=9090
|
||||
|
||||
# Replace with your domain name or IP address
|
||||
# If you are using a reverse proxy, set the FQDN to your domain name
|
||||
FQDN=localhost:9090
|
||||
|
||||
PGHOST=localhost
|
||||
PGPORT=5432
|
||||
PGUSERNAME=postgres
|
||||
PGPASSWORD=postgres
|
||||
PGDATABASE=postgres
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
# REDIS_USERNAME=redis
|
||||
# REDIS_PASSWORD=redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_TTL=3600
|
||||
|
||||
# For sessions and cookies, can be generated using `openssl rand -base64 32`
|
||||
JWT_SECRET=your_jwt_secret
|
||||
|
|
11
biome.json
11
biome.json
|
@ -17,10 +17,19 @@
|
|||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedImports": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
export 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"),
|
||||
};
|
||||
|
||||
export const redisConfig: {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | undefined;
|
||||
password?: string | undefined;
|
||||
} = {
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||
username: process.env.REDIS_USERNAME || undefined,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
};
|
||||
|
||||
export const jwt: {
|
||||
secret: string;
|
||||
expiresIn: string;
|
||||
} = {
|
||||
secret: process.env.JWT_SECRET || "",
|
||||
expiresIn: process.env.JWT_EXPIRES || "1d",
|
||||
};
|
||||
|
||||
export const dataType: { type: string; path: string | undefined } = {
|
||||
type: process.env.DATASOURCE_TYPE || "local",
|
||||
path:
|
||||
process.env.DATASOURCE_TYPE === "local"
|
||||
? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads")
|
||||
: undefined,
|
||||
};
|
61
config/index.ts
Normal file
61
config/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { resolve } from "node:path";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { normalizeFqdn } from "@lib/char";
|
||||
|
||||
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: normalizeFqdn(process.env.FQDN) || "http://localhost:8080",
|
||||
};
|
||||
|
||||
const dataType: { type: string; path: string | undefined } = {
|
||||
type: process.env.DATASOURCE_TYPE || "local",
|
||||
path:
|
||||
process.env.DATASOURCE_TYPE === "local"
|
||||
? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads")
|
||||
: undefined,
|
||||
};
|
||||
|
||||
function verifyRequiredVariables(): void {
|
||||
const requiredVariables = [
|
||||
"HOST",
|
||||
"PORT",
|
||||
|
||||
"FQDN",
|
||||
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSERNAME",
|
||||
"PGPASSWORD",
|
||||
"PGDATABASE",
|
||||
|
||||
"REDIS_URL",
|
||||
"REDIS_TTL",
|
||||
|
||||
"JWT_SECRET",
|
||||
"JWT_EXPIRES",
|
||||
|
||||
"DATASOURCE_TYPE",
|
||||
];
|
||||
|
||||
let hasError = false;
|
||||
|
||||
for (const key of requiredVariables) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value.trim() === "") {
|
||||
logger.error(`Missing or empty environment variable: ${key}`);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export * from "@config/jwt";
|
||||
export * from "@config/redis";
|
||||
|
||||
export { environment, dataType, verifyRequiredVariables };
|
27
config/jwt.ts
Normal file
27
config/jwt.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
const allowedAlgorithms = [
|
||||
"HS256",
|
||||
"RS256",
|
||||
"HS384",
|
||||
"HS512",
|
||||
"RS384",
|
||||
"RS512",
|
||||
] as const;
|
||||
|
||||
type AllowedAlgorithm = (typeof allowedAlgorithms)[number];
|
||||
|
||||
function getAlgorithm(envVar: string | undefined): AllowedAlgorithm {
|
||||
if (allowedAlgorithms.includes(envVar as AllowedAlgorithm)) {
|
||||
return envVar as AllowedAlgorithm;
|
||||
}
|
||||
return "HS256";
|
||||
}
|
||||
|
||||
export const jwt: {
|
||||
secret: string;
|
||||
expiration: string;
|
||||
algorithm: AllowedAlgorithm;
|
||||
} = {
|
||||
secret: process.env.JWT_SECRET || "",
|
||||
expiration: process.env.JWT_EXPIRATION || "1h",
|
||||
algorithm: getAlgorithm(process.env.JWT_ALGORITHM),
|
||||
};
|
3
config/redis.ts
Normal file
3
config/redis.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const redisTtl: number = process.env.REDIS_TTL
|
||||
? Number.parseInt(process.env.REDIS_TTL, 10)
|
||||
: 60 * 60 * 1; // 1 hour
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 6;
|
||||
|
@ -32,13 +32,3 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidTypeOrExtension(
|
||||
type: string,
|
||||
extension: string,
|
||||
): boolean {
|
||||
return (
|
||||
["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) &&
|
||||
["jpeg", "jpg", "png", "gif", "webp"].includes(extension)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 5;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 4;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 3;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 2;
|
||||
|
@ -93,8 +93,6 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// * Validation functions
|
||||
|
||||
export async function getSetting(
|
||||
key: string,
|
||||
reservation?: ReservedSQL,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 1;
|
||||
|
@ -36,135 +36,3 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Validation functions
|
||||
|
||||
// ? should support non english characters but won't mess up the url
|
||||
export const userNameRestrictions: {
|
||||
length: { min: number; max: number };
|
||||
regex: RegExp;
|
||||
} = {
|
||||
length: { min: 3, max: 20 },
|
||||
regex: /^[\p{L}\p{N}._-]+$/u,
|
||||
};
|
||||
|
||||
export const passwordRestrictions: {
|
||||
length: { min: number; max: number };
|
||||
regex: RegExp;
|
||||
} = {
|
||||
length: { min: 12, max: 64 },
|
||||
regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u,
|
||||
};
|
||||
|
||||
export const emailRestrictions: { regex: RegExp } = {
|
||||
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
};
|
||||
|
||||
export const inviteRestrictions: { min: number; max: number; regex: RegExp } = {
|
||||
min: 4,
|
||||
max: 15,
|
||||
regex: /^[a-zA-Z0-9]+$/,
|
||||
};
|
||||
|
||||
export function isValidUsername(username: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!username) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (username.length < userNameRestrictions.length.min) {
|
||||
return { valid: false, error: "Username is too short" };
|
||||
}
|
||||
|
||||
if (username.length > userNameRestrictions.length.max) {
|
||||
return { valid: false, error: "Username is too long" };
|
||||
}
|
||||
|
||||
if (!userNameRestrictions.regex.test(username)) {
|
||||
return { valid: false, error: "Username contains invalid characters" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function isValidPassword(password: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!password) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (password.length < passwordRestrictions.length.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password must be at least ${passwordRestrictions.length.min} characters long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (password.length > passwordRestrictions.length.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password can't be longer than ${passwordRestrictions.length.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!passwordRestrictions.regex.test(password)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!email) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (!emailRestrictions.regex.test(email)) {
|
||||
return { valid: false, error: "Invalid email address" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function isValidInvite(invite: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!invite) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (invite.length < inviteRestrictions.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invite code must be at least ${inviteRestrictions.min} characters long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (invite.length > inviteRestrictions.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invite code can't be longer than ${inviteRestrictions.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!inviteRestrictions.regex.test(invite)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invite code contains invalid characters",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
|
12
package.json
12
package.json
|
@ -8,12 +8,12 @@
|
|||
"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.lockdb",
|
||||
"cleanup": "rm -rf logs node_modules bun.lock",
|
||||
"clearTable": "bun run src/helpers/commands/clearTable.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "^1.2.9",
|
||||
"@types/bun": "^1.2.13",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/image-thumbnail": "^1.0.4",
|
||||
|
@ -22,16 +22,16 @@
|
|||
"prettier": "^3.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@creations.works/logger": "^1.0.3",
|
||||
"ejs": "^3.1.10",
|
||||
"eta": "^3.5.0",
|
||||
"exiftool-vendored": "^29.3.0",
|
||||
"exiftool-vendored": "^30.0.0",
|
||||
"fast-jwt": "6.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"image-thumbnail": "^1.0.17",
|
||||
"luxon": "^3.6.1",
|
||||
"redis": "^4.7.0"
|
||||
"luxon": "^3.6.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,205 +0,0 @@
|
|||
import type { Stats } from "node:fs";
|
||||
import {
|
||||
type WriteStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { EOL } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { environment } from "@config/environment";
|
||||
import { timestampToReadable } from "@helpers/char";
|
||||
|
||||
class Logger {
|
||||
private static instance: Logger;
|
||||
private static log: string = join(__dirname, "../../logs");
|
||||
|
||||
public static getInstance(): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger();
|
||||
}
|
||||
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
private writeToLog(logMessage: string): void {
|
||||
if (environment.development) return;
|
||||
|
||||
const date: Date = new Date();
|
||||
const logDir: string = Logger.log;
|
||||
const logFile: string = join(
|
||||
logDir,
|
||||
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
|
||||
);
|
||||
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
let addSeparator = false;
|
||||
|
||||
if (existsSync(logFile)) {
|
||||
const fileStats: Stats = statSync(logFile);
|
||||
if (fileStats.size > 0) {
|
||||
const lastModified: Date = new Date(fileStats.mtime);
|
||||
if (
|
||||
lastModified.getFullYear() === date.getFullYear() &&
|
||||
lastModified.getMonth() === date.getMonth() &&
|
||||
lastModified.getDate() === date.getDate() &&
|
||||
lastModified.getHours() !== date.getHours()
|
||||
) {
|
||||
addSeparator = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stream: WriteStream = createWriteStream(logFile, { flags: "a" });
|
||||
|
||||
if (addSeparator) {
|
||||
stream.write(`${EOL}${date.toISOString()}${EOL}`);
|
||||
}
|
||||
|
||||
stream.write(`${logMessage}${EOL}`);
|
||||
stream.close();
|
||||
}
|
||||
|
||||
private extractFileName(stack: string): string {
|
||||
const stackLines: string[] = stack.split("\n");
|
||||
let callerFile = "";
|
||||
|
||||
for (let i = 2; i < stackLines.length; i++) {
|
||||
const line: string = stackLines[i].trim();
|
||||
if (line && !line.includes("Logger.") && line.includes("(")) {
|
||||
callerFile = line.split("(")[1]?.split(")")[0] || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return basename(callerFile);
|
||||
}
|
||||
|
||||
private getCallerInfo(stack: unknown): {
|
||||
filename: string;
|
||||
timestamp: string;
|
||||
} {
|
||||
const filename: string =
|
||||
typeof stack === "string" ? this.extractFileName(stack) : "unknown";
|
||||
|
||||
const readableTimestamp: string = timestampToReadable();
|
||||
|
||||
return { filename, timestamp: readableTimestamp };
|
||||
}
|
||||
|
||||
public info(message: string | string[], breakLine = false): void {
|
||||
const stack: string = new Error().stack || "";
|
||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
||||
|
||||
const joinedMessage: string = Array.isArray(message)
|
||||
? message.join(" ")
|
||||
: message;
|
||||
|
||||
const logMessageParts: ILogMessageParts = {
|
||||
readableTimestamp: { value: timestamp, color: "90" },
|
||||
level: { value: "[INFO]", color: "32" },
|
||||
filename: { value: `(${filename})`, color: "36" },
|
||||
message: { value: joinedMessage, color: "0" },
|
||||
};
|
||||
|
||||
this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`);
|
||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
||||
}
|
||||
|
||||
public warn(message: string | string[], breakLine = false): void {
|
||||
const stack: string = new Error().stack || "";
|
||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
||||
|
||||
const joinedMessage: string = Array.isArray(message)
|
||||
? message.join(" ")
|
||||
: message;
|
||||
|
||||
const logMessageParts: ILogMessageParts = {
|
||||
readableTimestamp: { value: timestamp, color: "90" },
|
||||
level: { value: "[WARN]", color: "33" },
|
||||
filename: { value: `(${filename})`, color: "36" },
|
||||
message: { value: joinedMessage, color: "0" },
|
||||
};
|
||||
|
||||
this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`);
|
||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
||||
}
|
||||
|
||||
public error(
|
||||
message: string | Error | ErrorEvent | (string | Error)[],
|
||||
breakLine = false,
|
||||
): void {
|
||||
const stack: string = new Error().stack || "";
|
||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
||||
|
||||
const messages: (string | Error | ErrorEvent)[] = Array.isArray(message)
|
||||
? message
|
||||
: [message];
|
||||
const joinedMessage: string = messages
|
||||
.map((msg: string | Error | ErrorEvent): string =>
|
||||
typeof msg === "string" ? msg : msg.message,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
const logMessageParts: ILogMessageParts = {
|
||||
readableTimestamp: { value: timestamp, color: "90" },
|
||||
level: { value: "[ERROR]", color: "31" },
|
||||
filename: { value: `(${filename})`, color: "36" },
|
||||
message: { value: joinedMessage, color: "0" },
|
||||
};
|
||||
|
||||
this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`);
|
||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
||||
}
|
||||
|
||||
public custom(
|
||||
bracketMessage: string,
|
||||
bracketMessage2: string,
|
||||
message: string | string[],
|
||||
color: string,
|
||||
breakLine = false,
|
||||
): void {
|
||||
const stack: string = new Error().stack || "";
|
||||
const { timestamp } = this.getCallerInfo(stack);
|
||||
|
||||
const joinedMessage: string = Array.isArray(message)
|
||||
? message.join(" ")
|
||||
: message;
|
||||
|
||||
const logMessageParts: ILogMessageParts = {
|
||||
readableTimestamp: { value: timestamp, color: "90" },
|
||||
level: { value: bracketMessage, color },
|
||||
filename: { value: `${bracketMessage2}`, color: "36" },
|
||||
message: { value: joinedMessage, color: "0" },
|
||||
};
|
||||
|
||||
this.writeToLog(
|
||||
`${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`,
|
||||
);
|
||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
||||
}
|
||||
|
||||
public space(): void {
|
||||
console.log();
|
||||
}
|
||||
|
||||
private writeConsoleMessageColored(
|
||||
logMessageParts: ILogMessageParts,
|
||||
breakLine = false,
|
||||
): void {
|
||||
const logMessage: string = Object.keys(logMessageParts)
|
||||
.map((key: string) => {
|
||||
const part: ILogMessagePart = logMessageParts[key];
|
||||
return `\x1b[${part.color}m${part.value}\x1b[0m`;
|
||||
})
|
||||
.join(" ");
|
||||
console.log(logMessage + (breakLine ? EOL : ""));
|
||||
}
|
||||
}
|
||||
|
||||
const logger: Logger = Logger.getInstance();
|
||||
export { logger };
|
|
@ -1,197 +0,0 @@
|
|||
import { redisConfig } from "@config/environment";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type RedisClientType, createClient } from "redis";
|
||||
|
||||
class RedisJson {
|
||||
private static instance: RedisJson | null = null;
|
||||
private client: RedisClientType | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static async initialize(): Promise<RedisJson> {
|
||||
if (!RedisJson.instance) {
|
||||
RedisJson.instance = new RedisJson();
|
||||
RedisJson.instance.client = createClient({
|
||||
socket: {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
},
|
||||
username: redisConfig.username || undefined,
|
||||
password: redisConfig.password || undefined,
|
||||
});
|
||||
|
||||
RedisJson.instance.client.on("error", (err: Error) => {
|
||||
logger.error(["Error connecting to Redis:", err, redisConfig.host]);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
RedisJson.instance.client.on("connect", () => {
|
||||
logger.info([
|
||||
"Connected to Redis on",
|
||||
`${redisConfig.host}:${redisConfig.port}`,
|
||||
]);
|
||||
});
|
||||
|
||||
await RedisJson.instance.client.connect();
|
||||
}
|
||||
|
||||
return RedisJson.instance;
|
||||
}
|
||||
|
||||
public static getInstance(): RedisJson {
|
||||
if (!RedisJson.instance || !RedisJson.instance.client) {
|
||||
throw new Error(
|
||||
"Redis instance not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
return RedisJson.instance;
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
logger.info("Redis disconnected successfully.");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting Redis client:");
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async get(
|
||||
type: "JSON" | "STRING",
|
||||
key: string,
|
||||
path?: string,
|
||||
): Promise<
|
||||
string | number | boolean | Record<string, unknown> | null | unknown
|
||||
> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
const value: unknown = await this.client.json.get(key, {
|
||||
path,
|
||||
});
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (type === "STRING") {
|
||||
const value: string | null = await this.client.get(key);
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error getting value from Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(
|
||||
type: "JSON" | "STRING",
|
||||
key: string,
|
||||
value: unknown,
|
||||
expiresInSeconds?: number,
|
||||
path?: string,
|
||||
): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
await this.client.json.set(key, path || "$", value as string);
|
||||
|
||||
if (expiresInSeconds) {
|
||||
await this.client.expire(key, expiresInSeconds);
|
||||
}
|
||||
} else if (type === "STRING") {
|
||||
if (expiresInSeconds) {
|
||||
await this.client.set(key, value as string, {
|
||||
EX: expiresInSeconds,
|
||||
});
|
||||
} else {
|
||||
await this.client.set(key, value as string);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error setting value in Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(type: "JSON" | "STRING", key: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
await this.client.json.del(key);
|
||||
} else if (type === "STRING") {
|
||||
await this.client.del(key);
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting value from Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async expire(key: string, seconds: number): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
await this.client.expire(key, seconds);
|
||||
} catch (error) {
|
||||
logger.error([`Error expiring key in Redis: ${key}`, error as Error]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async keys(pattern: string): Promise<string[]> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
const keys: string[] = await this.client.keys(pattern);
|
||||
return keys;
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
`Error getting keys from Redis for pattern: ${pattern}`,
|
||||
error as Error,
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const redis: {
|
||||
initialize: () => Promise<RedisJson>;
|
||||
getInstance: () => RedisJson;
|
||||
} = {
|
||||
initialize: RedisJson.initialize,
|
||||
getInstance: RedisJson.getInstance,
|
||||
};
|
||||
|
||||
export { RedisJson };
|
|
@ -1,189 +0,0 @@
|
|||
import { jwt } from "@config/environment";
|
||||
import { environment } from "@config/environment";
|
||||
import { redis } from "@helpers/redis";
|
||||
import { createDecoder, createSigner, createVerifier } from "fast-jwt";
|
||||
|
||||
type Signer = (payload: UserSession, options?: UserSession) => string;
|
||||
type Verifier = (token: string, options?: UserSession) => UserSession;
|
||||
type Decoder = (token: string, options?: UserSession) => UserSession;
|
||||
|
||||
class SessionManager {
|
||||
private signer: Signer;
|
||||
private verifier: Verifier;
|
||||
private decoder: Decoder;
|
||||
|
||||
constructor() {
|
||||
this.signer = createSigner({
|
||||
key: jwt.secret,
|
||||
expiresIn: jwt.expiresIn,
|
||||
});
|
||||
this.verifier = createVerifier({ key: jwt.secret });
|
||||
this.decoder = createDecoder();
|
||||
}
|
||||
|
||||
public async createSession(
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
): Promise<string> {
|
||||
const token: string = this.signer(payload);
|
||||
const sessionKey: string = `session:${payload.id}:${token}`;
|
||||
|
||||
await redis
|
||||
.getInstance()
|
||||
.set(
|
||||
"JSON",
|
||||
sessionKey,
|
||||
{ ...payload, userAgent },
|
||||
this.getExpirationInSeconds(),
|
||||
);
|
||||
|
||||
const cookie: string = this.generateCookie(token);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
public async getSession(request: Request): Promise<UserSession | null> {
|
||||
const cookie: string | null = request.headers.get("Cookie");
|
||||
if (!cookie) return null;
|
||||
|
||||
const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null;
|
||||
if (!token) return null;
|
||||
|
||||
const userSessions: string[] = await redis
|
||||
.getInstance()
|
||||
.keys(`session:*:${token}`);
|
||||
if (!userSessions.length) return null;
|
||||
|
||||
const sessionData: unknown = await redis
|
||||
.getInstance()
|
||||
.get("JSON", userSessions[0]);
|
||||
if (!sessionData) return null;
|
||||
|
||||
const payload: UserSession & { userAgent: string } =
|
||||
sessionData as UserSession & { userAgent: string };
|
||||
return payload;
|
||||
}
|
||||
|
||||
public async updateSession(
|
||||
request: Request,
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
): Promise<string> {
|
||||
const cookie: string | null = request.headers.get("Cookie");
|
||||
if (!cookie) throw new Error("No session found in request");
|
||||
|
||||
const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null;
|
||||
if (!token) throw new Error("Session token not found");
|
||||
|
||||
const userSessions: string[] = await redis
|
||||
.getInstance()
|
||||
.keys(`session:*:${token}`);
|
||||
if (!userSessions.length) throw new Error("Session not found or expired");
|
||||
|
||||
const sessionKey: string = userSessions[0];
|
||||
|
||||
await redis
|
||||
.getInstance()
|
||||
.set(
|
||||
"JSON",
|
||||
sessionKey,
|
||||
{ ...payload, userAgent },
|
||||
this.getExpirationInSeconds(),
|
||||
);
|
||||
|
||||
return this.generateCookie(token);
|
||||
}
|
||||
|
||||
public async verifySession(token: string): Promise<UserSession> {
|
||||
const userSessions: string[] = await redis
|
||||
.getInstance()
|
||||
.keys(`session:*:${token}`);
|
||||
if (!userSessions.length) throw new Error("Session not found or expired");
|
||||
|
||||
const sessionData: unknown = await redis
|
||||
.getInstance()
|
||||
.get("JSON", userSessions[0]);
|
||||
if (!sessionData) throw new Error("Session not found or expired");
|
||||
|
||||
const payload: UserSession = this.verifier(token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public async decodeSession(token: string): Promise<UserSession> {
|
||||
const payload: UserSession = this.decoder(token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public async invalidateSession(request: Request): Promise<void> {
|
||||
const cookie: string | null = request.headers.get("Cookie");
|
||||
if (!cookie) return;
|
||||
|
||||
const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null;
|
||||
if (!token) return;
|
||||
|
||||
const userSessions: string[] = await redis
|
||||
.getInstance()
|
||||
.keys(`session:*:${token}`);
|
||||
if (!userSessions.length) return;
|
||||
|
||||
await redis.getInstance().delete("JSON", userSessions[0]);
|
||||
}
|
||||
|
||||
private generateCookie(
|
||||
token: string,
|
||||
maxAge: number = this.getExpirationInSeconds(),
|
||||
options?: {
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
path?: string;
|
||||
domain?: string;
|
||||
},
|
||||
): 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;
|
||||
}
|
||||
|
||||
private getExpirationInSeconds(): number {
|
||||
const match: RegExpMatchArray | null =
|
||||
jwt.expiresIn.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid expiresIn format in jwt config");
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num: number = Number.parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return num;
|
||||
case "m":
|
||||
return num * 60;
|
||||
case "h":
|
||||
return num * 3600;
|
||||
case "d":
|
||||
return num * 86400;
|
||||
default:
|
||||
throw new Error("Invalid time unit in expiresIn");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessionManager: SessionManager = new SessionManager();
|
||||
export { sessionManager };
|
26
src/index.ts
26
src/index.ts
|
@ -1,14 +1,12 @@
|
|||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, s3, sql } from "bun";
|
||||
import { dataType, verifyRequiredVariables } from "@config";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type ReservedSQL, redis, s3, sql } from "bun";
|
||||
|
||||
import { serverHandler } from "@/server";
|
||||
|
||||
import { redis } from "./helpers/redis";
|
||||
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
const sqlDir: string = resolve("config", "sql");
|
||||
const files: string[] = await readdir(sqlDir);
|
||||
|
@ -38,6 +36,8 @@ async function initializeDatabase(): Promise<void> {
|
|||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
verifyRequiredVariables();
|
||||
|
||||
try {
|
||||
await sql`SELECT 1;`;
|
||||
|
||||
|
@ -53,6 +53,19 @@ async function main(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
|
||||
const url = new URL(process.env.REDIS_URL || "redis://localhost:6379");
|
||||
const host = url.hostname;
|
||||
const port = url.port || "6379";
|
||||
|
||||
logger.info(["Connected to Redis on", `${host}:${port}`]);
|
||||
} catch (error) {
|
||||
logger.error(["Redis connection failed:", error as Error]);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dataType.type === "local" && dataType.path) {
|
||||
if (!existsSync(dataType.path)) {
|
||||
try {
|
||||
|
@ -82,7 +95,8 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
await redis.initialize();
|
||||
logger.space();
|
||||
|
||||
serverHandler.initialize();
|
||||
await initializeDatabase();
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { isUUID } from "@helpers/char";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export async function authByToken(
|
|
@ -200,6 +200,12 @@ export function supportsThumbnail(mimeType: string): boolean {
|
|||
return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType);
|
||||
}
|
||||
|
||||
export function normalizeFqdn(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
if (!/^https?:\/\//.test(value)) return `https://${value}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Commands
|
||||
export function parseArgs(): Record<string, string | boolean> {
|
||||
const args: string[] = process.argv.slice(2);
|
|
@ -1,4 +1,4 @@
|
|||
import { parseArgs } from "@helpers/char";
|
||||
import { parseArgs } from "@lib/char";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
(async (): Promise<void> => {
|
145
src/lib/jwt.ts
Normal file
145
src/lib/jwt.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { environment, jwt } from "@config";
|
||||
import { redis } from "bun";
|
||||
import { createDecoder, createSigner, createVerifier } from "fast-jwt";
|
||||
|
||||
const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration });
|
||||
const verifier = createVerifier({ key: jwt.secret });
|
||||
const decoder = createDecoder();
|
||||
|
||||
export async function createSession(
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
): Promise<string> {
|
||||
const token = signer(payload);
|
||||
const sessionKey = `session:${payload.id}:${token}`;
|
||||
await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent }));
|
||||
await redis.expire(sessionKey, getExpirationInSeconds());
|
||||
return generateCookie(token);
|
||||
}
|
||||
|
||||
export async function getSession(
|
||||
request: Request,
|
||||
): Promise<UserSession | null> {
|
||||
const token = extractToken(request);
|
||||
if (!token) return null;
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) return null;
|
||||
const raw = await redis.get(keys[0]);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
request: Request,
|
||||
payload: UserSession,
|
||||
userAgent: string,
|
||||
): Promise<string> {
|
||||
const token = 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");
|
||||
await redis.set(keys[0], JSON.stringify({ ...payload, userAgent }));
|
||||
await redis.expire(keys[0], getExpirationInSeconds());
|
||||
return generateCookie(token);
|
||||
}
|
||||
|
||||
export async function verifySession(token: string): Promise<UserSession> {
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) throw new Error("Session not found or expired");
|
||||
return verifier(token);
|
||||
}
|
||||
|
||||
export async function decodeSession(token: string): Promise<UserSession> {
|
||||
return decoder(token);
|
||||
}
|
||||
|
||||
export async function invalidateSession(request: Request): Promise<void> {
|
||||
const token = extractToken(request);
|
||||
if (!token) return;
|
||||
const keys = await redis.keys(`session:*:${token}`);
|
||||
if (!keys.length) return;
|
||||
await redis.del(keys[0]);
|
||||
}
|
||||
|
||||
export async function invalidateSessionById(
|
||||
sessionId: string,
|
||||
): Promise<boolean> {
|
||||
const keys = await redis.keys(`session:*:${sessionId}`);
|
||||
if (!keys.length) return false;
|
||||
await redis.del(keys[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function 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;
|
||||
}
|
||||
|
||||
// helpers
|
||||
function extractToken(request: Request): string | null {
|
||||
return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
|
||||
}
|
||||
|
||||
function generateCookie(
|
||||
token: string,
|
||||
maxAge = getExpirationInSeconds(),
|
||||
options?: {
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
path?: string;
|
||||
domain?: string;
|
||||
},
|
||||
): 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;
|
||||
}
|
||||
|
||||
function getExpirationInSeconds(): number {
|
||||
const match = jwt.expiration.match(/^(\d+)([smhd])$/);
|
||||
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;
|
||||
default:
|
||||
throw new Error("Invalid time unit in expiresIn");
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = {
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
verifySession,
|
||||
decodeSession,
|
||||
invalidateSession,
|
||||
invalidateSessionById,
|
||||
invalidateAllSessionsForUser,
|
||||
};
|
9
src/lib/validators/avatar.ts
Normal file
9
src/lib/validators/avatar.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export function isValidTypeOrExtension(
|
||||
type: string,
|
||||
extension: string,
|
||||
): boolean {
|
||||
return (
|
||||
["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) &&
|
||||
["jpeg", "jpg", "png", "gif", "webp"].includes(extension)
|
||||
);
|
||||
}
|
18
src/lib/validators/email.ts
Normal file
18
src/lib/validators/email.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
const emailRestrictions: { regex: RegExp } = {
|
||||
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
};
|
||||
|
||||
export function isValidEmail(email: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!email) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (!emailRestrictions.regex.test(email)) {
|
||||
return { valid: false, error: "Invalid email address" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
5
src/lib/validators/index.ts
Normal file
5
src/lib/validators/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "@lib/validators/name";
|
||||
export * from "@lib/validators/email";
|
||||
export * from "@lib/validators/password";
|
||||
export * from "@lib/validators/invite";
|
||||
export * from "@lib/validators/avatar";
|
37
src/lib/validators/invite.ts
Normal file
37
src/lib/validators/invite.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
const inviteRestrictions: { min: number; max: number; regex: RegExp } = {
|
||||
min: 4,
|
||||
max: 15,
|
||||
regex: /^[a-zA-Z0-9]+$/,
|
||||
};
|
||||
|
||||
export function isValidInvite(invite: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!invite) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (invite.length < inviteRestrictions.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invite code must be at least ${inviteRestrictions.min} characters long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (invite.length > inviteRestrictions.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invite code can't be longer than ${inviteRestrictions.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!inviteRestrictions.regex.test(invite)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invite code contains invalid characters",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
31
src/lib/validators/name.ts
Normal file
31
src/lib/validators/name.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// ? should support non english characters but won't mess up the url
|
||||
export const userNameRestrictions: {
|
||||
length: { min: number; max: number };
|
||||
regex: RegExp;
|
||||
} = {
|
||||
length: { min: 3, max: 20 },
|
||||
regex: /^[\p{L}\p{N}._-]+$/u,
|
||||
};
|
||||
|
||||
export function isValidUsername(username: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!username) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (username.length < userNameRestrictions.length.min) {
|
||||
return { valid: false, error: "Username is too short" };
|
||||
}
|
||||
|
||||
if (username.length > userNameRestrictions.length.max) {
|
||||
return { valid: false, error: "Username is too long" };
|
||||
}
|
||||
|
||||
if (!userNameRestrictions.regex.test(username)) {
|
||||
return { valid: false, error: "Username contains invalid characters" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
40
src/lib/validators/password.ts
Normal file
40
src/lib/validators/password.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
const passwordRestrictions: {
|
||||
length: { min: number; max: number };
|
||||
regex: RegExp;
|
||||
} = {
|
||||
length: { min: 12, max: 64 },
|
||||
regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u,
|
||||
};
|
||||
|
||||
export function isValidPassword(password: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!password) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (password.length < passwordRestrictions.length.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password must be at least ${passwordRestrictions.length.min} characters long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (password.length > passwordRestrictions.length.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password can't be longer than ${passwordRestrictions.length.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!passwordRestrictions.regex.test(password)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { join, resolve } from "node:path";
|
||||
import { dataType } from "@config/environment.ts";
|
||||
import { logger } from "@helpers/logger.ts";
|
||||
import { dataType } from "@config";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { type BunFile, s3, sql } from "bun";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import imageThumbnail from "image-thumbnail";
|
||||
|
@ -186,5 +186,5 @@ self.onmessage = async (event: MessageEvent): Promise<void> => {
|
|||
};
|
||||
|
||||
self.onerror = (error: ErrorEvent): void => {
|
||||
logger.error(error);
|
||||
logger.error(["An error occurred in the thumbnail worker:", error.message]);
|
||||
};
|
|
@ -1,9 +1,8 @@
|
|||
import { sql } from "bun";
|
||||
import { redis, sql } from "bun";
|
||||
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { redis } from "@/helpers/redis";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -37,11 +36,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
try {
|
||||
const verificationData: unknown = await redis
|
||||
.getInstance()
|
||||
.get("JSON", `email:verify:${code}`);
|
||||
const raw: string | null = await redis.get(`email:verify:${code}`);
|
||||
|
||||
if (!verificationData) {
|
||||
if (!raw) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
@ -52,11 +49,24 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
const { user_id: userId } = verificationData as {
|
||||
user_id: string;
|
||||
};
|
||||
let verificationData: { user_id: string };
|
||||
|
||||
await redis.getInstance().delete("JSON", `email:verify:${code}`);
|
||||
try {
|
||||
verificationData = JSON.parse(raw);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 400,
|
||||
error: "Malformed verification data",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { user_id: userId } = verificationData;
|
||||
|
||||
await redis.del(`email:verify:${code}`);
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUIDv7, sql } from "bun";
|
||||
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { redis } from "@/helpers/redis";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { redis } from "bun";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -51,11 +51,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
const code: string = randomUUIDv7();
|
||||
await redis.getInstance().set(
|
||||
"JSON",
|
||||
await redis.set(
|
||||
`email:verify:${code}`,
|
||||
{ user_id: request.session.id },
|
||||
60 * 60 * 2, // 2 hours
|
||||
JSON.stringify({ user_id: request.session.id }),
|
||||
);
|
||||
|
||||
// TODO: Send email when email service is implemented
|
||||
|
|
|
@ -2,11 +2,11 @@ import {
|
|||
isValidEmail,
|
||||
isValidPassword,
|
||||
isValidUsername,
|
||||
} from "@config/sql/users";
|
||||
} from "@lib/validators";
|
||||
import { type ReservedSQL, password as bunPassword, sql } from "bun";
|
||||
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
|
|
@ -4,12 +4,12 @@ import {
|
|||
isValidInvite,
|
||||
isValidPassword,
|
||||
isValidUsername,
|
||||
} from "@config/sql/users";
|
||||
} from "@lib/validators";
|
||||
import { type ReservedSQL, password as bunPassword, sql } from "bun";
|
||||
|
||||
import { isValidTimezone } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isValidTimezone } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { dataType } from "@config";
|
||||
import { type SQLQuery, s3, sql } from "bun";
|
||||
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "DELETE",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { dataType } from "@config";
|
||||
import { getSetting } from "@config/sql/settings";
|
||||
import {
|
||||
type SQLQuery,
|
||||
|
@ -11,6 +11,7 @@ import {
|
|||
import { exiftool } from "exiftool-vendored";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import {
|
||||
generateRandomString,
|
||||
getBaseUrl,
|
||||
|
@ -19,8 +20,7 @@ import {
|
|||
nameWithoutExtension,
|
||||
supportsExif,
|
||||
supportsThumbnail,
|
||||
} from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
} from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -439,7 +439,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
filesThatSupportThumbnails.length > 0
|
||||
) {
|
||||
try {
|
||||
const worker: Worker = new Worker("./src/helpers/workers/thumbnails.ts", {
|
||||
const worker: Worker = new Worker("./src/helpers/workers/thumbnails", {
|
||||
type: "module",
|
||||
});
|
||||
worker.postMessage({
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { getSetting } from "@config/sql/settings";
|
||||
import { sql } from "bun";
|
||||
|
||||
import { generateRandomString, getNewTimeUTC } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { generateRandomString, getNewTimeUTC } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { isValidInvite } from "@config/sql/users";
|
||||
import { isValidInvite } from "@lib/validators";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "DELETE",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { setSetting } from "@config/sql/settings";
|
||||
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { dataType } from "@config";
|
||||
import { s3, sql } from "bun";
|
||||
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
|
||||
async function deleteAvatar(
|
||||
request: ExtendedRequest,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { isValidTypeOrExtension } from "@config/sql/avatars";
|
||||
import { dataType } from "@config";
|
||||
import { getSetting } from "@config/sql/settings";
|
||||
import { isValidTypeOrExtension } from "@lib/validators";
|
||||
import { s3, sql } from "bun";
|
||||
|
||||
import { getBaseUrl, getExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { getBaseUrl, getExtension } from "@lib/char";
|
||||
|
||||
async function processFile(
|
||||
file: File,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { type ReservedSQL, type SQLQuery, sql } from "bun";
|
||||
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
|
||||
function isValidSort(sortBy: string): boolean {
|
||||
const validSorts: string[] = [
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { isValidUsername } from "@config/sql/users";
|
||||
import { isValidUsername } from "@lib/validators";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { renderEjsTemplate } from "@helpers/ejs";
|
||||
import { renderEjsTemplate } from "@lib/ejs";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { dataType } from "@config";
|
||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { isUUID, nameWithoutExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID, nameWithoutExtension } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config/environment";
|
||||
import { isValidUsername } from "@config/sql/users";
|
||||
import { dataType } from "@config";
|
||||
import { isValidUsername } from "@lib/validators";
|
||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { resolve } from "node:path";
|
||||
import { environment } from "@config/environment";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { environment } from "@config";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import {
|
||||
type BunFile,
|
||||
FileSystemRouter,
|
||||
|
@ -8,10 +8,9 @@ import {
|
|||
type Serve,
|
||||
} from "bun";
|
||||
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { webSocketHandler } from "@/websocket";
|
||||
|
||||
import { authByToken } from "./helpers/auth";
|
||||
import { sessionManager } from "./helpers/sessions";
|
||||
import { authByToken } from "@lib/auth";
|
||||
|
||||
class ServerHandler {
|
||||
private router: FileSystemRouter;
|
||||
|
@ -41,15 +40,7 @@ class ServerHandler {
|
|||
maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon
|
||||
});
|
||||
|
||||
const accessUrls: string[] = [
|
||||
`http://${server.hostname}:${server.port}`,
|
||||
`http://localhost:${server.port}`,
|
||||
`http://127.0.0.1:${server.port}`,
|
||||
];
|
||||
|
||||
logger.info(`Server running at ${accessUrls[0]}`);
|
||||
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true);
|
||||
|
||||
logger.info(`Server running at ${environment.fqdn}`);
|
||||
this.logRoutes();
|
||||
}
|
||||
|
||||
|
@ -67,10 +58,15 @@ class ServerHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private async serveStaticFile(pathname: string): Promise<Response> {
|
||||
try {
|
||||
let filePath: string;
|
||||
private async serveStaticFile(
|
||||
request: ExtendedRequest,
|
||||
pathname: string,
|
||||
ip: string,
|
||||
): Promise<Response> {
|
||||
let filePath: string;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
if (pathname === "/favicon.ico") {
|
||||
filePath = resolve("public", "assets", "favicon.ico");
|
||||
} else {
|
||||
|
@ -83,16 +79,37 @@ class ServerHandler {
|
|||
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
||||
const contentType: string = file.type || "application/octet-stream";
|
||||
|
||||
return new Response(fileContent, {
|
||||
response = new Response(fileContent, {
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
} else {
|
||||
logger.warn(`File not found: ${filePath}`);
|
||||
response = new Response("Not Found", { status: 404 });
|
||||
}
|
||||
logger.warn(`File not found: ${filePath}`);
|
||||
return new Response("Not Found", { status: 404 });
|
||||
} catch (error) {
|
||||
logger.error([`Error serving static file: ${pathname}`, error as Error]);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
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 {
|
||||
logger.custom(
|
||||
`[${request.method}]`,
|
||||
`(${response.status})`,
|
||||
[
|
||||
request.url,
|
||||
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||
ip || "unknown",
|
||||
],
|
||||
"90",
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRequest(
|
||||
|
@ -102,14 +119,25 @@ class ServerHandler {
|
|||
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;
|
||||
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
||||
return await this.serveStaticFile(pathname);
|
||||
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||
}
|
||||
|
||||
const match: MatchedRoute | null = this.router.match(request);
|
||||
let requestBody: unknown = {};
|
||||
let response: Response;
|
||||
|
||||
if (match) {
|
||||
const { filePath, params, query } = match;
|
||||
|
@ -230,28 +258,6 @@ class ServerHandler {
|
|||
);
|
||||
}
|
||||
|
||||
const headers: Headers = response.headers;
|
||||
let ip: string | null = server.requestIP(request)?.address || null;
|
||||
|
||||
if (!ip) {
|
||||
ip =
|
||||
headers.get("CF-Connecting-IP") ||
|
||||
headers.get("X-Real-IP") ||
|
||||
headers.get("X-Forwarded-For") ||
|
||||
null;
|
||||
}
|
||||
|
||||
logger.custom(
|
||||
`[${request.method}]`,
|
||||
`(${response.status})`,
|
||||
[
|
||||
request.url,
|
||||
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
|
||||
ip || "unknown",
|
||||
],
|
||||
"90",
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@helpers/logger";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
class WebSocketHandler {
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@config": ["config/index.ts"],
|
||||
"@config/*": ["config/*"],
|
||||
"@types/*": ["types/*"],
|
||||
"@helpers/*": ["src/helpers/*"]
|
||||
"@lib/*": ["src/lib/*"]
|
||||
},
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"],
|
||||
// Enable latest features
|
||||
|
|
1
types/config.d.ts
vendored
1
types/config.d.ts
vendored
|
@ -2,6 +2,7 @@ type Environment = {
|
|||
port: number;
|
||||
host: string;
|
||||
development: boolean;
|
||||
fqdn: string;
|
||||
};
|
||||
|
||||
type UserValidation = {
|
||||
|
|
9
types/logger.d.ts
vendored
9
types/logger.d.ts
vendored
|
@ -1,9 +0,0 @@
|
|||
type ILogMessagePart = { value: string; color: string };
|
||||
|
||||
type ILogMessageParts = {
|
||||
level: ILogMessagePart;
|
||||
filename: ILogMessagePart;
|
||||
readableTimestamp: ILogMessagePart;
|
||||
message: ILogMessagePart;
|
||||
[key: string]: ILogMessagePart;
|
||||
};
|
Loading…
Add table
Reference in a new issue