Compare commits
2 commits
test-preac
...
main
Author | SHA1 | Date | |
---|---|---|---|
4936ff8978 | |||
8a9499be85 |
61 changed files with 573 additions and 1104 deletions
11
.env.example
11
.env.example
|
@ -2,16 +2,19 @@
|
|||
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
|
||||
FRONTEND_URL=http://localhost:8080
|
||||
|
||||
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
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,5 +3,3 @@ bun.lock
|
|||
.env
|
||||
/uploads
|
||||
.idea
|
||||
temp
|
||||
.vscode/settings.json
|
||||
|
|
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
|
@ -2,7 +2,6 @@
|
|||
"recommendations": [
|
||||
"mikestead.dotenv",
|
||||
"EditorConfig.EditorConfig",
|
||||
"leonzalion.vscode-ejs",
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
|
|
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,
|
||||
};
|
64
config/index.ts
Normal file
64
config/index.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
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,
|
||||
};
|
||||
|
||||
const frontendUrl: string = process.env.FRONTEND_URL || "http://localhost:8080";
|
||||
|
||||
function verifyRequiredVariables(): void {
|
||||
const requiredVariables = [
|
||||
"HOST",
|
||||
"PORT",
|
||||
|
||||
"FQDN",
|
||||
"FRONTEND_URL",
|
||||
|
||||
"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, frontendUrl };
|
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 };
|
||||
}
|
||||
|
|
15
package.json
15
package.json
|
@ -8,13 +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/ejs": "^3.1.5",
|
||||
"@types/bun": "^1.2.13",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/image-thumbnail": "^1.0.4",
|
||||
"@types/luxon": "^3.6.2",
|
||||
|
@ -22,17 +21,15 @@
|
|||
"prettier": "^3.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"ejs": "^3.1.10",
|
||||
"@creations.works/logger": "^1.0.3",
|
||||
"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",
|
||||
"preact-render-to-string": "^6.5.13",
|
||||
"redis": "^4.7.0"
|
||||
"luxon": "^3.6.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
import type { FunctionalComponent, JSX } from "preact";
|
||||
|
||||
export const Head: FunctionalComponent<Props> = ({
|
||||
title,
|
||||
styles,
|
||||
scripts,
|
||||
}): JSX.Element => (
|
||||
<>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
|
||||
{title && <title>{title}</title>}
|
||||
|
||||
<link rel="stylesheet" href="/public/css/global.css" />
|
||||
|
||||
{styles?.map(
|
||||
(style): JSX.Element => (
|
||||
<link key={style} rel="stylesheet" href={`/public/css/${style}.css`} />
|
||||
),
|
||||
)}
|
||||
|
||||
{scripts?.map(
|
||||
(script: string | [string, boolean]): JSX.Element | undefined => {
|
||||
if (typeof script === "string") {
|
||||
return <script src={`/public/js/${script}.js`} defer />;
|
||||
}
|
||||
|
||||
if (Array.isArray(script)) {
|
||||
return (
|
||||
<script
|
||||
src={`/public/js/${script[0]}.js`}
|
||||
{...(script[1] ? { defer: true } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
)}
|
||||
|
||||
<script src="/public/js/global.js" />
|
||||
</>
|
||||
);
|
|
@ -1,26 +0,0 @@
|
|||
import { resolve } from "node:path";
|
||||
import { renderFile } from "ejs";
|
||||
|
||||
export async function renderEjsTemplate(
|
||||
viewName: string | string[],
|
||||
data: EjsTemplateData,
|
||||
headers?: Record<string, string | number | boolean>,
|
||||
): Promise<Response> {
|
||||
let templatePath: string;
|
||||
|
||||
if (Array.isArray(viewName)) {
|
||||
templatePath = resolve("src", "views", ...viewName);
|
||||
} else {
|
||||
templatePath = resolve("src", "views", viewName);
|
||||
}
|
||||
|
||||
if (!templatePath.endsWith(".ejs")) {
|
||||
templatePath += ".ejs";
|
||||
}
|
||||
|
||||
const html: string = await renderFile(templatePath, data);
|
||||
|
||||
return new Response(html, {
|
||||
headers: { "Content-Type": "text/html", ...headers },
|
||||
});
|
||||
}
|
|
@ -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,15 +0,0 @@
|
|||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(): Promise<Response> {
|
||||
// TODO: Put something useful here
|
||||
|
||||
return Response.json({
|
||||
message: "Hello, World!",
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
|
@ -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,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,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,4 +1,4 @@
|
|||
import { renderEjsTemplate } from "@helpers/ejs";
|
||||
import { frontendUrl } from "@config";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -7,15 +7,14 @@ const routeDef: RouteDef = {
|
|||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
if (!request.session) {
|
||||
return Response.redirect("/auth/login");
|
||||
}
|
||||
|
||||
const ejsTemplateData: EjsTemplateData = {
|
||||
title: "Hello, World!",
|
||||
};
|
||||
|
||||
return await renderEjsTemplate("index", ejsTemplateData);
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: `This is the api for ${frontendUrl}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
||||
|
|
|
@ -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,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,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,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,26 +0,0 @@
|
|||
import { HtmlShell } from "@/templates/shell";
|
||||
import { render } from "preact-render-to-string";
|
||||
import type { JSX } from "preact/jsx-runtime";
|
||||
|
||||
const routeDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "text/html",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
const name = "test";
|
||||
const page: JSX.Element = (
|
||||
<HtmlShell title="Test Page" styles={[]} scripts={[]}>
|
||||
<div class="foo">test {name}</div>
|
||||
</HtmlShell>
|
||||
);
|
||||
|
||||
return new Response(`<!DOCTYPE html>${render(page)}`, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
|
@ -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,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,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;
|
||||
|
@ -23,7 +22,7 @@ class ServerHandler {
|
|||
this.router = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: "./src/routes",
|
||||
fileExtensions: [".ts", ".tsx"],
|
||||
fileExtensions: [".ts"],
|
||||
origin: `http://${this.host}:${this.port}`,
|
||||
});
|
||||
}
|
||||
|
@ -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,12 +0,0 @@
|
|||
import { Head } from "@/components/head";
|
||||
|
||||
export function HtmlShell({ title, styles, scripts, children }: Props) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Head title={title} styles={styles} scripts={scripts} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
|
||||
<% if (title) { %>
|
||||
<title><%= title %></title>
|
||||
<% } %>
|
||||
|
||||
<link rel="stylesheet" href="/public/css/global.css">
|
||||
|
||||
<% if (typeof styles !== "undefined") { %>
|
||||
<% styles.forEach(style => { %>
|
||||
<link rel="stylesheet" href="/public/css/<%= style %>.css">
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof scripts !== "undefined") { %>
|
||||
<% scripts.forEach(script => { %>
|
||||
<% if (typeof script === "string") { %>
|
||||
<script src="/public/js/<%= script %>.js" defer></script>
|
||||
<% } else if (Array.isArray(script)) { %>
|
||||
<% if (script[1]) { %>
|
||||
<script src="/public/js/<%= script[0] %>.js" defer></script>
|
||||
<% } else { %>
|
||||
<script src="/public/js/<%= script[0] %>.js"></script>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
<script src="/public/js/global.js"></script>
|
|
@ -1,9 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<%- include("global", { styles: [], scripts: [] }) %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -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
|
||||
|
@ -14,8 +15,7 @@
|
|||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"allowJs": false,
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
|
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 = {
|
||||
|
|
3
types/ejs.d.ts
vendored
3
types/ejs.d.ts
vendored
|
@ -1,3 +0,0 @@
|
|||
interface EjsTemplateData {
|
||||
[key: string]: string | number | boolean | object | undefined | null;
|
||||
}
|
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;
|
||||
};
|
6
types/preact.d.ts
vendored
6
types/preact.d.ts
vendored
|
@ -1,6 +0,0 @@
|
|||
type Props = {
|
||||
title?: string;
|
||||
styles?: string[];
|
||||
scripts?: (string | [string, boolean])[];
|
||||
children?: preact.ComponentChildren;
|
||||
};
|
Loading…
Add table
Reference in a new issue