Compare commits

..

15 commits
main ... main

65 changed files with 1336 additions and 1788 deletions

View file

@ -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

View file

@ -0,0 +1,24 @@
name: Code quality checks
on:
push:
pull_request:
jobs:
biome:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install Dependencies
run: bun install
- name: Run Biome with verbose output
run: bunx biome ci . --verbose

7
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"recommendations": [
"mikestead.dotenv",
"EditorConfig.EditorConfig",
"biomejs.biome"
]
}

44
biome.json Normal file
View file

@ -0,0 +1,44 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": true,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"indentStyle": "tab",
"lineEnding": "lf",
"jsxQuoteStyle": "double",
"semicolons": "always"
}
}
}

View file

@ -1,37 +0,0 @@
import { resolve } from "path";
export const environment: Environment = {
port: 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: 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,
};

66
config/index.ts Normal file
View file

@ -0,0 +1,66 @@
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",
frontendUrl:
normalizeFqdn(process.env.FRONTEND_URL) || "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
View 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
View 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

View file

@ -1,18 +1,18 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import { type ReservedSQL, sql } from "bun";
export const order: number = 6;
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS avatars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -28,17 +28,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
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)
);
}

View file

@ -1,18 +1,18 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import { type ReservedSQL, sql } from "bun";
export const order: number = 5;
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
);
`;
const functionExists: { exists: boolean }[] = await reservation`
const functionExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_proc
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!functionExists[0].exists) {
await reservation`
await activeReservation`
CREATE FUNCTION update_files_updated_at()
RETURNS TRIGGER AS $$
BEGIN
@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
}
const triggerExists: { exists: boolean }[] = await reservation`
const triggerExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trigger_update_files_updated_at'
@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!triggerExists[0].exists) {
await reservation`
await activeReservation`
CREATE TRIGGER trigger_update_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
@ -80,7 +80,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}

View file

@ -1,18 +1,18 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import { type ReservedSQL, sql } from "bun";
export const order: number = 4;
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -26,7 +26,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
);
`;
const functionExists: { exists: boolean }[] = await reservation`
const functionExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_proc
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
@ -35,7 +35,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!functionExists[0].exists) {
await reservation`
await activeReservation`
CREATE FUNCTION update_folders_updated_at()
RETURNS TRIGGER AS $$
BEGIN
@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
}
const triggerExists: { exists: boolean }[] = await reservation`
const triggerExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trigger_update_folders_updated_at'
@ -54,7 +54,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!triggerExists[0].exists) {
await reservation`
await activeReservation`
CREATE TRIGGER trigger_update_folders_updated_at
BEFORE UPDATE ON folders
FOR EACH ROW
@ -69,7 +69,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}

View file

@ -1,18 +1,18 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import { type ReservedSQL, sql } from "bun";
export const order: number = 3;
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY NOT NULL UNIQUE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -27,7 +27,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}

View file

@ -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;
@ -20,15 +20,15 @@ const defaultSettings: Setting[] = [
];
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS settings (
"key" VARCHAR(64) PRIMARY KEY NOT NULL UNIQUE,
"value" TEXT NOT NULL,
@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
);
`;
const functionExists: { exists: boolean }[] = await reservation`
const functionExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_proc
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!functionExists[0].exists) {
await reservation`
await activeReservation`
CREATE FUNCTION update_settings_updated_at()
RETURNS TRIGGER AS $$
BEGIN
@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
}
const triggerExists: { exists: boolean }[] = await reservation`
const triggerExists: { exists: boolean }[] = await activeReservation`
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trigger_update_settings_updated_at'
@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
`;
if (!triggerExists[0].exists) {
await reservation`
await activeReservation`
CREATE TRIGGER trigger_update_settings_updated_at
BEFORE UPDATE ON settings
FOR EACH ROW
@ -74,7 +74,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
}
for (const setting of defaultSettings) {
await reservation`
await activeReservation`
INSERT INTO settings ("key", "value")
VALUES (${setting.key}, ${setting.value})
ON CONFLICT ("key") DO NOTHING;
@ -88,27 +88,25 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
// * Validation functions
export async function getSetting(
key: string,
reservation?: ReservedSQL,
): Promise<string | null> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
const result: { value: string }[] =
await reservation`SELECT value FROM settings WHERE "key" = ${key};`;
await activeReservation`SELECT value FROM settings WHERE "key" = ${key};`;
if (result.length === 0) {
return null;
@ -120,7 +118,7 @@ export async function getSetting(
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
@ -130,15 +128,15 @@ export async function setSetting(
value: string,
reservation?: ReservedSQL,
): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
INSERT INTO settings ("key", "value", updated_at)
VALUES (${key}, ${value}, NOW())
ON CONFLICT ("key")
@ -148,7 +146,7 @@ export async function setSetting(
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
@ -157,21 +155,21 @@ export async function deleteSetting(
key: string,
reservation?: ReservedSQL,
): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`DELETE FROM settings WHERE "key" = ${key};`;
await activeReservation`DELETE FROM settings WHERE "key" = ${key};`;
} catch (error) {
logger.error(["Could not delete the setting:", error as Error]);
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
@ -179,16 +177,16 @@ export async function deleteSetting(
export async function getAllSettings(
reservation?: ReservedSQL,
): Promise<{ key: string; value: string }[]> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
const result: { key: string; value: string }[] =
await reservation`SELECT "key", "value" FROM settings;`;
await activeReservation`SELECT "key", "value" FROM settings;`;
return result;
} catch (error) {
@ -196,7 +194,7 @@ export async function getAllSettings(
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}

View file

@ -1,18 +1,18 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import { type ReservedSQL, sql } from "bun";
export const order: number = 1;
export async function createTable(reservation?: ReservedSQL): Promise<void> {
let selfReservation: boolean = false;
let selfReservation = false;
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
if (!reservation) {
reservation = await sql.reserve();
selfReservation = true;
}
try {
await reservation`
await activeReservation`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
@ -32,122 +32,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
throw error;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}
// * 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.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.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 (!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.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 };
}

View file

@ -1,142 +0,0 @@
import pluginJs from "@eslint/js";
import tseslintPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import prettier from "eslint-plugin-prettier";
import promisePlugin from "eslint-plugin-promise";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unicorn from "eslint-plugin-unicorn";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import stylelintPlugin from "stylelint";
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
{
files: ["**/*.{js,mjs,cjs}"],
languageOptions: {
globals: globals.node,
},
...pluginJs.configs.recommended,
plugins: {
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
},
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
globals: globals.node,
},
plugins: {
"@typescript-eslint": tseslintPlugin,
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
...tseslintPlugin.configs.recommended.rules,
quotes: ["error", "double"],
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-module-boundary-types": ["error"],
"@typescript-eslint/typedef": [
"error",
{
arrowParameter: true,
variableDeclaration: true,
propertyDeclaration: true,
memberVariableDeclaration: true,
parameter: true,
},
],
},
},
{
files: ["**/*.{css,scss,sass,less}"],
plugins: {
stylelint: stylelintPlugin,
},
rules: {
"stylelint/rule-name": "error",
},
},
];

View file

@ -1,46 +1,35 @@
{
"name": "bun_frontend_template",
"name": "atums.world",
"private": true,
"module": "src/index.ts",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run --watch src/index.ts --dev",
"lint": "eslint",
"lint:fix": "bun lint --fix",
"cleanup": "rm -rf logs node_modules bun.lockdb",
"dev": "bun run --hot src/index.ts --dev",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lock",
"clearTable": "bun run src/helpers/commands/clearTable.ts"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/bun": "^1.2.5",
"@types/ejs": "^3.1.5",
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.13",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/image-thumbnail": "^1.0.4",
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"eslint": "^9.22.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-stylelint": "^0.1.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"stylelint": "^16.16.0",
"stylelint-config-standard": "^37.0.0"
"@types/luxon": "^3.6.2",
"globals": "16.0.0",
"prettier": "^3.5.3"
},
"peerDependencies": {
"typescript": "^5.7.3"
"typescript": "^5.8.3"
},
"dependencies": {
"ejs": "^3.1.10",
"exiftool-vendored": "^29.2.0",
"fast-jwt": "^5.0.5",
"@creations.works/logger": "^1.0.3",
"eta": "^3.5.0",
"exiftool-vendored": "^30.0.0",
"fast-jwt": "6.0.1",
"fluent-ffmpeg": "^2.1.3",
"image-thumbnail": "^1.0.17",
"luxon": "^3.5.0",
"redis": "^4.7.0"
"luxon": "^3.6.1"
}
}

View file

@ -1,27 +0,0 @@
.container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100vh;
}
.content {
border: 1px solid var(--border);
background-color: var(--background-secondary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
width: clamp(200px, 50%, 300px);
}
.content form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View file

@ -1,40 +1,48 @@
[data-theme="dark"] {
--background: rgb(31, 30, 30);
--background-secondary: rgb(45, 45, 45);
--border: rgb(31, 30, 30);
--text: rgb(255, 255, 255);
--text-secondary: rgb(255, 255, 255);
}
body {
font-family: "Ubuntu", sans-serif;
margin: 0;
padding: 0;
box-sizing: border-box;
font-size: 16px;
background-color: var(--background);
--background: rgb(31 30 30);
--background-secondary: rgb(45 45 45);
--border: rgb(70 70 70);
--text: rgb(255 255 255);
--svg-fill: rgb(255 255 255);
--text-secondary: rgb(200 200 200);
--accent: rgb(88 101 242);
--accent-hover: rgb(71 82 196);
--error: rgb(237 66 69);
--success: rgb(87 242 135);
--shadow: rgb(0 0 0 / 20%);
--card-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%);
--input-background: rgb(55 55 55);
}
/* Fonts */
@font-face {
font-family: "Ubuntu";
font-family: Ubuntu;
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Ubuntu Bold";
font-family: Ubuntu Bold;
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: "Fira Code";
src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf") format("truetype");
font-family: Fira Code;
src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf")
format("truetype");
font-weight: normal;
font-style: normal;
}
body {
font-family: Ubuntu, sans-serif;
margin: 0;
padding: 0;
box-sizing: border-box;
font-size: 16px;
background-color: var(--background);
color: var(--text);
}

View file

@ -1,26 +0,0 @@
import { renderFile } from "ejs";
import { resolve } from "path";
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 },
});
}

View file

@ -1,205 +0,0 @@
import { environment } from "@config/environment";
import { timestampToReadable } from "@helpers/char";
import type { Stats } from "fs";
import {
createWriteStream,
existsSync,
mkdirSync,
statSync,
WriteStream,
} from "fs";
import { EOL } from "os";
import { basename, join } from "path";
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: boolean = 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: string = "";
for (let i: number = 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: boolean = 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: boolean = 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: boolean = 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: boolean = 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: boolean = 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 };

View file

@ -1,204 +0,0 @@
import { redisConfig } from "@config/environment";
import { logger } from "@helpers/logger";
import { createClient, type RedisClientType } 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;
} else if (type === "STRING") {
const value: string | null = await this.client.get(key);
return value;
} else {
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 };

View file

@ -1,194 +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: string = `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 = 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 };

View file

@ -1,14 +1,12 @@
import { dataType } from "@config/environment";
import { logger } from "@helpers/logger";
import { type ReservedSQL, s3, sql } from "bun";
import { existsSync, mkdirSync } from "fs";
import { readdir } from "fs/promises";
import { resolve } from "path";
import { existsSync, mkdirSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { resolve } from "node:path";
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);
@ -17,9 +15,7 @@ async function initializeDatabase(): Promise<void> {
files
.filter((file: string): boolean => file.endsWith(".ts"))
.map(async (file: string): Promise<Module> => {
const module: Module["module"] = await import(
resolve(sqlDir, file)
);
const module: Module["module"] = await import(resolve(sqlDir, file));
return { file, module };
}),
);
@ -40,7 +36,8 @@ async function initializeDatabase(): Promise<void> {
}
async function main(): Promise<void> {
try {
verifyRequiredVariables();
try {
await sql`SELECT 1;`;
@ -56,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 {
@ -69,19 +79,13 @@ async function main(): Promise<void> {
}
}
logger.info([
"Using local datasource directory",
`${dataType.path}`,
]);
logger.info(["Using local datasource directory", `${dataType.path}`]);
} else {
try {
await s3.write("test", "test");
await s3.delete("test");
logger.info([
"Connected to S3 with bucket",
`${process.env.S3_BUCKET}`,
]);
logger.info(["Connected to S3 with bucket", `${process.env.S3_BUCKET}`]);
} catch (error) {
logger.error([
"Could not establish a connection to S3 bucket:",
@ -91,12 +95,10 @@ async function main(): Promise<void> {
}
}
await redis.initialize();
logger.space();
serverHandler.initialize();
await initializeDatabase();
} catch (error) {
throw error;
}
}
main().catch((error: Error) => {

View file

@ -1,12 +1,13 @@
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(
request: ExtendedRequest,
reservation?: ReservedSQL,
): Promise<ApiUserSession | null> {
let selfReservation: boolean = false;
let selfReservation = false;
let activeReservation: ReservedSQL | undefined = reservation;
const authorizationHeader: string | null =
request.headers.get("Authorization");
@ -17,14 +18,14 @@ export async function authByToken(
const authorizationToken: string = authorizationHeader.slice(7).trim();
if (!authorizationToken || !isUUID(authorizationToken)) return null;
if (!reservation) {
reservation = await sql.reserve();
if (!activeReservation) {
activeReservation = await sql.reserve();
selfReservation = true;
}
try {
const result: User[] =
await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
await activeReservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
if (result.length === 0) return null;
@ -44,7 +45,7 @@ export async function authByToken(
return null;
} finally {
if (selfReservation) {
reservation.release();
activeReservation.release();
}
}
}

View file

@ -2,8 +2,8 @@ import { DateTime } from "luxon";
export function timestampToReadable(timestamp?: number): string {
const date: Date =
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
if (isNaN(date.getTime())) return "Invalid Date";
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}
@ -48,7 +48,7 @@ export function parseDuration(input: string): DurationObject {
};
for (const match of matches) {
const value: number = parseInt(match[1], 10);
const value: number = Number.parseInt(match[1], 10);
const unit: string = match[2];
switch (unit) {
@ -84,18 +84,14 @@ export function isValidTimezone(timezone: string): boolean {
}
export function generateRandomString(length?: number): string {
if (!length) {
length = length || Math.floor(Math.random() * 10) + 5;
}
const finalLength: number = length ?? Math.floor(Math.random() * 10) + 5;
const characters: string =
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result: string = "";
let result = "";
for (let i: number = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length),
);
for (let i = 0; i < finalLength; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
@ -172,9 +168,7 @@ export function nameWithoutExtension(fileName: string): string {
if (lastDotIndex <= 0) return fileName;
const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase();
return knownExtensions.has(ext)
? fileName.slice(0, lastDotIndex)
: fileName;
return knownExtensions.has(ext) ? fileName.slice(0, lastDotIndex) : fileName;
}
export function supportsExif(mimeType: string, extension: string): boolean {
@ -206,18 +200,22 @@ 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);
const parsedArgs: Record<string, string | boolean> = {};
for (let i: number = 0; i < args.length; i++) {
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) {
const key: string = args[i].slice(2);
const value: string | boolean =
args[i + 1] && !args[i + 1].startsWith("--")
? args[i + 1]
: true;
args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true;
parsedArgs[key] = value;
if (value !== true) i++;
}

View file

@ -1,4 +1,4 @@
import { parseArgs } from "@helpers/char";
import { parseArgs } from "@lib/char";
import { type ReservedSQL, sql } from "bun";
(async (): Promise<void> => {
@ -24,8 +24,7 @@ import { type ReservedSQL, sql } from "bun";
error.message.includes("foreign key constraint")
) {
console.error(
`Could not clear table "${table}" due to foreign key constraints.\n` +
"Try using --cascade if you want to remove dependent records.",
`Could not clear table "${table}" due to foreign key constraints.\nTry using --cascade if you want to remove dependent records.`,
);
} else {
console.error("Could not clear table:", error);

145
src/lib/jwt.ts Normal file
View 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,
};

View 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)
);
}

View 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 };
}

View 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";

View 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 };
}

View 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 };
}

View 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 };
}

View file

@ -1,11 +1,11 @@
import { dataType } from "@config/environment.ts";
import { logger } from "@helpers/logger.ts";
import { join, resolve } from "node:path";
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";
import { join, resolve } from "path";
declare var self: Worker;
declare let self: Worker;
async function generateVideoThumbnail(
filePath: string,
@ -22,10 +22,7 @@ async function generateVideoThumbnail(
.format("mjpeg")
.output(thumbnailPath)
.on("error", (error: Error) => {
logger.error([
"failed to generate thumbnail",
error as Error,
]);
logger.error(["failed to generate thumbnail", error as Error]);
reject(error);
})
@ -50,40 +47,34 @@ async function generateImageThumbnail(
thumbnailPath: string,
): Promise<ArrayBuffer> {
return new Promise(
async (
(
resolve: (value: ArrayBuffer) => void,
reject: (reason: Error) => void,
) => {
try {
const options: {
responseType: "buffer";
height: number;
jpegOptions: {
force: boolean;
quality: number;
};
} = {
): void => {
const options = {
height: 320,
responseType: "buffer",
responseType: "buffer" as const,
jpegOptions: {
force: true,
quality: 60,
},
};
const thumbnailBuffer: Buffer = await imageThumbnail(
filePath,
options,
);
await Bun.write(thumbnailPath, thumbnailBuffer.buffer);
resolve(await Bun.file(thumbnailPath).arrayBuffer());
await Bun.file(filePath).unlink();
await Bun.file(thumbnailPath).unlink();
} catch (error) {
reject(error as Error);
}
imageThumbnail(filePath, options)
.then(
(thumbnailBuffer: Buffer): Promise<ArrayBuffer> =>
Bun.write(thumbnailPath, thumbnailBuffer.buffer).then(
(): Promise<ArrayBuffer> => Bun.file(thumbnailPath).arrayBuffer(),
),
)
.then((arrayBuffer: ArrayBuffer) => {
resolve(arrayBuffer);
return Promise.all([
Bun.file(filePath).unlink(),
Bun.file(thumbnailPath).unlink(),
]);
})
.catch(reject);
},
);
}
@ -104,20 +95,14 @@ async function createThumbnails(files: FileEntry[]): Promise<void> {
try {
fileArrayBuffer = await Bun.file(filePath).arrayBuffer();
} catch {
logger.error([
"Could not generate thumbnail for file:",
fileName,
]);
logger.error(["Could not generate thumbnail for file:", fileName]);
continue;
}
} else {
try {
fileArrayBuffer = await s3.file(fileName).arrayBuffer();
} catch {
logger.error([
"Could not generate thumbnail for file:",
fileName,
]);
logger.error(["Could not generate thumbnail for file:", fileName]);
continue;
}
}
@ -149,10 +134,7 @@ async function createThumbnails(files: FileEntry[]): Promise<void> {
: await generateImageThumbnail(tempFilePath, tempThumbnailPath);
if (!thumbnailArrayBuffer) {
logger.error([
"Could not generate thumbnail for file:",
fileName,
]);
logger.error(["Could not generate thumbnail for file:", fileName]);
continue;
}
@ -204,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]);
};

View file

@ -1,161 +0,0 @@
import {
isValidEmail,
isValidPassword,
isValidUsername,
} from "@config/sql/users";
import { password as bunPassword, type ReservedSQL, sql } from "bun";
import { logger } from "@/helpers/logger";
import { sessionManager } from "@/helpers/sessions";
const routeDef: RouteDef = {
method: "POST",
accepts: "application/json",
returns: "application/json",
needsBody: "json",
};
async function handler(
request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
if (request.session) {
if ((request.session as ApiUserSession).is_api) {
return Response.json(
{
success: false,
code: 403,
error: "You cannot log in while using an authorization token",
},
{ status: 403 },
);
}
return Response.json(
{
success: false,
code: 403,
error: "Already logged in",
},
{ status: 403 },
);
}
const { username, email, password } = requestBody as {
username: string;
email: string;
password: string;
};
if (!password || (!username && !email)) {
return Response.json(
{
success: false,
code: 400,
error: "Expected username or email, and password",
},
{ status: 400 },
);
}
const errors: string[] = [];
const validations: UserValidation[] = [
{ check: isValidUsername(username), field: "Username" },
{ check: isValidEmail(email), field: "Email" },
{ check: isValidPassword(password), field: "Password" },
];
validations.forEach(({ check }: UserValidation): void => {
if (!check.valid && check.error) {
errors.push(check.error);
}
});
if (errors.length > 0) {
return Response.json(
{
success: false,
code: 400,
errors,
},
{ status: 400 },
);
}
const reservation: ReservedSQL = await sql.reserve();
let user: User | null = null;
try {
user = await reservation`
SELECT * FROM users
WHERE (username = ${username} OR email = ${email})
LIMIT 1;
`.then((rows: User[]): User | null => rows[0]);
if (!user) {
await bunPassword.verify("fake", await bunPassword.hash("fake"));
return Response.json(
{
success: false,
code: 401,
error: "Invalid username, email, or password",
},
{ status: 401 },
);
}
const passwordMatch: boolean = await bunPassword.verify(
password,
user.password,
);
if (!passwordMatch) {
return Response.json(
{
success: false,
code: 401,
error: "Invalid username, email, or password",
},
{ status: 401 },
);
}
} catch (error) {
logger.error(["Error logging in", error as Error]);
return Response.json(
{
success: false,
code: 500,
error: "An error occurred while logging in",
},
{ status: 500 },
);
} finally {
if (reservation) reservation.release();
}
const sessionCookie: string = await sessionManager.createSession(
{
id: user.id,
username: user.username,
email: user.email,
email_verified: user.email_verified,
roles: user.roles[0].split(","),
avatar: user.avatar,
timezone: user.timezone,
authorization_token: user.authorization_token,
},
request.headers.get("User-Agent") || "",
);
return Response.json(
{
success: true,
code: 200,
},
{ status: 200, headers: { "Set-Cookie": sessionCookie } },
);
}
export { handler, routeDef };

View file

@ -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 };

View file

@ -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

View file

@ -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

View file

@ -1,20 +1,166 @@
import { getSetting } from "@config/sql/settings";
import { renderEjsTemplate } from "@helpers/ejs";
import {
isValidEmail,
isValidPassword,
isValidUsername,
} from "@lib/validators";
import { type ReservedSQL, password as bunPassword, sql } from "bun";
import { sessionManager } from "@/lib/jwt";
import { logger } from "@creations.works/logger";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
method: "POST",
accepts: "application/json",
returns: "application/json",
needsBody: "json",
};
async function handler(): Promise<Response> {
const ejsTemplateData: EjsTemplateData = {
title: "Hello, World!",
instance_name:
(await getSetting("instance_name")) || "Unnamed Instance",
async function handler(
request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
if (request.session) {
if ((request.session as ApiUserSession).is_api) {
return Response.json(
{
success: false,
code: 403,
error: "You cannot log in while using an authorization token",
},
{ status: 403 },
);
}
return Response.json(
{
success: false,
code: 403,
error: "Already logged in",
},
{ status: 403 },
);
}
const { username, email, password } = requestBody as {
username: string;
email: string;
password: string;
};
return await renderEjsTemplate("auth/login", ejsTemplateData);
if (!password || (!username && !email)) {
return Response.json(
{
success: false,
code: 400,
error: "Expected username or email, and password",
},
{ status: 400 },
);
}
const errors: string[] = [];
const validations: UserValidation[] = [
username ? { check: isValidUsername(username), field: "Username" } : null,
email ? { check: isValidEmail(email), field: "Email" } : null,
password ? { check: isValidPassword(password), field: "Password" } : null,
].filter(Boolean) as UserValidation[];
for (const { check } of validations) {
if (!check.valid && check.error) {
errors.push(check.error);
}
}
if (!username && !email) {
errors.push("Either a username or an email is required.");
}
if (errors.length > 0) {
return Response.json(
{
success: false,
code: 400,
errors,
},
{ status: 400 },
);
}
const reservation: ReservedSQL = await sql.reserve();
let user: User | null = null;
try {
[user] = await reservation`
SELECT * FROM users
WHERE (username = ${username} OR email = ${email})
LIMIT 1;
`;
if (!user) {
await bunPassword.verify("fake", await bunPassword.hash("fake"));
return Response.json(
{
success: false,
code: 401,
error: "Invalid username, email, or password",
},
{ status: 401 },
);
}
const passwordMatch: boolean = await bunPassword.verify(
password,
user.password,
);
if (!passwordMatch) {
return Response.json(
{
success: false,
code: 401,
error: "Invalid username, email, or password",
},
{ status: 401 },
);
}
} catch (error) {
logger.error(["Error logging in", error as Error]);
return Response.json(
{
success: false,
code: 500,
error: "An error occurred while logging in",
},
{ status: 500 },
);
} finally {
if (reservation) reservation.release();
}
const sessionCookie: string = await sessionManager.createSession(
{
id: user.id,
username: user.username,
email: user.email,
email_verified: user.email_verified,
roles: user.roles[0].split(","),
avatar: user.avatar,
timezone: user.timezone,
authorization_token: user.authorization_token,
},
request.headers.get("User-Agent") || "",
);
return Response.json(
{
success: true,
code: 200,
},
{ status: 200, headers: { "Set-Cookie": sessionCookie } },
);
}
export { handler, routeDef };

View file

@ -1,4 +1,4 @@
import { sessionManager } from "@/helpers/sessions";
import { sessionManager } from "@/lib/jwt";
const routeDef: RouteDef = {
method: "POST",

View file

@ -4,12 +4,12 @@ import {
isValidInvite,
isValidPassword,
isValidUsername,
} from "@config/sql/users";
import { password as bunPassword, type ReservedSQL, sql } from "bun";
} 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",
@ -49,17 +49,17 @@ async function handler(
{ check: isValidPassword(password), field: "Password" },
];
validations.forEach(({ check }: UserValidation): void => {
for (const { check } of validations) {
if (!check.valid && check.error) {
errors.push(check.error);
}
});
}
const normalizedUsername: string = username.normalize("NFC");
const reservation: ReservedSQL = await sql.reserve();
let firstUser: boolean = false;
let firstUser = false;
let inviteData: Invite | null = null;
let roles: string[] = [];
const roles: string[] = [];
try {
const registrationEnabled: boolean =
@ -69,13 +69,16 @@ async function handler(
firstUser =
Number(
(await reservation`SELECT COUNT(*) AS count FROM users;`)[0]
?.count,
(await reservation`SELECT COUNT(*) AS count FROM users;`)[0]?.count,
) === 0;
let inviteValid = true;
if (!firstUser && invite) {
const inviteValidation: { valid: boolean; error?: string } =
isValidInvite(invite);
inviteValid = inviteValidation.valid;
if (!inviteValidation.valid && inviteValidation.error) {
errors.push(inviteValidation.error);
}
@ -89,10 +92,12 @@ async function handler(
}
roles.push("user");
if (firstUser) roles.push("admin");
if (firstUser) {
roles.push("admin");
roles.push("superadmin");
}
const result: { usernameExists: boolean; emailExists: boolean }[] =
await reservation`
const [result] = await reservation`
SELECT
EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${normalizedUsername})) AS "usernameExists",
EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists";
@ -104,15 +109,13 @@ async function handler(
errors.push("Username or email already exists");
}
if (invite && !firstUser) {
const result: Invite[] =
if (invite && inviteValid && !firstUser) {
[inviteData] =
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
if (!result || result.length === 0) {
if (!inviteData) {
errors.push("Invalid invite");
}
inviteData = result[0];
}
} catch (error) {
errors.push("An error occurred while checking for existing users");
@ -140,13 +143,13 @@ async function handler(
: (await getSetting("default_timezone", reservation)) || "UTC";
try {
const result: User[] = await reservation`
[user] = await reservation`
INSERT INTO users (username, email, password, invited_by, roles, timezone)
VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${setTimezone})
RETURNING *;
`;
if (result.length === 0) {
if (!user) {
logger.error("User was not created");
return Response.json(
{
@ -158,8 +161,6 @@ async function handler(
);
}
user = result[0];
if (!user) {
logger.error("User was not created");
return Response.json(
@ -185,10 +186,7 @@ async function handler(
if (inviteData?.role) roles.push(inviteData.role);
}
} catch (error) {
logger.error([
"Error inserting user into the database:",
error as Error,
]);
logger.error(["Error inserting user into the database:", error as Error]);
return Response.json(
{
success: false,

View file

@ -0,0 +1,38 @@
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
if (!request.session) {
return Response.json(
{
success: false,
code: 403,
error: "Not logged in",
},
{ status: 403 },
);
}
const { session } = request;
if ((session as ApiUserSession).is_api === true) {
return Response.json(
{
success: false,
code: 403,
error: "You cannot use this endpoint with an authorization token",
},
{ status: 403 },
);
}
return Response.json({
success: true,
session,
});
}
export { routeDef, handler };

181
src/routes/files.ts Normal file
View file

@ -0,0 +1,181 @@
import { type ReservedSQL, type SQLQuery, sql } from "bun";
import { logger } from "@creations.works/logger";
import { isUUID } from "@lib/char";
function isValidSort(sortBy: string): boolean {
const validSorts: string[] = [
"size",
"created_at",
"expires_at",
"views",
"name",
"original_name",
"mime_type",
"extension",
];
return validSorts.includes(sortBy);
}
function validSortOrder(sortOrder: string): string {
const validSortOrder: { [key: string]: string } = {
asc: "ASC",
desc: "DESC",
ascending: "ASC",
descending: "DESC",
};
return validSortOrder[sortOrder.toLowerCase()] || "DESC";
}
const escapeLike: (value: string) => string = (value: string): string =>
value.replace(/[%_\\]/g, "\\$&");
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const {
user: user_id,
count = "25",
page = "0",
sort_by = "created_at",
sort_order = "DESC",
search_value,
} = request.query as {
user: string;
count: string;
page: string;
sort_by: string;
sort_order: string;
search_value: string;
};
if (!isValidSort(sort_by)) {
return Response.json(
{
success: false,
code: 400,
error: "Invalid sort_by value",
},
{ status: 400 },
);
}
const userLookup: string | undefined = user_id || request.session?.id;
if (!userLookup) {
return Response.json(
{
success: false,
code: 400,
error: "Please provide a user ID or log in",
},
{ status: 400 },
);
}
const isId: boolean = isUUID(userLookup);
if (!isId) {
return Response.json(
{
success: false,
code: 400,
error: "Invalid user ID",
},
{ status: 400 },
);
}
const isSelf: boolean = request.session?.id === userLookup;
const isAdmin: boolean = request.session
? request.session.roles.includes("admin")
: false;
if (!isSelf && !isAdmin) {
return Response.json(
{
success: false,
code: 403,
error: "Unauthorized",
},
{ status: 403 },
);
}
const safeCount: number = Math.min(Number.parseInt(count) || 25, 100);
const safePage: number = Math.max(Number.parseInt(page) || 0, 0);
const offset: number = safePage * safeCount;
const sortColumn: string = sort_by || "created_at";
const order: "ASC" | "DESC" = validSortOrder(sort_order) as "ASC" | "DESC";
const safeSearchValue: string = escapeLike(search_value || "");
let files: FileEntry[];
let totalPages = 0;
let totalFiles = 0;
const reservation: ReservedSQL = await sql.reserve();
try {
// ! i really dont understand why bun wont accept reservation(order)`
function orderBy(field_name: string, orderBy: "ASC" | "DESC"): SQLQuery {
return reservation`ORDER BY ${reservation(field_name)} ${orderBy === "ASC" ? reservation`ASC` : reservation`DESC`}`;
}
files = await reservation`
SELECT
* FROM files
WHERE owner = ${userLookup} AND
(name ILIKE '%' || ${safeSearchValue} || '%' OR
original_name ILIKE '%' || ${safeSearchValue} || '%')
${orderBy(sortColumn, order)}
LIMIT ${safeCount} OFFSET ${offset};
`;
if (!files.length) {
return Response.json(
{
success: false,
code: 404,
error: "No files found",
},
{ status: 404 },
);
}
[{ count: totalFiles }] = await reservation`
SELECT COUNT(*)::int as count FROM files
WHERE owner = ${userLookup};
`;
totalPages = Math.ceil(totalFiles / safeCount);
} catch (error) {
logger.error(["Error fetching files", error as Error]);
return Response.json(
{
success: false,
code: 500,
error: "Internal server error",
},
{ status: 500 },
);
} finally {
reservation.release();
}
return Response.json(
{
success: true,
code: 200,
total_files: totalFiles,
total_pages: totalPages,
files,
},
{ status: 200 },
);
}
export { handler, routeDef };

View file

@ -1,9 +1,9 @@
import { dataType } from "@config/environment";
import { s3, sql, type SQLQuery } from "bun";
import { resolve } from "path";
import { resolve } from "node:path";
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",
@ -124,7 +124,9 @@ async function handler(
);
}
const isAdmin: boolean = request.session.roles.includes("admin");
const isAdmin: boolean =
request.session.roles.includes("admin") ||
request.session.roles.includes("superadmin");
const { query: file } = request.params as { query: string };
let { files } = requestBody as { files: string[] | string };
// const { password } = request.query as { password: string };
@ -134,26 +136,14 @@ async function handler(
try {
if (file && !(typeof file === "string" && file.length === 0)) {
await processFile(
request,
file,
isAdmin,
failedFiles,
successfulFiles,
);
await processFile(request, file, isAdmin, failedFiles, successfulFiles);
} else if (files) {
files = Array.isArray(files)
? files
: files.split(/[, ]+/).filter(Boolean);
for (const file of files) {
await processFile(
request,
file,
isAdmin,
failedFiles,
successfulFiles,
);
await processFile(request, file, isAdmin, failedFiles, successfulFiles);
}
}
} catch (error) {

View file

@ -1,16 +1,17 @@
import { dataType } from "@config/environment";
import { resolve } from "node:path";
import { dataType } from "@config";
import { getSetting } from "@config/sql/settings";
import {
type SQLQuery,
password as bunPassword,
randomUUIDv7,
s3,
sql,
type SQLQuery,
} from "bun";
import { exiftool } from "exiftool-vendored";
import { DateTime } from "luxon";
import { resolve } from "path";
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",
@ -97,9 +97,7 @@ async function removeExifData(
LocationName: null,
};
await exiftool.write(tempInputPath, tagsToRemove, [
"-overwrite_original",
]);
await exiftool.write(tempInputPath, tagsToRemove, ["-overwrite_original"]);
return await Bun.file(tempInputPath).arrayBuffer();
} catch (error) {
@ -161,9 +159,9 @@ async function processFile(
};
const extension: string | null = getExtension(file.name);
let rawName: string | null = nameWithoutExtension(file.name);
const rawName: string | null = nameWithoutExtension(file.name);
const maxViews: number | null =
parseInt(user_provided_max_views, 10) || null;
Number.parseInt(user_provided_max_views, 10) || null;
if (!rawName) {
failedFiles.push({
@ -176,13 +174,9 @@ async function processFile(
let hashedPassword: string | null = null;
if (user_provided_password) {
try {
hashedPassword = await bunPassword.hash(user_provided_password, {
algorithm: "argon2id",
});
} catch (error) {
throw error;
}
}
const randomUUID: string = randomUUIDv7();
@ -190,7 +184,7 @@ async function processFile(
? user_provided_tags
: (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []);
let uploadEntry: FileUpload = {
const uploadEntry: FileUpload = {
id: randomUUID as UUID,
owner: session.id as UUID,
name: rawName,
@ -201,9 +195,7 @@ async function processFile(
password: hashedPassword,
favorite: user_wants_favorite === "true" || user_wants_favorite === "1",
tags: tags,
expires_at: delete_short_string
? getNewTimeUTC(delete_short_string)
: null,
expires_at: delete_short_string ? getNewTimeUTC(delete_short_string) : null,
};
if (name_format === "date") {
@ -221,7 +213,7 @@ async function processFile(
// ? Should work not sure about non-english characters
const sanitizedFileName: string = rawName
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\p{Mn}/gu, "")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.toLowerCase();
@ -280,7 +272,7 @@ async function processFile(
return;
}
} else {
path = "/uploads/" + uuidWithExtension;
path = `/uploads/${uuidWithExtension}`;
try {
await s3.write(path, fileBuffer);
@ -296,7 +288,7 @@ async function processFile(
}
try {
const result: FileUpload[] = await sql`
const [result] = await sql`
INSERT INTO files ( id, owner, folder, name, original_name, mime_type, extension, size, max_views, password, favorite, tags, expires_at )
VALUES (
${uploadEntry.id}, ${uploadEntry.owner}, ${folder_identifier}, ${uploadEntry.name},
@ -308,7 +300,7 @@ async function processFile(
RETURNING id;
`;
if (result.length === 0) {
if (!result) {
failedFiles.push({
reason: "Failed to create file entry",
file: key,
@ -325,7 +317,7 @@ async function processFile(
return;
}
if (uploadEntry.password) delete uploadEntry.password;
if (uploadEntry.password) uploadEntry.password = undefined;
uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`;
successfulFiles.push(uploadEntry);
@ -366,9 +358,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
requestBody.append(
"file",
new Blob([body], { type: request.actualContentType }),
request.actualContentType === "text/plain"
? "file.txt"
: "file.json",
request.actualContentType === "text/plain" ? "file.txt" : "file.json",
);
} else {
return Response.json(
@ -442,20 +432,16 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}
const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter(
(file: FileUpload): boolean =>
supportsThumbnail(file.mime_type as string),
(file: FileUpload): boolean => supportsThumbnail(file.mime_type as string),
);
if (
(await getSetting("enable_thumbnails")) === "true" &&
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({
files: filesThatSupportThumbnails,
});

View file

@ -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 };

View file

@ -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",
@ -28,7 +28,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}
let user: GetUser | null = null;
let isSelf: boolean = false;
let isSelf = false;
const isId: boolean = isUUID(query);
const normalized: string = isId ? query : query.normalize("NFC");
const isAdmin: boolean = request.session
@ -49,11 +49,11 @@ async function handler(request: ExtendedRequest): Promise<Response> {
const reservation: ReservedSQL = await sql.reserve();
try {
const result: GetUser[] = isId
[user] = isId
? await reservation`SELECT * FROM users WHERE id = ${normalized}`
: await reservation`SELECT * FROM users WHERE username = ${normalized}`;
if (result.length === 0) {
if (!user) {
return Response.json(
{
success: false,
@ -64,8 +64,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
);
}
user = result[0];
isSelf = request.session ? user.id === request.session.id : false;
const files: { count: bigint }[] =
@ -112,9 +110,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
);
}
delete user.password;
delete user.authorization_token;
if (!isSelf) delete user.email;
user.password = undefined;
user.authorization_token = undefined;
if (!isSelf) user.email = undefined;
user.roles = user.roles ? user.roles[0].split(",") : [];

View file

@ -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",
@ -37,7 +37,9 @@ async function handler(
);
}
const isAdmin: boolean = request.session.roles.includes("admin");
const isAdmin: boolean =
request.session.roles.includes("admin") ||
request.session.roles.includes("superadmin");
if (!isAdmin && !getSetting("allow_user_invites")) {
return Response.json(
@ -67,21 +69,19 @@ async function handler(
);
}
const expirationDate: string | null = expires
? getNewTimeUTC(expires)
: null;
const expirationDate: string | null = expires ? getNewTimeUTC(expires) : null;
const maxUses: number = Number(max_uses) || 1;
const inviteRole: string = role || "user";
let invite: Invite | null = null;
try {
const result: Invite[] = await sql`
[invite] = await sql`
INSERT INTO invites (created_by, expiration, max_uses, role, id)
VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)})
RETURNING *;
`;
if (result.length === 0) {
if (!invite) {
logger.error("Invite failed to create");
return Response.json(
@ -93,8 +93,6 @@ async function handler(
{ status: 500 },
);
}
invite = result[0];
} catch (error) {
logger.error(["Error creating invite:", error as Error]);

View file

@ -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",
@ -21,7 +21,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
);
}
const isAdmin: boolean = request.session.roles.includes("admin");
const isAdmin: boolean =
request.session.roles.includes("admin") ||
request.session.roles.includes("superadmin");
const { invite } = request.params as { invite: string };
if (!invite) {
@ -52,10 +54,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
let inviteData: Invite | null = null;
try {
const result: Invite[] =
[inviteData] =
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
if (result.length === 0) {
if (!inviteData) {
return Response.json(
{
success: false,
@ -66,8 +68,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
);
}
inviteData = result[0];
if (!isAdmin && inviteData.created_by !== request.session.id) {
return Response.json(
{

View file

@ -1,9 +1,9 @@
import { dataType } from "@config/environment";
import { resolve } from "node:path";
import { dataType } from "@config";
import { type BunFile, type ReservedSQL, sql } from "bun";
import { resolve } from "path";
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",
@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}
if (json === "true" || json === "1") {
delete fileData.password;
fileData.password = undefined;
fileData.tags = fileData.tags = fileData.tags[0]?.trim()
? fileData.tags[0].split(",").filter((tag: string) => tag.trim())
: [];
@ -139,9 +139,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
} else {
path = resolve(
dataType.path,
`${fileData.id}${
fileData.extension ? `.${fileData.extension}` : ""
}`,
`${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`,
);
}
} else {
@ -157,9 +155,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
return new Response(bunStream, {
headers: {
"Content-Type": shouldShowThumbnail
? "image/jpeg"
: fileData.mime_type,
"Content-Type": shouldShowThumbnail ? "image/jpeg" : fileData.mime_type,
"Content-Disposition":
downloadFile === "true" || downloadFile === "1"
? `attachment; filename="${fileData.original_name || fileData.name}"`

View file

@ -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",
@ -53,7 +53,8 @@ async function handler(
{
success: false,
code: 400,
error: "Expected key to be a string and value to be a string, boolean, or number",
error:
"Expected key to be a string and value to be a string, boolean, or number",
},
{ status: 400 },
);

View file

@ -1,10 +1,10 @@
import { dataType } from "@config/environment";
import { isValidUsername } from "@config/sql/users";
import { resolve } from "node:path";
import { dataType } from "@config";
import { isValidUsername } from "@lib/validators";
import { type BunFile, type ReservedSQL, sql } from "bun";
import { resolve } from "path";
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",
@ -71,11 +71,12 @@ async function handler(request: ExtendedRequest): Promise<Response> {
if (json === "true" || json === "1") {
return Response.json(
{
success: true, code: 200,
success: true,
code: 200,
avatar: {
...avatar,
url: `${getBaseUrl(request)}/user/avatar/${user.id}`,
}
},
},
{ status: 200 },
);

View file

@ -1,9 +1,9 @@
import { dataType } from "@config/environment";
import { resolve } from "node:path";
import { dataType } from "@config";
import { s3, sql } from "bun";
import { resolve } from "path";
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,
@ -21,9 +21,7 @@ async function deleteAvatar(
try {
if (dataType.type === "local" && dataType.path) {
await Bun.file(
resolve(dataType.path, "avatars", fileName),
).unlink();
await Bun.file(resolve(dataType.path, "avatars", fileName)).unlink();
} else {
await s3.delete(`/avatars/${fileName}`);
}
@ -61,7 +59,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}
const userID: UUID = (request.query.user as UUID) || request.session.id;
const isAdmin: boolean = request.session.roles.includes("admin");
const isAdmin: boolean =
request.session.roles.includes("admin") ||
request.session.roles.includes("superadmin");
if (request.session.id !== userID && !isAdmin) {
return Response.json(
@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
},
},
);
} else {
}
return Response.json(
{
success: true,
@ -124,7 +124,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
},
{ status: 200 },
);
}
} catch (error) {
logger.error(["Error processing delete request:", error as Error]);

View file

@ -1,12 +1,12 @@
import { dataType } from "@config/environment";
import { isValidTypeOrExtension } from "@config/sql/avatars";
import { resolve } from "node:path";
import { dataType } from "@config";
import { getSetting } from "@config/sql/settings";
import { isValidTypeOrExtension } from "@lib/validators";
import { s3, sql } from "bun";
import { resolve } from "path";
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,
@ -50,10 +50,7 @@ async function processFile(
await s3.delete(`/avatars/${existingFileName}`);
}
} catch (error) {
logger.error([
"Error deleting existing avatar file:",
error as Error,
]);
logger.error(["Error deleting existing avatar file:", error as Error]);
}
}
@ -138,9 +135,7 @@ async function handler(
}
const file: File | null =
(formData.get("file") as File) ||
(formData.get("avatar") as File) ||
null;
(formData.get("file") as File) || (formData.get("avatar") as File) || null;
if (!file.type || file.type === "") {
return Response.json(
@ -206,7 +201,7 @@ async function handler(
},
},
);
} else {
}
return Response.json(
{
success: true,
@ -216,7 +211,6 @@ async function handler(
},
{ status: 200 },
);
}
} catch (error) {
logger.error(["Error processing file:", error as Error]);

View file

@ -1,17 +1,16 @@
import { environment } from "@config/environment";
import { logger } from "@helpers/logger";
import { resolve } from "node:path";
import { environment } from "@config";
import { logger } from "@creations.works/logger";
import {
type BunFile,
FileSystemRouter,
type MatchedRoute,
type Serve,
} from "bun";
import { resolve } from "path";
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,11 +40,7 @@ class ServerHandler {
maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon
});
logger.info(
`Server running at http://${server.hostname}:${server.port}`,
true,
);
logger.info(`Server running at ${environment.fqdn}`);
this.logRoutes();
}
@ -63,10 +58,15 @@ class ServerHandler {
}
}
private async serveStaticFile(pathname: string): Promise<Response> {
try {
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 {
@ -77,47 +77,87 @@ class ServerHandler {
if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string =
file.type || "application/octet-stream";
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}`);
return new Response("Not Found", { status: 404 });
response = 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 });
logger.error([`Error serving static file: ${pathname}`, error as Error]);
response = new Response("Internal Server Error", { status: 500 });
}
this.logRequest(request, response, ip);
return response;
}
private logRequest(
request: ExtendedRequest,
response: Response,
ip: string | undefined,
): void {
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
}
private async handleRequest(
request: ExtendedRequest,
request: Request,
server: BunServer,
): Promise<Response> {
request.startPerf = performance.now();
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": environment.frontendUrl,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Headers":
request.headers.get("Access-Control-Request-Headers") || "*",
},
});
}
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;
try {
const routeModule: RouteModule = await import(filePath);
const contentType: string | null =
request.headers.get("Content-Type");
const contentType: string | null = request.headers.get("Content-Type");
const actualContentType: string | null = contentType
? contentType.split(";")[0].trim()
: null;
@ -144,9 +184,7 @@ class ServerHandler {
if (
(Array.isArray(routeModule.routeDef.method) &&
!routeModule.routeDef.method.includes(
request.method,
)) ||
!routeModule.routeDef.method.includes(request.method)) ||
(!Array.isArray(routeModule.routeDef.method) &&
routeModule.routeDef.method !== request.method)
) {
@ -171,9 +209,7 @@ class ServerHandler {
if (Array.isArray(expectedContentType)) {
matchesAccepts =
expectedContentType.includes("*/*") ||
expectedContentType.includes(
actualContentType || "",
);
expectedContentType.includes(actualContentType || "");
} else {
matchesAccepts =
expectedContentType === "*/*" ||
@ -194,19 +230,15 @@ class ServerHandler {
{ status: 406 },
);
} else {
request.params = params;
request.query = query;
request.actualContentType = actualContentType;
extendedRequest.params = params;
extendedRequest.query = query;
extendedRequest.actualContentType = actualContentType;
request.session =
(await authByToken(request)) ||
extendedRequest.session =
(await authByToken(extendedRequest)) ||
(await sessionManager.getSession(request));
response = await routeModule.handler(
request,
requestBody,
server,
);
response = await routeModule.handler(request, requestBody, server);
if (routeModule.routeDef.returns !== "*/*") {
response.headers.set(
@ -217,10 +249,7 @@ class ServerHandler {
}
}
} catch (error: unknown) {
logger.error([
`Error handling route ${request.url}:`,
error as Error,
]);
logger.error([`Error handling route ${request.url}:`, error as Error]);
response = Response.json(
{
@ -242,27 +271,21 @@ 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() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
if (response?.headers) {
response.headers.set(
"Access-Control-Allow-Origin",
environment.frontendUrl,
);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set(
"Access-Control-Allow-Headers",
request.headers.get("Access-Control-Request-Headers") || "Content-Type",
);
}
return response;
}

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include("../global", { styles: ["auth/login"], scripts: [] }) %>
</head>
<body>
<div class="container">
<div class="header">
<h1><%= instance_name %></h1>
</div>
<div class="content">
<form id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" id="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
</div>
<div class="form-group">
<button type="submit">Login</button>
</div>
</form>
</div>
</div>
</body>
</html>

View file

@ -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>

View file

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include("global", { styles: [], scripts: [] }) %>
</head>
<body>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { logger } from "@helpers/logger";
import { type ServerWebSocket } from "bun";
import { logger } from "@creations.works/logger";
import type { ServerWebSocket } from "bun";
class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void {
@ -20,11 +20,7 @@ class WebSocketHandler {
}
}
public handleClose(
ws: ServerWebSocket,
code: number,
reason: string,
): void {
public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
}
}

View file

@ -1,9 +0,0 @@
/** @type {import('stylelint').Config} */
export default {
extends: ["stylelint-config-standard"],
rules: {
"color-function-notation": "modern",
"font-family-name-quotes": "always-where-required",
"declaration-empty-line-before": "never",
},
};

View file

@ -2,28 +2,15 @@
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
],
"@config/*": [
"config/*"
],
"@types/*": [
"types/*"
],
"@helpers/*": [
"src/helpers/*"
]
"@/*": ["src/*"],
"@config": ["config/index.ts"],
"@config/*": ["config/*"],
"@types/*": ["types/*"],
"@lib/*": ["src/lib/*"]
},
"typeRoots": [
"./src/types",
"./node_modules/@types"
],
"typeRoots": ["./src/types", "./node_modules/@types"],
// Enable latest features
"lib": [
"ESNext",
"DOM"
],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
@ -41,11 +28,7 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"noPropertyAccessFromIndexSignature": false
},
"include": [
"src",
"types",
"config"
],
"include": ["src", "types", "config"]
}

2
types/config.d.ts vendored
View file

@ -2,6 +2,8 @@ type Environment = {
port: number;
host: string;
development: boolean;
fqdn: string;
frontendUrl: string;
};
type UserValidation = {

3
types/ejs.d.ts vendored
View file

@ -1,3 +0,0 @@
interface EjsTemplateData {
[key: string]: string | number | boolean | object | undefined | null;
}

9
types/logger.d.ts vendored
View file

@ -1,9 +0,0 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};