Compare commits
No commits in common. "main" and "dev" have entirely different histories.
65 changed files with 1788 additions and 1336 deletions
11
.env.example
11
.env.example
|
@ -2,19 +2,16 @@
|
|||
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_URL=redis://localhost:6379
|
||||
REDIS_TTL=3600
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
# REDIS_USERNAME=redis
|
||||
# REDIS_PASSWORD=redis
|
||||
|
||||
# For sessions and cookies, can be generated using `openssl rand -base64 32`
|
||||
JWT_SECRET=your_jwt_secret
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
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
7
.vscode/extensions.json
vendored
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"mikestead.dotenv",
|
||||
"EditorConfig.EditorConfig",
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
44
biome.json
44
biome.json
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
37
config/environment.ts
Normal file
37
config/environment.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
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,
|
||||
};
|
|
@ -1,66 +0,0 @@
|
|||
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 };
|
|
@ -1,27 +0,0 @@
|
|||
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),
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
export const redisTtl: number = process.env.REDIS_TTL
|
||||
? Number.parseInt(process.env.REDIS_TTL, 10)
|
||||
: 60 * 60 * 1; // 1 hour
|
|
@ -1,18 +1,18 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 6;
|
||||
|
||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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,7 +28,17 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 5;
|
||||
|
||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const functionExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const triggerExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 4;
|
||||
|
||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const functionExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const triggerExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 3;
|
||||
|
||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/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 = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const functionExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
const triggerExists: { exists: boolean }[] = await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
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 activeReservation`
|
||||
await reservation`
|
||||
INSERT INTO settings ("key", "value")
|
||||
VALUES (${setting.key}, ${setting.value})
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
|
@ -88,25 +88,27 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Validation functions
|
||||
|
||||
export async function getSetting(
|
||||
key: string,
|
||||
reservation?: ReservedSQL,
|
||||
): Promise<string | null> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: { value: string }[] =
|
||||
await activeReservation`SELECT value FROM settings WHERE "key" = ${key};`;
|
||||
await reservation`SELECT value FROM settings WHERE "key" = ${key};`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
|
@ -118,7 +120,7 @@ export async function getSetting(
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -128,15 +130,15 @@ export async function setSetting(
|
|||
value: string,
|
||||
reservation?: ReservedSQL,
|
||||
): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
INSERT INTO settings ("key", "value", updated_at)
|
||||
VALUES (${key}, ${value}, NOW())
|
||||
ON CONFLICT ("key")
|
||||
|
@ -146,7 +148,7 @@ export async function setSetting(
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -155,21 +157,21 @@ export async function deleteSetting(
|
|||
key: string,
|
||||
reservation?: ReservedSQL,
|
||||
): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`DELETE FROM settings WHERE "key" = ${key};`;
|
||||
await reservation`DELETE FROM settings WHERE "key" = ${key};`;
|
||||
} catch (error) {
|
||||
logger.error(["Could not delete the setting:", error as Error]);
|
||||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -177,16 +179,16 @@ export async function deleteSetting(
|
|||
export async function getAllSettings(
|
||||
reservation?: ReservedSQL,
|
||||
): Promise<{ key: string; value: string }[]> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: { key: string; value: string }[] =
|
||||
await activeReservation`SELECT "key", "value" FROM settings;`;
|
||||
await reservation`SELECT "key", "value" FROM settings;`;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
@ -194,7 +196,7 @@ export async function getAllSettings(
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export const order: number = 1;
|
||||
|
||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||
let selfReservation = false;
|
||||
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await activeReservation`
|
||||
await reservation`
|
||||
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,7 +32,122 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
|||
throw error;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.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 };
|
||||
}
|
||||
|
|
142
eslint.config.js
Normal file
142
eslint.config.js
Normal file
|
@ -0,0 +1,142 @@
|
|||
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",
|
||||
},
|
||||
},
|
||||
];
|
45
package.json
45
package.json
|
@ -1,35 +1,46 @@
|
|||
{
|
||||
"name": "atums.world",
|
||||
"private": true,
|
||||
"name": "bun_frontend_template",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"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",
|
||||
"dev": "bun run --watch src/index.ts --dev",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "bun lint --fix",
|
||||
"cleanup": "rm -rf logs node_modules bun.lockdb",
|
||||
"clearTable": "bun run src/helpers/commands/clearTable.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "^1.2.13",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/bun": "^1.2.5",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/image-thumbnail": "^1.0.4",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"globals": "16.0.0",
|
||||
"prettier": "^3.5.3"
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@creations.works/logger": "^1.0.3",
|
||||
"eta": "^3.5.0",
|
||||
"exiftool-vendored": "^30.0.0",
|
||||
"fast-jwt": "6.0.1",
|
||||
"ejs": "^3.1.10",
|
||||
"exiftool-vendored": "^29.2.0",
|
||||
"fast-jwt": "^5.0.5",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"image-thumbnail": "^1.0.17",
|
||||
"luxon": "^3.6.1"
|
||||
"luxon": "^3.5.0",
|
||||
"redis": "^4.7.0"
|
||||
}
|
||||
}
|
||||
|
|
27
public/css/auth/login.css
Normal file
27
public/css/auth/login.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
.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;
|
||||
}
|
|
@ -1,48 +1,40 @@
|
|||
[data-theme="dark"] {
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
import { isUUID } from "@helpers/char";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
export async function authByToken(
|
||||
request: ExtendedRequest,
|
||||
reservation?: ReservedSQL,
|
||||
): Promise<ApiUserSession | null> {
|
||||
let selfReservation = false;
|
||||
let activeReservation: ReservedSQL | undefined = reservation;
|
||||
let selfReservation: boolean = false;
|
||||
|
||||
const authorizationHeader: string | null =
|
||||
request.headers.get("Authorization");
|
||||
|
@ -18,14 +17,14 @@ export async function authByToken(
|
|||
const authorizationToken: string = authorizationHeader.slice(7).trim();
|
||||
if (!authorizationToken || !isUUID(authorizationToken)) return null;
|
||||
|
||||
if (!activeReservation) {
|
||||
activeReservation = await sql.reserve();
|
||||
if (!reservation) {
|
||||
reservation = await sql.reserve();
|
||||
selfReservation = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: User[] =
|
||||
await activeReservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
|
||||
await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
|
||||
|
||||
if (result.length === 0) return null;
|
||||
|
||||
|
@ -45,7 +44,7 @@ export async function authByToken(
|
|||
return null;
|
||||
} finally {
|
||||
if (selfReservation) {
|
||||
activeReservation.release();
|
||||
reservation.release();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,8 +2,8 @@ import { DateTime } from "luxon";
|
|||
|
||||
export function timestampToReadable(timestamp?: number): string {
|
||||
const date: Date =
|
||||
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
|
||||
if (Number.isNaN(date.getTime())) return "Invalid Date";
|
||||
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
|
||||
if (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 = Number.parseInt(match[1], 10);
|
||||
const value: number = parseInt(match[1], 10);
|
||||
const unit: string = match[2];
|
||||
|
||||
switch (unit) {
|
||||
|
@ -84,14 +84,18 @@ export function isValidTimezone(timezone: string): boolean {
|
|||
}
|
||||
|
||||
export function generateRandomString(length?: number): string {
|
||||
const finalLength: number = length ?? Math.floor(Math.random() * 10) + 5;
|
||||
if (!length) {
|
||||
length = length || Math.floor(Math.random() * 10) + 5;
|
||||
}
|
||||
|
||||
const characters =
|
||||
const characters: string =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
let result: string = "";
|
||||
|
||||
for (let i = 0; i < finalLength; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
for (let i: number = 0; i < length; i++) {
|
||||
result += characters.charAt(
|
||||
Math.floor(Math.random() * characters.length),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
@ -168,7 +172,9 @@ 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 {
|
||||
|
@ -200,22 +206,18 @@ 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 = 0; i < args.length; i++) {
|
||||
for (let i: number = 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++;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { parseArgs } from "@lib/char";
|
||||
import { parseArgs } from "@helpers/char";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
(async (): Promise<void> => {
|
||||
|
@ -24,7 +24,8 @@ import { type ReservedSQL, sql } from "bun";
|
|||
error.message.includes("foreign key constraint")
|
||||
) {
|
||||
console.error(
|
||||
`Could not clear table "${table}" due to foreign key constraints.\nTry using --cascade if you want to remove dependent records.`,
|
||||
`Could not clear table "${table}" due to foreign key constraints.\n` +
|
||||
"Try using --cascade if you want to remove dependent records.",
|
||||
);
|
||||
} else {
|
||||
console.error("Could not clear table:", error);
|
26
src/helpers/ejs.ts
Normal file
26
src/helpers/ejs.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
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 },
|
||||
});
|
||||
}
|
205
src/helpers/logger.ts
Normal file
205
src/helpers/logger.ts
Normal file
|
@ -0,0 +1,205 @@
|
|||
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 };
|
204
src/helpers/redis.ts
Normal file
204
src/helpers/redis.ts
Normal file
|
@ -0,0 +1,204 @@
|
|||
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 };
|
194
src/helpers/sessions.ts
Normal file
194
src/helpers/sessions.ts
Normal file
|
@ -0,0 +1,194 @@
|
|||
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 };
|
|
@ -1,11 +1,11 @@
|
|||
import { join, resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { dataType } from "@config/environment.ts";
|
||||
import { logger } from "@helpers/logger.ts";
|
||||
import { type BunFile, s3, sql } from "bun";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import imageThumbnail from "image-thumbnail";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
declare let self: Worker;
|
||||
declare var self: Worker;
|
||||
|
||||
async function generateVideoThumbnail(
|
||||
filePath: string,
|
||||
|
@ -22,7 +22,10 @@ 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);
|
||||
})
|
||||
|
||||
|
@ -47,34 +50,40 @@ async function generateImageThumbnail(
|
|||
thumbnailPath: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
return new Promise(
|
||||
(
|
||||
async (
|
||||
resolve: (value: ArrayBuffer) => void,
|
||||
reject: (reason: Error) => void,
|
||||
): void => {
|
||||
const options = {
|
||||
height: 320,
|
||||
responseType: "buffer" as const,
|
||||
jpegOptions: {
|
||||
force: true,
|
||||
quality: 60,
|
||||
},
|
||||
};
|
||||
) => {
|
||||
try {
|
||||
const options: {
|
||||
responseType: "buffer";
|
||||
height: number;
|
||||
jpegOptions: {
|
||||
force: boolean;
|
||||
quality: number;
|
||||
};
|
||||
} = {
|
||||
height: 320,
|
||||
responseType: "buffer",
|
||||
jpegOptions: {
|
||||
force: true,
|
||||
quality: 60,
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -95,14 +104,20 @@ 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;
|
||||
}
|
||||
}
|
||||
|
@ -134,7 +149,10 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -186,5 +204,5 @@ self.onmessage = async (event: MessageEvent): Promise<void> => {
|
|||
};
|
||||
|
||||
self.onerror = (error: ErrorEvent): void => {
|
||||
logger.error(["An error occurred in the thumbnail worker:", error.message]);
|
||||
logger.error(error);
|
||||
};
|
110
src/index.ts
110
src/index.ts
|
@ -1,12 +1,14 @@
|
|||
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 { 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 { 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);
|
||||
|
@ -15,7 +17,9 @@ 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 };
|
||||
}),
|
||||
);
|
||||
|
@ -36,69 +40,63 @@ async function initializeDatabase(): Promise<void> {
|
|||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
verifyRequiredVariables();
|
||||
|
||||
try {
|
||||
await sql`SELECT 1;`;
|
||||
try {
|
||||
await sql`SELECT 1;`;
|
||||
|
||||
logger.info([
|
||||
"Connected to PostgreSQL on",
|
||||
`${process.env.PGHOST}:${process.env.PGPORT}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
"Could not establish a connection to PostgreSQL:",
|
||||
error as Error,
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info([
|
||||
"Connected to PostgreSQL on",
|
||||
`${process.env.PGHOST}:${process.env.PGPORT}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
"Could not establish a connection to PostgreSQL:",
|
||||
error as Error,
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
if (dataType.type === "local" && dataType.path) {
|
||||
if (!existsSync(dataType.path)) {
|
||||
try {
|
||||
mkdirSync(dataType.path);
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
"Could not create datasource local directory",
|
||||
error as Error,
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
logger.info([
|
||||
"Using local datasource directory",
|
||||
`${dataType.path}`,
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
mkdirSync(dataType.path);
|
||||
await s3.write("test", "test");
|
||||
await s3.delete("test");
|
||||
|
||||
logger.info([
|
||||
"Connected to S3 with bucket",
|
||||
`${process.env.S3_BUCKET}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
"Could not create datasource local directory",
|
||||
"Could not establish a connection to S3 bucket:",
|
||||
error as Error,
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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}`]);
|
||||
} catch (error) {
|
||||
logger.error([
|
||||
"Could not establish a connection to S3 bucket:",
|
||||
error as Error,
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
await redis.initialize();
|
||||
serverHandler.initialize();
|
||||
await initializeDatabase();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.space();
|
||||
|
||||
serverHandler.initialize();
|
||||
await initializeDatabase();
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
|
|
145
src/lib/jwt.ts
145
src/lib/jwt.ts
|
@ -1,145 +0,0 @@
|
|||
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,
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
export function isValidTypeOrExtension(
|
||||
type: string,
|
||||
extension: string,
|
||||
): boolean {
|
||||
return (
|
||||
["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) &&
|
||||
["jpeg", "jpg", "png", "gif", "webp"].includes(extension)
|
||||
);
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
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 };
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
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";
|
|
@ -1,37 +0,0 @@
|
|||
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 };
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
// ? 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 };
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
const passwordRestrictions: {
|
||||
length: { min: number; max: number };
|
||||
regex: RegExp;
|
||||
} = {
|
||||
length: { min: 12, max: 64 },
|
||||
regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u,
|
||||
};
|
||||
|
||||
export function isValidPassword(password: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!password) {
|
||||
return { valid: false, error: "" };
|
||||
}
|
||||
|
||||
if (password.length < passwordRestrictions.length.min) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password must be at least ${passwordRestrictions.length.min} characters long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (password.length > passwordRestrictions.length.max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Password can't be longer than ${passwordRestrictions.length.max} characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!passwordRestrictions.regex.test(password)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import { redis, sql } from "bun";
|
||||
import { sql } from "bun";
|
||||
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { redis } from "@/helpers/redis";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -36,9 +37,11 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
try {
|
||||
const raw: string | null = await redis.get(`email:verify:${code}`);
|
||||
const verificationData: unknown = await redis
|
||||
.getInstance()
|
||||
.get("JSON", `email:verify:${code}`);
|
||||
|
||||
if (!raw) {
|
||||
if (!verificationData) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
@ -49,24 +52,11 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
let verificationData: { user_id: string };
|
||||
const { user_id: userId } = verificationData as {
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
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 redis.getInstance().delete("JSON", `email:verify:${code}`);
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET email_verified = true
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUIDv7, sql } from "bun";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { redis } from "bun";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { redis } from "@/helpers/redis";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -51,9 +51,11 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
const code: string = randomUUIDv7();
|
||||
await redis.set(
|
||||
await redis.getInstance().set(
|
||||
"JSON",
|
||||
`email:verify:${code}`,
|
||||
JSON.stringify({ user_id: request.session.id }),
|
||||
{ user_id: request.session.id },
|
||||
60 * 60 * 2, // 2 hours
|
||||
);
|
||||
|
||||
// TODO: Send email when email service is implemented
|
161
src/routes/api/auth/login.ts
Normal file
161
src/routes/api/auth/login.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
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 };
|
|
@ -1,4 +1,4 @@
|
|||
import { sessionManager } from "@/lib/jwt";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
|
@ -4,12 +4,12 @@ import {
|
|||
isValidInvite,
|
||||
isValidPassword,
|
||||
isValidUsername,
|
||||
} from "@lib/validators";
|
||||
import { type ReservedSQL, password as bunPassword, sql } from "bun";
|
||||
} from "@config/sql/users";
|
||||
import { password as bunPassword, type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isValidTimezone } from "@lib/char";
|
||||
import { isValidTimezone } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -49,17 +49,17 @@ async function handler(
|
|||
{ check: isValidPassword(password), field: "Password" },
|
||||
];
|
||||
|
||||
for (const { check } of validations) {
|
||||
validations.forEach(({ check }: UserValidation): void => {
|
||||
if (!check.valid && check.error) {
|
||||
errors.push(check.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedUsername: string = username.normalize("NFC");
|
||||
const reservation: ReservedSQL = await sql.reserve();
|
||||
let firstUser = false;
|
||||
let firstUser: boolean = false;
|
||||
let inviteData: Invite | null = null;
|
||||
const roles: string[] = [];
|
||||
let roles: string[] = [];
|
||||
|
||||
try {
|
||||
const registrationEnabled: boolean =
|
||||
|
@ -69,16 +69,13 @@ 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);
|
||||
}
|
||||
|
@ -92,12 +89,10 @@ async function handler(
|
|||
}
|
||||
|
||||
roles.push("user");
|
||||
if (firstUser) {
|
||||
roles.push("admin");
|
||||
roles.push("superadmin");
|
||||
}
|
||||
if (firstUser) roles.push("admin");
|
||||
|
||||
const [result] = await reservation`
|
||||
const result: { usernameExists: boolean; emailExists: boolean }[] =
|
||||
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";
|
||||
|
@ -109,13 +104,15 @@ async function handler(
|
|||
errors.push("Username or email already exists");
|
||||
}
|
||||
|
||||
if (invite && inviteValid && !firstUser) {
|
||||
[inviteData] =
|
||||
if (invite && !firstUser) {
|
||||
const result: Invite[] =
|
||||
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||
|
||||
if (!inviteData) {
|
||||
if (!result || result.length === 0) {
|
||||
errors.push("Invalid invite");
|
||||
}
|
||||
|
||||
inviteData = result[0];
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push("An error occurred while checking for existing users");
|
||||
|
@ -143,13 +140,13 @@ async function handler(
|
|||
: (await getSetting("default_timezone", reservation)) || "UTC";
|
||||
|
||||
try {
|
||||
[user] = await reservation`
|
||||
const result: 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 (!user) {
|
||||
if (result.length === 0) {
|
||||
logger.error("User was not created");
|
||||
return Response.json(
|
||||
{
|
||||
|
@ -161,6 +158,8 @@ async function handler(
|
|||
);
|
||||
}
|
||||
|
||||
user = result[0];
|
||||
|
||||
if (!user) {
|
||||
logger.error("User was not created");
|
||||
return Response.json(
|
||||
|
@ -186,7 +185,10 @@ 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,
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { type SQLQuery, s3, sql } from "bun";
|
||||
import { dataType } from "@config/environment";
|
||||
import { s3, sql, type SQLQuery } from "bun";
|
||||
import { resolve } from "path";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "DELETE",
|
||||
|
@ -124,9 +124,7 @@ async function handler(
|
|||
);
|
||||
}
|
||||
|
||||
const isAdmin: boolean =
|
||||
request.session.roles.includes("admin") ||
|
||||
request.session.roles.includes("superadmin");
|
||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||
const { query: file } = request.params as { query: string };
|
||||
let { files } = requestBody as { files: string[] | string };
|
||||
// const { password } = request.query as { password: string };
|
||||
|
@ -136,14 +134,26 @@ 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) {
|
|
@ -1,17 +1,16 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { dataType } from "@config/environment";
|
||||
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,
|
||||
|
@ -20,7 +19,8 @@ import {
|
|||
nameWithoutExtension,
|
||||
supportsExif,
|
||||
supportsThumbnail,
|
||||
} from "@lib/char";
|
||||
} from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -97,7 +97,9 @@ 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) {
|
||||
|
@ -159,9 +161,9 @@ async function processFile(
|
|||
};
|
||||
|
||||
const extension: string | null = getExtension(file.name);
|
||||
const rawName: string | null = nameWithoutExtension(file.name);
|
||||
let rawName: string | null = nameWithoutExtension(file.name);
|
||||
const maxViews: number | null =
|
||||
Number.parseInt(user_provided_max_views, 10) || null;
|
||||
parseInt(user_provided_max_views, 10) || null;
|
||||
|
||||
if (!rawName) {
|
||||
failedFiles.push({
|
||||
|
@ -174,9 +176,13 @@ async function processFile(
|
|||
let hashedPassword: string | null = null;
|
||||
|
||||
if (user_provided_password) {
|
||||
hashedPassword = await bunPassword.hash(user_provided_password, {
|
||||
algorithm: "argon2id",
|
||||
});
|
||||
try {
|
||||
hashedPassword = await bunPassword.hash(user_provided_password, {
|
||||
algorithm: "argon2id",
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const randomUUID: string = randomUUIDv7();
|
||||
|
@ -184,7 +190,7 @@ async function processFile(
|
|||
? user_provided_tags
|
||||
: (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []);
|
||||
|
||||
const uploadEntry: FileUpload = {
|
||||
let uploadEntry: FileUpload = {
|
||||
id: randomUUID as UUID,
|
||||
owner: session.id as UUID,
|
||||
name: rawName,
|
||||
|
@ -195,7 +201,9 @@ 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") {
|
||||
|
@ -213,7 +221,7 @@ async function processFile(
|
|||
// ? Should work not sure about non-english characters
|
||||
const sanitizedFileName: string = rawName
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Mn}/gu, "")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.toLowerCase();
|
||||
|
||||
|
@ -272,7 +280,7 @@ async function processFile(
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
path = `/uploads/${uuidWithExtension}`;
|
||||
path = "/uploads/" + uuidWithExtension;
|
||||
|
||||
try {
|
||||
await s3.write(path, fileBuffer);
|
||||
|
@ -288,7 +296,7 @@ async function processFile(
|
|||
}
|
||||
|
||||
try {
|
||||
const [result] = await sql`
|
||||
const result: FileUpload[] = 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},
|
||||
|
@ -300,7 +308,7 @@ async function processFile(
|
|||
RETURNING id;
|
||||
`;
|
||||
|
||||
if (!result) {
|
||||
if (result.length === 0) {
|
||||
failedFiles.push({
|
||||
reason: "Failed to create file entry",
|
||||
file: key,
|
||||
|
@ -317,7 +325,7 @@ async function processFile(
|
|||
return;
|
||||
}
|
||||
|
||||
if (uploadEntry.password) uploadEntry.password = undefined;
|
||||
if (uploadEntry.password) delete uploadEntry.password;
|
||||
|
||||
uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`;
|
||||
successfulFiles.push(uploadEntry);
|
||||
|
@ -358,7 +366,9 @@ 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(
|
||||
|
@ -432,16 +442,20 @@ 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", {
|
||||
type: "module",
|
||||
});
|
||||
const worker: Worker = new Worker(
|
||||
"./src/helpers/workers/thumbnails.ts",
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
worker.postMessage({
|
||||
files: filesThatSupportThumbnails,
|
||||
});
|
15
src/routes/api/index.ts
Normal file
15
src/routes/api/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(): Promise<Response> {
|
||||
// TODO: Put something useful here
|
||||
|
||||
return Response.json({
|
||||
message: "Hello, World!",
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
|
@ -1,8 +1,8 @@
|
|||
import { getSetting } from "@config/sql/settings";
|
||||
import { sql } from "bun";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { generateRandomString, getNewTimeUTC } from "@lib/char";
|
||||
import { generateRandomString, getNewTimeUTC } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -37,9 +37,7 @@ async function handler(
|
|||
);
|
||||
}
|
||||
|
||||
const isAdmin: boolean =
|
||||
request.session.roles.includes("admin") ||
|
||||
request.session.roles.includes("superadmin");
|
||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||
|
||||
if (!isAdmin && !getSetting("allow_user_invites")) {
|
||||
return Response.json(
|
||||
|
@ -69,19 +67,21 @@ 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 {
|
||||
[invite] = await sql`
|
||||
const result: Invite[] = await sql`
|
||||
INSERT INTO invites (created_by, expiration, max_uses, role, id)
|
||||
VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)})
|
||||
RETURNING *;
|
||||
`;
|
||||
|
||||
if (!invite) {
|
||||
if (result.length === 0) {
|
||||
logger.error("Invite failed to create");
|
||||
|
||||
return Response.json(
|
||||
|
@ -93,6 +93,8 @@ async function handler(
|
|||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
invite = result[0];
|
||||
} catch (error) {
|
||||
logger.error(["Error creating invite:", error as Error]);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { isValidInvite } from "@lib/validators";
|
||||
import { isValidInvite } from "@config/sql/users";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "DELETE",
|
||||
|
@ -21,9 +21,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
const isAdmin: boolean =
|
||||
request.session.roles.includes("admin") ||
|
||||
request.session.roles.includes("superadmin");
|
||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||
const { invite } = request.params as { invite: string };
|
||||
|
||||
if (!invite) {
|
||||
|
@ -54,10 +52,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
let inviteData: Invite | null = null;
|
||||
|
||||
try {
|
||||
[inviteData] =
|
||||
const result: Invite[] =
|
||||
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||
|
||||
if (!inviteData) {
|
||||
if (result.length === 0) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
@ -68,6 +66,8 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
inviteData = result[0];
|
||||
|
||||
if (!isAdmin && inviteData.created_by !== request.session.id) {
|
||||
return Response.json(
|
||||
{
|
|
@ -1,6 +1,6 @@
|
|||
import { setSetting } from "@config/sql/settings";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
|
@ -53,8 +53,7 @@ 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 },
|
||||
);
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { dataType } from "@config/environment";
|
||||
import { s3, sql } from "bun";
|
||||
import { resolve } from "path";
|
||||
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
|
||||
async function deleteAvatar(
|
||||
request: ExtendedRequest,
|
||||
|
@ -21,7 +21,9 @@ 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}`);
|
||||
}
|
||||
|
@ -59,9 +61,7 @@ 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") ||
|
||||
request.session.roles.includes("superadmin");
|
||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||
|
||||
if (request.session.id !== userID && !isAdmin) {
|
||||
return Response.json(
|
||||
|
@ -115,15 +115,16 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: "Avatar deleted",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: "Avatar deleted",
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(["Error processing delete request:", error as Error]);
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { dataType } from "@config/environment";
|
||||
import { isValidTypeOrExtension } from "@config/sql/avatars";
|
||||
import { getSetting } from "@config/sql/settings";
|
||||
import { isValidTypeOrExtension } from "@lib/validators";
|
||||
import { s3, sql } from "bun";
|
||||
import { resolve } from "path";
|
||||
|
||||
import { sessionManager } from "@/lib/jwt";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { getBaseUrl, getExtension } from "@lib/char";
|
||||
import { getBaseUrl, getExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
import { sessionManager } from "@/helpers/sessions";
|
||||
|
||||
async function processFile(
|
||||
file: File,
|
||||
|
@ -50,7 +50,10 @@ 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,7 +138,9 @@ 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(
|
||||
|
@ -201,16 +206,17 @@ async function handler(
|
|||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: "Avatar uploaded",
|
||||
url: message,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: "Avatar uploaded",
|
||||
url: message,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(["Error processing file:", error as Error]);
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { isValidUsername } from "@lib/validators";
|
||||
import { isValidUsername } from "@config/sql/users";
|
||||
import { type ReservedSQL, sql } from "bun";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID } from "@lib/char";
|
||||
import { isUUID } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -28,7 +28,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
let user: GetUser | null = null;
|
||||
let isSelf = false;
|
||||
let isSelf: boolean = 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 {
|
||||
[user] = isId
|
||||
const result: GetUser[] = isId
|
||||
? await reservation`SELECT * FROM users WHERE id = ${normalized}`
|
||||
: await reservation`SELECT * FROM users WHERE username = ${normalized}`;
|
||||
|
||||
if (!user) {
|
||||
if (result.length === 0) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
@ -64,6 +64,8 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
user = result[0];
|
||||
|
||||
isSelf = request.session ? user.id === request.session.id : false;
|
||||
|
||||
const files: { count: bigint }[] =
|
||||
|
@ -110,9 +112,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
);
|
||||
}
|
||||
|
||||
user.password = undefined;
|
||||
user.authorization_token = undefined;
|
||||
if (!isSelf) user.email = undefined;
|
||||
delete user.password;
|
||||
delete user.authorization_token;
|
||||
if (!isSelf) delete user.email;
|
||||
|
||||
user.roles = user.roles ? user.roles[0].split(",") : [];
|
||||
|
|
@ -1,166 +1,20 @@
|
|||
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";
|
||||
import { getSetting } from "@config/sql/settings";
|
||||
import { renderEjsTemplate } from "@helpers/ejs";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "POST",
|
||||
accepts: "application/json",
|
||||
returns: "application/json",
|
||||
needsBody: "json",
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "text/html",
|
||||
};
|
||||
|
||||
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;
|
||||
async function handler(): Promise<Response> {
|
||||
const ejsTemplateData: EjsTemplateData = {
|
||||
title: "Hello, World!",
|
||||
instance_name:
|
||||
(await getSetting("instance_name")) || "Unnamed Instance",
|
||||
};
|
||||
|
||||
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 } },
|
||||
);
|
||||
return await renderEjsTemplate("auth/login", ejsTemplateData);
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
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 };
|
|
@ -1,181 +0,0 @@
|
|||
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 };
|
|
@ -1,4 +1,4 @@
|
|||
import { frontendUrl } from "@config";
|
||||
import { renderEjsTemplate } from "@helpers/ejs";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -7,14 +7,15 @@ const routeDef: RouteDef = {
|
|||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
message: `This is the api for ${frontendUrl}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
if (!request.session) {
|
||||
return Response.redirect("/auth/login");
|
||||
}
|
||||
|
||||
const ejsTemplateData: EjsTemplateData = {
|
||||
title: "Hello, World!",
|
||||
};
|
||||
|
||||
return await renderEjsTemplate("index", ejsTemplateData);
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { dataType } from "@config/environment";
|
||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||
import { resolve } from "path";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { isUUID, nameWithoutExtension } from "@lib/char";
|
||||
import { isUUID, nameWithoutExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
|||
}
|
||||
|
||||
if (json === "true" || json === "1") {
|
||||
fileData.password = undefined;
|
||||
delete fileData.password;
|
||||
fileData.tags = fileData.tags = fileData.tags[0]?.trim()
|
||||
? fileData.tags[0].split(",").filter((tag: string) => tag.trim())
|
||||
: [];
|
||||
|
@ -139,7 +139,9 @@ 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 {
|
||||
|
@ -155,7 +157,9 @@ 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}"`
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { resolve } from "node:path";
|
||||
import { dataType } from "@config";
|
||||
import { isValidUsername } from "@lib/validators";
|
||||
import { dataType } from "@config/environment";
|
||||
import { isValidUsername } from "@config/sql/users";
|
||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||
import { resolve } from "path";
|
||||
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char";
|
||||
import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char";
|
||||
import { logger } from "@/helpers/logger";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
|
@ -71,12 +71,11 @@ 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 },
|
||||
);
|
||||
|
|
161
src/server.ts
161
src/server.ts
|
@ -1,16 +1,17 @@
|
|||
import { resolve } from "node:path";
|
||||
import { environment } from "@config";
|
||||
import { logger } from "@creations.works/logger";
|
||||
import { environment } from "@config/environment";
|
||||
import { logger } from "@helpers/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 "@lib/auth";
|
||||
|
||||
import { authByToken } from "./helpers/auth";
|
||||
import { sessionManager } from "./helpers/sessions";
|
||||
|
||||
class ServerHandler {
|
||||
private router: FileSystemRouter;
|
||||
|
@ -40,7 +41,11 @@ class ServerHandler {
|
|||
maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon
|
||||
});
|
||||
|
||||
logger.info(`Server running at ${environment.fqdn}`);
|
||||
logger.info(
|
||||
`Server running at http://${server.hostname}:${server.port}`,
|
||||
true,
|
||||
);
|
||||
|
||||
this.logRoutes();
|
||||
}
|
||||
|
||||
|
@ -58,15 +63,10 @@ class ServerHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private async serveStaticFile(
|
||||
request: ExtendedRequest,
|
||||
pathname: string,
|
||||
ip: string,
|
||||
): Promise<Response> {
|
||||
let filePath: string;
|
||||
let response: Response;
|
||||
|
||||
private async serveStaticFile(pathname: string): Promise<Response> {
|
||||
try {
|
||||
let filePath: string;
|
||||
|
||||
if (pathname === "/favicon.ico") {
|
||||
filePath = resolve("public", "assets", "favicon.ico");
|
||||
} else {
|
||||
|
@ -77,87 +77,47 @@ 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";
|
||||
|
||||
response = new Response(fileContent, {
|
||||
return new Response(fileContent, {
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
} else {
|
||||
logger.warn(`File not found: ${filePath}`);
|
||||
response = new Response("Not Found", { status: 404 });
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error([`Error serving static file: ${pathname}`, error as Error]);
|
||||
response = new Response("Internal Server Error", { status: 500 });
|
||||
logger.error([
|
||||
`Error serving static file: ${pathname}`,
|
||||
error as Error,
|
||||
]);
|
||||
return 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: Request,
|
||||
request: ExtendedRequest,
|
||||
server: BunServer,
|
||||
): Promise<Response> {
|
||||
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";
|
||||
}
|
||||
request.startPerf = performance.now();
|
||||
|
||||
const pathname: string = new URL(request.url).pathname;
|
||||
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
||||
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||
return await this.serveStaticFile(pathname);
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -184,7 +144,9 @@ 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)
|
||||
) {
|
||||
|
@ -209,7 +171,9 @@ class ServerHandler {
|
|||
if (Array.isArray(expectedContentType)) {
|
||||
matchesAccepts =
|
||||
expectedContentType.includes("*/*") ||
|
||||
expectedContentType.includes(actualContentType || "");
|
||||
expectedContentType.includes(
|
||||
actualContentType || "",
|
||||
);
|
||||
} else {
|
||||
matchesAccepts =
|
||||
expectedContentType === "*/*" ||
|
||||
|
@ -230,15 +194,19 @@ class ServerHandler {
|
|||
{ status: 406 },
|
||||
);
|
||||
} else {
|
||||
extendedRequest.params = params;
|
||||
extendedRequest.query = query;
|
||||
extendedRequest.actualContentType = actualContentType;
|
||||
request.params = params;
|
||||
request.query = query;
|
||||
request.actualContentType = actualContentType;
|
||||
|
||||
extendedRequest.session =
|
||||
(await authByToken(extendedRequest)) ||
|
||||
request.session =
|
||||
(await authByToken(request)) ||
|
||||
(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(
|
||||
|
@ -249,7 +217,10 @@ 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(
|
||||
{
|
||||
|
@ -271,22 +242,28 @@ class ServerHandler {
|
|||
);
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
28
src/views/auth/login.ejs
Normal file
28
src/views/auth/login.ejs
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!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>
|
31
src/views/global.ejs
Normal file
31
src/views/global.ejs
Normal file
|
@ -0,0 +1,31 @@
|
|||
<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>
|
9
src/views/index.ejs
Normal file
9
src/views/index.ejs
Normal file
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<%- include("global", { styles: [], scripts: [] }) %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
0
src/views/partials/header.ejs
Normal file
0
src/views/partials/header.ejs
Normal file
|
@ -1,5 +1,5 @@
|
|||
import { logger } from "@creations.works/logger";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { type ServerWebSocket } from "bun";
|
||||
|
||||
class WebSocketHandler {
|
||||
public handleMessage(ws: ServerWebSocket, message: string): void {
|
||||
|
@ -20,7 +20,11 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
|
9
stylelint.config.js
Normal file
9
stylelint.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
/** @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",
|
||||
},
|
||||
};
|
|
@ -2,15 +2,28 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@config": ["config/index.ts"],
|
||||
"@config/*": ["config/*"],
|
||||
"@types/*": ["types/*"],
|
||||
"@lib/*": ["src/lib/*"]
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@config/*": [
|
||||
"config/*"
|
||||
],
|
||||
"@types/*": [
|
||||
"types/*"
|
||||
],
|
||||
"@helpers/*": [
|
||||
"src/helpers/*"
|
||||
]
|
||||
},
|
||||
"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",
|
||||
|
@ -28,7 +41,11 @@
|
|||
// 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
2
types/config.d.ts
vendored
|
@ -2,8 +2,6 @@ type Environment = {
|
|||
port: number;
|
||||
host: string;
|
||||
development: boolean;
|
||||
fqdn: string;
|
||||
frontendUrl: string;
|
||||
};
|
||||
|
||||
type UserValidation = {
|
||||
|
|
3
types/ejs.d.ts
vendored
Normal file
3
types/ejs.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
interface EjsTemplateData {
|
||||
[key: string]: string | number | boolean | object | undefined | null;
|
||||
}
|
9
types/logger.d.ts
vendored
Normal file
9
types/logger.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
type ILogMessagePart = { value: string; color: string };
|
||||
|
||||
type ILogMessageParts = {
|
||||
level: ILogMessagePart;
|
||||
filename: ILogMessagePart;
|
||||
readableTimestamp: ILogMessagePart;
|
||||
message: ILogMessagePart;
|
||||
[key: string]: ILogMessagePart;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue