forked from atums.world/backend
Compare commits
15 commits
Author | SHA1 | Date | |
---|---|---|---|
b31d77983c | |||
4936ff8978 | |||
8a9499be85 | |||
a646607597 | |||
ca5a9d8309 | |||
c02b519eee | |||
25fcd99acf | |||
f4237afc59 | |||
d1dc1b4bf0 | |||
f39d1cdbf8 | |||
0b09b6eb3d | |||
99f170750c | |||
17d7e4f238 | |||
7ddd7fa7a1 | |||
b40c1db189 |
65 changed files with 1336 additions and 1788 deletions
11
.env.example
11
.env.example
|
@ -2,16 +2,19 @@
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=9090
|
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
|
PGHOST=localhost
|
||||||
PGPORT=5432
|
PGPORT=5432
|
||||||
PGUSERNAME=postgres
|
PGUSERNAME=postgres
|
||||||
PGPASSWORD=postgres
|
PGPASSWORD=postgres
|
||||||
PGDATABASE=postgres
|
PGDATABASE=postgres
|
||||||
|
|
||||||
REDIS_HOST=localhost
|
REDIS_URL=redis://localhost:6379
|
||||||
REDIS_PORT=6379
|
REDIS_TTL=3600
|
||||||
# REDIS_USERNAME=redis
|
|
||||||
# REDIS_PASSWORD=redis
|
|
||||||
|
|
||||||
# For sessions and cookies, can be generated using `openssl rand -base64 32`
|
# For sessions and cookies, can be generated using `openssl rand -base64 32`
|
||||||
JWT_SECRET=your_jwt_secret
|
JWT_SECRET=your_jwt_secret
|
||||||
|
|
24
.forgejo/workflows/biomejs.yml
Normal file
24
.forgejo/workflows/biomejs.yml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
name: Code quality checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
biome:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
export BUN_INSTALL="$HOME/.bun"
|
||||||
|
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run Biome with verbose output
|
||||||
|
run: bunx biome ci . --verbose
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"mikestead.dotenv",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"biomejs.biome"
|
||||||
|
]
|
||||||
|
}
|
44
biome.json
Normal file
44
biome.json
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"ignore": []
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineEnding": "lf"
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineEnding": "lf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedImports": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,37 +0,0 @@
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
export const environment: Environment = {
|
|
||||||
port: parseInt(process.env.PORT || "8080", 10),
|
|
||||||
host: process.env.HOST || "0.0.0.0",
|
|
||||||
development:
|
|
||||||
process.env.NODE_ENV === "development" ||
|
|
||||||
process.argv.includes("--dev"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const redisConfig: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username?: string | undefined;
|
|
||||||
password?: string | undefined;
|
|
||||||
} = {
|
|
||||||
host: process.env.REDIS_HOST || "localhost",
|
|
||||||
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
||||||
username: process.env.REDIS_USERNAME || undefined,
|
|
||||||
password: process.env.REDIS_PASSWORD || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const jwt: {
|
|
||||||
secret: string;
|
|
||||||
expiresIn: string;
|
|
||||||
} = {
|
|
||||||
secret: process.env.JWT_SECRET || "",
|
|
||||||
expiresIn: process.env.JWT_EXPIRES || "1d",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dataType: { type: string; path: string | undefined } = {
|
|
||||||
type: process.env.DATASOURCE_TYPE || "local",
|
|
||||||
path:
|
|
||||||
process.env.DATASOURCE_TYPE === "local"
|
|
||||||
? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads")
|
|
||||||
: undefined,
|
|
||||||
};
|
|
66
config/index.ts
Normal file
66
config/index.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
|
import { normalizeFqdn } from "@lib/char";
|
||||||
|
|
||||||
|
const environment: Environment = {
|
||||||
|
port: Number.parseInt(process.env.PORT || "8080", 10),
|
||||||
|
host: process.env.HOST || "0.0.0.0",
|
||||||
|
development:
|
||||||
|
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
||||||
|
fqdn: normalizeFqdn(process.env.FQDN) || "http://localhost:8080",
|
||||||
|
frontendUrl:
|
||||||
|
normalizeFqdn(process.env.FRONTEND_URL) || "http://localhost:8080",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataType: { type: string; path: string | undefined } = {
|
||||||
|
type: process.env.DATASOURCE_TYPE || "local",
|
||||||
|
path:
|
||||||
|
process.env.DATASOURCE_TYPE === "local"
|
||||||
|
? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads")
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const frontendUrl: string = process.env.FRONTEND_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
function verifyRequiredVariables(): void {
|
||||||
|
const requiredVariables = [
|
||||||
|
"HOST",
|
||||||
|
"PORT",
|
||||||
|
|
||||||
|
"FQDN",
|
||||||
|
"FRONTEND_URL",
|
||||||
|
|
||||||
|
"PGHOST",
|
||||||
|
"PGPORT",
|
||||||
|
"PGUSERNAME",
|
||||||
|
"PGPASSWORD",
|
||||||
|
"PGDATABASE",
|
||||||
|
|
||||||
|
"REDIS_URL",
|
||||||
|
"REDIS_TTL",
|
||||||
|
|
||||||
|
"JWT_SECRET",
|
||||||
|
"JWT_EXPIRES",
|
||||||
|
|
||||||
|
"DATASOURCE_TYPE",
|
||||||
|
];
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
for (const key of requiredVariables) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value === undefined || value.trim() === "") {
|
||||||
|
logger.error(`Missing or empty environment variable: ${key}`);
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "@config/jwt";
|
||||||
|
export * from "@config/redis";
|
||||||
|
|
||||||
|
export { environment, dataType, verifyRequiredVariables, frontendUrl };
|
27
config/jwt.ts
Normal file
27
config/jwt.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
const allowedAlgorithms = [
|
||||||
|
"HS256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"HS512",
|
||||||
|
"RS384",
|
||||||
|
"RS512",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type AllowedAlgorithm = (typeof allowedAlgorithms)[number];
|
||||||
|
|
||||||
|
function getAlgorithm(envVar: string | undefined): AllowedAlgorithm {
|
||||||
|
if (allowedAlgorithms.includes(envVar as AllowedAlgorithm)) {
|
||||||
|
return envVar as AllowedAlgorithm;
|
||||||
|
}
|
||||||
|
return "HS256";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jwt: {
|
||||||
|
secret: string;
|
||||||
|
expiration: string;
|
||||||
|
algorithm: AllowedAlgorithm;
|
||||||
|
} = {
|
||||||
|
secret: process.env.JWT_SECRET || "",
|
||||||
|
expiration: process.env.JWT_EXPIRATION || "1h",
|
||||||
|
algorithm: getAlgorithm(process.env.JWT_ALGORITHM),
|
||||||
|
};
|
3
config/redis.ts
Normal file
3
config/redis.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const redisTtl: number = process.env.REDIS_TTL
|
||||||
|
? Number.parseInt(process.env.REDIS_TTL, 10)
|
||||||
|
: 60 * 60 * 1; // 1 hour
|
|
@ -1,18 +1,18 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 6;
|
export const order: number = 6;
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS avatars (
|
CREATE TABLE IF NOT EXISTS avatars (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
@ -28,17 +28,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidTypeOrExtension(
|
|
||||||
type: string,
|
|
||||||
extension: string,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) &&
|
|
||||||
["jpeg", "jpg", "png", "gif", "webp"].includes(extension)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 5;
|
export const order: number = 5;
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS files (
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const functionExists: { exists: boolean }[] = await reservation`
|
const functionExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_proc
|
SELECT 1 FROM pg_proc
|
||||||
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
|
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) {
|
if (!functionExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE FUNCTION update_files_updated_at()
|
CREATE FUNCTION update_files_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerExists: { exists: boolean }[] = await reservation`
|
const triggerExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_trigger
|
SELECT 1 FROM pg_trigger
|
||||||
WHERE tgname = 'trigger_update_files_updated_at'
|
WHERE tgname = 'trigger_update_files_updated_at'
|
||||||
|
@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!triggerExists[0].exists) {
|
if (!triggerExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TRIGGER trigger_update_files_updated_at
|
CREATE TRIGGER trigger_update_files_updated_at
|
||||||
BEFORE UPDATE ON files
|
BEFORE UPDATE ON files
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
|
@ -80,7 +80,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 4;
|
export const order: number = 4;
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
@ -26,7 +26,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const functionExists: { exists: boolean }[] = await reservation`
|
const functionExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_proc
|
SELECT 1 FROM pg_proc
|
||||||
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
|
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) {
|
if (!functionExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE FUNCTION update_folders_updated_at()
|
CREATE FUNCTION update_folders_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerExists: { exists: boolean }[] = await reservation`
|
const triggerExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_trigger
|
SELECT 1 FROM pg_trigger
|
||||||
WHERE tgname = 'trigger_update_folders_updated_at'
|
WHERE tgname = 'trigger_update_folders_updated_at'
|
||||||
|
@ -54,7 +54,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!triggerExists[0].exists) {
|
if (!triggerExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TRIGGER trigger_update_folders_updated_at
|
CREATE TRIGGER trigger_update_folders_updated_at
|
||||||
BEFORE UPDATE ON folders
|
BEFORE UPDATE ON folders
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
|
@ -69,7 +69,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 3;
|
export const order: number = 3;
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS invites (
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
id TEXT PRIMARY KEY NOT NULL UNIQUE,
|
id TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 2;
|
export const order: number = 2;
|
||||||
|
@ -20,15 +20,15 @@ const defaultSettings: Setting[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
"key" VARCHAR(64) PRIMARY KEY NOT NULL UNIQUE,
|
"key" VARCHAR(64) PRIMARY KEY NOT NULL UNIQUE,
|
||||||
"value" TEXT NOT NULL,
|
"value" TEXT NOT NULL,
|
||||||
|
@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const functionExists: { exists: boolean }[] = await reservation`
|
const functionExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_proc
|
SELECT 1 FROM pg_proc
|
||||||
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
|
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) {
|
if (!functionExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE FUNCTION update_settings_updated_at()
|
CREATE FUNCTION update_settings_updated_at()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerExists: { exists: boolean }[] = await reservation`
|
const triggerExists: { exists: boolean }[] = await activeReservation`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM pg_trigger
|
SELECT 1 FROM pg_trigger
|
||||||
WHERE tgname = 'trigger_update_settings_updated_at'
|
WHERE tgname = 'trigger_update_settings_updated_at'
|
||||||
|
@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!triggerExists[0].exists) {
|
if (!triggerExists[0].exists) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TRIGGER trigger_update_settings_updated_at
|
CREATE TRIGGER trigger_update_settings_updated_at
|
||||||
BEFORE UPDATE ON settings
|
BEFORE UPDATE ON settings
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
|
@ -74,7 +74,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const setting of defaultSettings) {
|
for (const setting of defaultSettings) {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
INSERT INTO settings ("key", "value")
|
INSERT INTO settings ("key", "value")
|
||||||
VALUES (${setting.key}, ${setting.value})
|
VALUES (${setting.key}, ${setting.value})
|
||||||
ON CONFLICT ("key") DO NOTHING;
|
ON CONFLICT ("key") DO NOTHING;
|
||||||
|
@ -88,27 +88,25 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Validation functions
|
|
||||||
|
|
||||||
export async function getSetting(
|
export async function getSetting(
|
||||||
key: string,
|
key: string,
|
||||||
reservation?: ReservedSQL,
|
reservation?: ReservedSQL,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { value: string }[] =
|
const result: { value: string }[] =
|
||||||
await reservation`SELECT value FROM settings WHERE "key" = ${key};`;
|
await activeReservation`SELECT value FROM settings WHERE "key" = ${key};`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -120,7 +118,7 @@ export async function getSetting(
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,15 +128,15 @@ export async function setSetting(
|
||||||
value: string,
|
value: string,
|
||||||
reservation?: ReservedSQL,
|
reservation?: ReservedSQL,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
INSERT INTO settings ("key", "value", updated_at)
|
INSERT INTO settings ("key", "value", updated_at)
|
||||||
VALUES (${key}, ${value}, NOW())
|
VALUES (${key}, ${value}, NOW())
|
||||||
ON CONFLICT ("key")
|
ON CONFLICT ("key")
|
||||||
|
@ -148,7 +146,7 @@ export async function setSetting(
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,21 +155,21 @@ export async function deleteSetting(
|
||||||
key: string,
|
key: string,
|
||||||
reservation?: ReservedSQL,
|
reservation?: ReservedSQL,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`DELETE FROM settings WHERE "key" = ${key};`;
|
await activeReservation`DELETE FROM settings WHERE "key" = ${key};`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["Could not delete the setting:", error as Error]);
|
logger.error(["Could not delete the setting:", error as Error]);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -179,16 +177,16 @@ export async function deleteSetting(
|
||||||
export async function getAllSettings(
|
export async function getAllSettings(
|
||||||
reservation?: ReservedSQL,
|
reservation?: ReservedSQL,
|
||||||
): Promise<{ key: string; value: string }[]> {
|
): Promise<{ key: string; value: string }[]> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { key: string; value: string }[] =
|
const result: { key: string; value: string }[] =
|
||||||
await reservation`SELECT "key", "value" FROM settings;`;
|
await activeReservation`SELECT "key", "value" FROM settings;`;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -196,7 +194,7 @@ export async function getAllSettings(
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export const order: number = 1;
|
export const order: number = 1;
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
const activeReservation: ReservedSQL = reservation ?? (await sql.reserve());
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
reservation = await sql.reserve();
|
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reservation`
|
await activeReservation`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||||
|
@ -32,122 +32,7 @@ export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Validation functions
|
|
||||||
|
|
||||||
// ? should support non english characters but won't mess up the url
|
|
||||||
export const userNameRestrictions: {
|
|
||||||
length: { min: number; max: number };
|
|
||||||
regex: RegExp;
|
|
||||||
} = {
|
|
||||||
length: { min: 3, max: 20 },
|
|
||||||
regex: /^[\p{L}\p{N}._-]+$/u,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const passwordRestrictions: {
|
|
||||||
length: { min: number; max: number };
|
|
||||||
regex: RegExp;
|
|
||||||
} = {
|
|
||||||
length: { min: 12, max: 64 },
|
|
||||||
regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emailRestrictions: { regex: RegExp } = {
|
|
||||||
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const inviteRestrictions: { min: number; max: number; regex: RegExp } = {
|
|
||||||
min: 4,
|
|
||||||
max: 15,
|
|
||||||
regex: /^[a-zA-Z0-9]+$/,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isValidUsername(username: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (username.length < userNameRestrictions.length.min) {
|
|
||||||
return { valid: false, error: "Username is too short" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username.length > userNameRestrictions.length.max) {
|
|
||||||
return { valid: false, error: "Username is too long" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userNameRestrictions.regex.test(username)) {
|
|
||||||
return { valid: false, error: "Username contains invalid characters" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidPassword(password: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (password.length < passwordRestrictions.length.min) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Password must be at least ${passwordRestrictions.length.min} characters long`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length > passwordRestrictions.length.max) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Password can't be longer than ${passwordRestrictions.length.max} characters`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!passwordRestrictions.regex.test(password)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidEmail(email: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (!emailRestrictions.regex.test(email)) {
|
|
||||||
return { valid: false, error: "Invalid email address" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidInvite(invite: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (invite.length < inviteRestrictions.min) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Invite code must be at least ${inviteRestrictions.min} characters long`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invite.length > inviteRestrictions.max) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Invite code can't be longer than ${inviteRestrictions.max} characters`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inviteRestrictions.regex.test(invite)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Invite code contains invalid characters",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
142
eslint.config.js
142
eslint.config.js
|
@ -1,142 +0,0 @@
|
||||||
import pluginJs from "@eslint/js";
|
|
||||||
import tseslintPlugin from "@typescript-eslint/eslint-plugin";
|
|
||||||
import tsParser from "@typescript-eslint/parser";
|
|
||||||
import prettier from "eslint-plugin-prettier";
|
|
||||||
import promisePlugin from "eslint-plugin-promise";
|
|
||||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
|
||||||
import unicorn from "eslint-plugin-unicorn";
|
|
||||||
import unusedImports from "eslint-plugin-unused-imports";
|
|
||||||
import globals from "globals";
|
|
||||||
import stylelintPlugin from "stylelint";
|
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.FlatConfig[]} */
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,mjs,cjs}"],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
...pluginJs.configs.recommended,
|
|
||||||
plugins: {
|
|
||||||
"simple-import-sort": simpleImportSort,
|
|
||||||
"unused-imports": unusedImports,
|
|
||||||
promise: promisePlugin,
|
|
||||||
prettier: prettier,
|
|
||||||
unicorn: unicorn,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"eol-last": ["error", "always"],
|
|
||||||
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
|
|
||||||
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
|
||||||
"simple-import-sort/imports": "error",
|
|
||||||
"simple-import-sort/exports": "error",
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"unused-imports/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
vars: "all",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
args: "after-used",
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"promise/always-return": "error",
|
|
||||||
"promise/no-return-wrap": "error",
|
|
||||||
"promise/param-names": "error",
|
|
||||||
"promise/catch-or-return": "error",
|
|
||||||
"promise/no-nesting": "warn",
|
|
||||||
"promise/no-promise-in-callback": "warn",
|
|
||||||
"promise/no-callback-in-promise": "warn",
|
|
||||||
"prettier/prettier": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
useTabs: true,
|
|
||||||
tabWidth: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
indent: ["error", "tab", { SwitchCase: 1 }],
|
|
||||||
"unicorn/filename-case": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
case: "camelCase",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,tsx}"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": tseslintPlugin,
|
|
||||||
"simple-import-sort": simpleImportSort,
|
|
||||||
"unused-imports": unusedImports,
|
|
||||||
promise: promisePlugin,
|
|
||||||
prettier: prettier,
|
|
||||||
unicorn: unicorn,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...tseslintPlugin.configs.recommended.rules,
|
|
||||||
quotes: ["error", "double"],
|
|
||||||
"eol-last": ["error", "always"],
|
|
||||||
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
|
|
||||||
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
|
||||||
"simple-import-sort/imports": "error",
|
|
||||||
"simple-import-sort/exports": "error",
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"unused-imports/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
vars: "all",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
args: "after-used",
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"promise/always-return": "error",
|
|
||||||
"promise/no-return-wrap": "error",
|
|
||||||
"promise/param-names": "error",
|
|
||||||
"promise/catch-or-return": "error",
|
|
||||||
"promise/no-nesting": "warn",
|
|
||||||
"promise/no-promise-in-callback": "warn",
|
|
||||||
"promise/no-callback-in-promise": "warn",
|
|
||||||
"prettier/prettier": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
useTabs: true,
|
|
||||||
tabWidth: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
indent: ["error", "tab", { SwitchCase: 1 }],
|
|
||||||
"unicorn/filename-case": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
case: "camelCase",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@typescript-eslint/explicit-function-return-type": ["error"],
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": ["error"],
|
|
||||||
"@typescript-eslint/typedef": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
arrowParameter: true,
|
|
||||||
variableDeclaration: true,
|
|
||||||
propertyDeclaration: true,
|
|
||||||
memberVariableDeclaration: true,
|
|
||||||
parameter: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.{css,scss,sass,less}"],
|
|
||||||
plugins: {
|
|
||||||
stylelint: stylelintPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"stylelint/rule-name": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
45
package.json
45
package.json
|
@ -1,46 +1,35 @@
|
||||||
{
|
{
|
||||||
"name": "bun_frontend_template",
|
"name": "atums.world",
|
||||||
|
"private": true,
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"dev": "bun run --watch src/index.ts --dev",
|
"dev": "bun run --hot src/index.ts --dev",
|
||||||
"lint": "eslint",
|
"lint": "bunx biome check",
|
||||||
"lint:fix": "bun lint --fix",
|
"lint:fix": "bunx biome check --fix",
|
||||||
"cleanup": "rm -rf logs node_modules bun.lockdb",
|
"cleanup": "rm -rf logs node_modules bun.lock",
|
||||||
"clearTable": "bun run src/helpers/commands/clearTable.ts"
|
"clearTable": "bun run src/helpers/commands/clearTable.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@types/bun": "^1.2.5",
|
"@types/bun": "^1.2.13",
|
||||||
"@types/ejs": "^3.1.5",
|
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/image-thumbnail": "^1.0.4",
|
"@types/image-thumbnail": "^1.0.4",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"globals": "16.0.0",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"prettier": "^3.5.3"
|
||||||
"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": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^3.1.10",
|
"@creations.works/logger": "^1.0.3",
|
||||||
"exiftool-vendored": "^29.2.0",
|
"eta": "^3.5.0",
|
||||||
"fast-jwt": "^5.0.5",
|
"exiftool-vendored": "^30.0.0",
|
||||||
|
"fast-jwt": "6.0.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"image-thumbnail": "^1.0.17",
|
"image-thumbnail": "^1.0.17",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.6.1"
|
||||||
"redis": "^4.7.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background-color: var(--background-secondary);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
|
|
||||||
width: clamp(200px, 50%, 300px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
|
@ -1,40 +1,48 @@
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--background: rgb(31, 30, 30);
|
--background: rgb(31 30 30);
|
||||||
--background-secondary: rgb(45, 45, 45);
|
--background-secondary: rgb(45 45 45);
|
||||||
--border: rgb(31, 30, 30);
|
--border: rgb(70 70 70);
|
||||||
--text: rgb(255, 255, 255);
|
--text: rgb(255 255 255);
|
||||||
--text-secondary: rgb(255, 255, 255);
|
--svg-fill: rgb(255 255 255);
|
||||||
}
|
--text-secondary: rgb(200 200 200);
|
||||||
|
--accent: rgb(88 101 242);
|
||||||
body {
|
--accent-hover: rgb(71 82 196);
|
||||||
font-family: "Ubuntu", sans-serif;
|
--error: rgb(237 66 69);
|
||||||
|
--success: rgb(87 242 135);
|
||||||
margin: 0;
|
--shadow: rgb(0 0 0 / 20%);
|
||||||
padding: 0;
|
--card-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||||
box-sizing: border-box;
|
--input-background: rgb(55 55 55);
|
||||||
font-size: 16px;
|
|
||||||
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Ubuntu";
|
font-family: Ubuntu;
|
||||||
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Regular.ttf") format("truetype");
|
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Regular.ttf") format("truetype");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Ubuntu Bold";
|
font-family: Ubuntu Bold;
|
||||||
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Bold.ttf") format("truetype");
|
src: url("/public/assets/fonts/Ubuntu/Ubuntu-Bold.ttf") format("truetype");
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Fira Code";
|
font-family: Fira Code;
|
||||||
src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf") format("truetype");
|
src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf")
|
||||||
|
format("truetype");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: 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,26 +0,0 @@
|
||||||
import { renderFile } from "ejs";
|
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
export async function renderEjsTemplate(
|
|
||||||
viewName: string | string[],
|
|
||||||
data: EjsTemplateData,
|
|
||||||
headers?: Record<string, string | number | boolean>,
|
|
||||||
): Promise<Response> {
|
|
||||||
let templatePath: string;
|
|
||||||
|
|
||||||
if (Array.isArray(viewName)) {
|
|
||||||
templatePath = resolve("src", "views", ...viewName);
|
|
||||||
} else {
|
|
||||||
templatePath = resolve("src", "views", viewName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!templatePath.endsWith(".ejs")) {
|
|
||||||
templatePath += ".ejs";
|
|
||||||
}
|
|
||||||
|
|
||||||
const html: string = await renderFile(templatePath, data);
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
headers: { "Content-Type": "text/html", ...headers },
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,205 +0,0 @@
|
||||||
import { environment } from "@config/environment";
|
|
||||||
import { timestampToReadable } from "@helpers/char";
|
|
||||||
import type { Stats } from "fs";
|
|
||||||
import {
|
|
||||||
createWriteStream,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
statSync,
|
|
||||||
WriteStream,
|
|
||||||
} from "fs";
|
|
||||||
import { EOL } from "os";
|
|
||||||
import { basename, join } from "path";
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
private static instance: Logger;
|
|
||||||
private static log: string = join(__dirname, "../../logs");
|
|
||||||
|
|
||||||
public static getInstance(): Logger {
|
|
||||||
if (!Logger.instance) {
|
|
||||||
Logger.instance = new Logger();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Logger.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeToLog(logMessage: string): void {
|
|
||||||
if (environment.development) return;
|
|
||||||
|
|
||||||
const date: Date = new Date();
|
|
||||||
const logDir: string = Logger.log;
|
|
||||||
const logFile: string = join(
|
|
||||||
logDir,
|
|
||||||
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existsSync(logDir)) {
|
|
||||||
mkdirSync(logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let addSeparator: boolean = false;
|
|
||||||
|
|
||||||
if (existsSync(logFile)) {
|
|
||||||
const fileStats: Stats = statSync(logFile);
|
|
||||||
if (fileStats.size > 0) {
|
|
||||||
const lastModified: Date = new Date(fileStats.mtime);
|
|
||||||
if (
|
|
||||||
lastModified.getFullYear() === date.getFullYear() &&
|
|
||||||
lastModified.getMonth() === date.getMonth() &&
|
|
||||||
lastModified.getDate() === date.getDate() &&
|
|
||||||
lastModified.getHours() !== date.getHours()
|
|
||||||
) {
|
|
||||||
addSeparator = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream: WriteStream = createWriteStream(logFile, { flags: "a" });
|
|
||||||
|
|
||||||
if (addSeparator) {
|
|
||||||
stream.write(`${EOL}${date.toISOString()}${EOL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.write(`${logMessage}${EOL}`);
|
|
||||||
stream.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractFileName(stack: string): string {
|
|
||||||
const stackLines: string[] = stack.split("\n");
|
|
||||||
let callerFile: string = "";
|
|
||||||
|
|
||||||
for (let i: number = 2; i < stackLines.length; i++) {
|
|
||||||
const line: string = stackLines[i].trim();
|
|
||||||
if (line && !line.includes("Logger.") && line.includes("(")) {
|
|
||||||
callerFile = line.split("(")[1]?.split(")")[0] || "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return basename(callerFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCallerInfo(stack: unknown): {
|
|
||||||
filename: string;
|
|
||||||
timestamp: string;
|
|
||||||
} {
|
|
||||||
const filename: string =
|
|
||||||
typeof stack === "string" ? this.extractFileName(stack) : "unknown";
|
|
||||||
|
|
||||||
const readableTimestamp: string = timestampToReadable();
|
|
||||||
|
|
||||||
return { filename, timestamp: readableTimestamp };
|
|
||||||
}
|
|
||||||
|
|
||||||
public info(message: string | string[], breakLine: boolean = false): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[INFO]", color: "32" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public warn(message: string | string[], breakLine: boolean = false): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[WARN]", color: "33" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public error(
|
|
||||||
message: string | Error | ErrorEvent | (string | Error)[],
|
|
||||||
breakLine: boolean = false,
|
|
||||||
): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const messages: (string | Error | ErrorEvent)[] = Array.isArray(message)
|
|
||||||
? message
|
|
||||||
: [message];
|
|
||||||
const joinedMessage: string = messages
|
|
||||||
.map((msg: string | Error | ErrorEvent): string =>
|
|
||||||
typeof msg === "string" ? msg : msg.message,
|
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[ERROR]", color: "31" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public custom(
|
|
||||||
bracketMessage: string,
|
|
||||||
bracketMessage2: string,
|
|
||||||
message: string | string[],
|
|
||||||
color: string,
|
|
||||||
breakLine: boolean = false,
|
|
||||||
): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: bracketMessage, color },
|
|
||||||
filename: { value: `${bracketMessage2}`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(
|
|
||||||
`${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`,
|
|
||||||
);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public space(): void {
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeConsoleMessageColored(
|
|
||||||
logMessageParts: ILogMessageParts,
|
|
||||||
breakLine: boolean = false,
|
|
||||||
): void {
|
|
||||||
const logMessage: string = Object.keys(logMessageParts)
|
|
||||||
.map((key: string) => {
|
|
||||||
const part: ILogMessagePart = logMessageParts[key];
|
|
||||||
return `\x1b[${part.color}m${part.value}\x1b[0m`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
console.log(logMessage + (breakLine ? EOL : ""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger: Logger = Logger.getInstance();
|
|
||||||
export { logger };
|
|
|
@ -1,204 +0,0 @@
|
||||||
import { redisConfig } from "@config/environment";
|
|
||||||
import { logger } from "@helpers/logger";
|
|
||||||
import { createClient, type RedisClientType } from "redis";
|
|
||||||
|
|
||||||
class RedisJson {
|
|
||||||
private static instance: RedisJson | null = null;
|
|
||||||
private client: RedisClientType | null = null;
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
public static async initialize(): Promise<RedisJson> {
|
|
||||||
if (!RedisJson.instance) {
|
|
||||||
RedisJson.instance = new RedisJson();
|
|
||||||
RedisJson.instance.client = createClient({
|
|
||||||
socket: {
|
|
||||||
host: redisConfig.host,
|
|
||||||
port: redisConfig.port,
|
|
||||||
},
|
|
||||||
username: redisConfig.username || undefined,
|
|
||||||
password: redisConfig.password || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
RedisJson.instance.client.on("error", (err: Error) => {
|
|
||||||
logger.error([
|
|
||||||
"Error connecting to Redis:",
|
|
||||||
err,
|
|
||||||
redisConfig.host,
|
|
||||||
]);
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
RedisJson.instance.client.on("connect", () => {
|
|
||||||
logger.info([
|
|
||||||
"Connected to Redis on",
|
|
||||||
`${redisConfig.host}:${redisConfig.port}`,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await RedisJson.instance.client.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return RedisJson.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getInstance(): RedisJson {
|
|
||||||
if (!RedisJson.instance || !RedisJson.instance.client) {
|
|
||||||
throw new Error(
|
|
||||||
"Redis instance not initialized. Call initialize() first.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return RedisJson.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.client.disconnect();
|
|
||||||
this.client = null;
|
|
||||||
logger.info("Redis disconnected successfully.");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error disconnecting Redis client:");
|
|
||||||
logger.error(error as Error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get(
|
|
||||||
type: "JSON" | "STRING",
|
|
||||||
key: string,
|
|
||||||
path?: string,
|
|
||||||
): Promise<
|
|
||||||
string | number | boolean | Record<string, unknown> | null | unknown
|
|
||||||
> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
throw new Error("Redis client is not initialized.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (type === "JSON") {
|
|
||||||
const value: unknown = await this.client.json.get(key, {
|
|
||||||
path,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
} else if (type === "STRING") {
|
|
||||||
const value: string | null = await this.client.get(key);
|
|
||||||
return value;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid type: ${type}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error getting value from Redis for key: ${key}`);
|
|
||||||
logger.error(error as Error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set(
|
|
||||||
type: "JSON" | "STRING",
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
expiresInSeconds?: number,
|
|
||||||
path?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
throw new Error("Redis client is not initialized.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (type === "JSON") {
|
|
||||||
await this.client.json.set(key, path || "$", value as string);
|
|
||||||
|
|
||||||
if (expiresInSeconds) {
|
|
||||||
await this.client.expire(key, expiresInSeconds);
|
|
||||||
}
|
|
||||||
} else if (type === "STRING") {
|
|
||||||
if (expiresInSeconds) {
|
|
||||||
await this.client.set(key, value as string, {
|
|
||||||
EX: expiresInSeconds,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.client.set(key, value as string);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid type: ${type}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error setting value in Redis for key: ${key}`);
|
|
||||||
logger.error(error as Error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async delete(type: "JSON" | "STRING", key: string): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
throw new Error("Redis client is not initialized.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (type === "JSON") {
|
|
||||||
await this.client.json.del(key);
|
|
||||||
} else if (type === "STRING") {
|
|
||||||
await this.client.del(key);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid type: ${type}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error deleting value from Redis for key: ${key}`);
|
|
||||||
logger.error(error as Error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async expire(key: string, seconds: number): Promise<void> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
throw new Error("Redis client is not initialized.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.client.expire(key, seconds);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error([
|
|
||||||
`Error expiring key in Redis: ${key}`,
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async keys(pattern: string): Promise<string[]> {
|
|
||||||
if (!this.client) {
|
|
||||||
logger.error("Redis client is not initialized.");
|
|
||||||
throw new Error("Redis client is not initialized.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const keys: string[] = await this.client.keys(pattern);
|
|
||||||
return keys;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error([
|
|
||||||
`Error getting keys from Redis for pattern: ${pattern}`,
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const redis: {
|
|
||||||
initialize: () => Promise<RedisJson>;
|
|
||||||
getInstance: () => RedisJson;
|
|
||||||
} = {
|
|
||||||
initialize: RedisJson.initialize,
|
|
||||||
getInstance: RedisJson.getInstance,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { RedisJson };
|
|
|
@ -1,194 +0,0 @@
|
||||||
import { jwt } from "@config/environment";
|
|
||||||
import { environment } from "@config/environment";
|
|
||||||
import { redis } from "@helpers/redis";
|
|
||||||
import { createDecoder, createSigner, createVerifier } from "fast-jwt";
|
|
||||||
|
|
||||||
type Signer = (payload: UserSession, options?: UserSession) => string;
|
|
||||||
type Verifier = (token: string, options?: UserSession) => UserSession;
|
|
||||||
type Decoder = (token: string, options?: UserSession) => UserSession;
|
|
||||||
|
|
||||||
class SessionManager {
|
|
||||||
private signer: Signer;
|
|
||||||
private verifier: Verifier;
|
|
||||||
private decoder: Decoder;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.signer = createSigner({
|
|
||||||
key: jwt.secret,
|
|
||||||
expiresIn: jwt.expiresIn,
|
|
||||||
});
|
|
||||||
this.verifier = createVerifier({ key: jwt.secret });
|
|
||||||
this.decoder = createDecoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createSession(
|
|
||||||
payload: UserSession,
|
|
||||||
userAgent: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const token: string = this.signer(payload);
|
|
||||||
const sessionKey: string = `session:${payload.id}:${token}`;
|
|
||||||
|
|
||||||
await redis
|
|
||||||
.getInstance()
|
|
||||||
.set(
|
|
||||||
"JSON",
|
|
||||||
sessionKey,
|
|
||||||
{ ...payload, userAgent },
|
|
||||||
this.getExpirationInSeconds(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cookie: string = this.generateCookie(token);
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSession(request: Request): Promise<UserSession | null> {
|
|
||||||
const cookie: string | null = request.headers.get("Cookie");
|
|
||||||
if (!cookie) return null;
|
|
||||||
|
|
||||||
const token: string | null =
|
|
||||||
cookie.match(/session=([^;]+)/)?.[1] || null;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const userSessions: string[] = await redis
|
|
||||||
.getInstance()
|
|
||||||
.keys("session:*:" + token);
|
|
||||||
if (!userSessions.length) return null;
|
|
||||||
|
|
||||||
const sessionData: unknown = await redis
|
|
||||||
.getInstance()
|
|
||||||
.get("JSON", userSessions[0]);
|
|
||||||
if (!sessionData) return null;
|
|
||||||
|
|
||||||
const payload: UserSession & { userAgent: string } =
|
|
||||||
sessionData as UserSession & { userAgent: string };
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateSession(
|
|
||||||
request: Request,
|
|
||||||
payload: UserSession,
|
|
||||||
userAgent: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const cookie: string | null = request.headers.get("Cookie");
|
|
||||||
if (!cookie) throw new Error("No session found in request");
|
|
||||||
|
|
||||||
const token: string | null =
|
|
||||||
cookie.match(/session=([^;]+)/)?.[1] || null;
|
|
||||||
if (!token) throw new Error("Session token not found");
|
|
||||||
|
|
||||||
const userSessions: string[] = await redis
|
|
||||||
.getInstance()
|
|
||||||
.keys("session:*:" + token);
|
|
||||||
if (!userSessions.length)
|
|
||||||
throw new Error("Session not found or expired");
|
|
||||||
|
|
||||||
const sessionKey: string = userSessions[0];
|
|
||||||
|
|
||||||
await redis
|
|
||||||
.getInstance()
|
|
||||||
.set(
|
|
||||||
"JSON",
|
|
||||||
sessionKey,
|
|
||||||
{ ...payload, userAgent },
|
|
||||||
this.getExpirationInSeconds(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.generateCookie(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async verifySession(token: string): Promise<UserSession> {
|
|
||||||
const userSessions: string[] = await redis
|
|
||||||
.getInstance()
|
|
||||||
.keys("session:*:" + token);
|
|
||||||
if (!userSessions.length)
|
|
||||||
throw new Error("Session not found or expired");
|
|
||||||
|
|
||||||
const sessionData: unknown = await redis
|
|
||||||
.getInstance()
|
|
||||||
.get("JSON", userSessions[0]);
|
|
||||||
if (!sessionData) throw new Error("Session not found or expired");
|
|
||||||
|
|
||||||
const payload: UserSession = this.verifier(token);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async decodeSession(token: string): Promise<UserSession> {
|
|
||||||
const payload: UserSession = this.decoder(token);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async invalidateSession(request: Request): Promise<void> {
|
|
||||||
const cookie: string | null = request.headers.get("Cookie");
|
|
||||||
if (!cookie) return;
|
|
||||||
|
|
||||||
const token: string | null =
|
|
||||||
cookie.match(/session=([^;]+)/)?.[1] || null;
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const userSessions: string[] = await redis
|
|
||||||
.getInstance()
|
|
||||||
.keys("session:*:" + token);
|
|
||||||
if (!userSessions.length) return;
|
|
||||||
|
|
||||||
await redis.getInstance().delete("JSON", userSessions[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateCookie(
|
|
||||||
token: string,
|
|
||||||
maxAge: number = this.getExpirationInSeconds(),
|
|
||||||
options?: {
|
|
||||||
secure?: boolean;
|
|
||||||
httpOnly?: boolean;
|
|
||||||
sameSite?: "Strict" | "Lax" | "None";
|
|
||||||
path?: string;
|
|
||||||
domain?: string;
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
const {
|
|
||||||
secure = !environment.development,
|
|
||||||
httpOnly = true,
|
|
||||||
sameSite = environment.development ? "Lax" : "None",
|
|
||||||
path = "/",
|
|
||||||
domain,
|
|
||||||
} = options || {};
|
|
||||||
|
|
||||||
let cookie: string = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`;
|
|
||||||
|
|
||||||
if (httpOnly) cookie += "; HttpOnly";
|
|
||||||
|
|
||||||
if (secure) cookie += "; Secure";
|
|
||||||
|
|
||||||
if (sameSite) cookie += `; SameSite=${sameSite}`;
|
|
||||||
|
|
||||||
if (domain) cookie += `; Domain=${domain}`;
|
|
||||||
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getExpirationInSeconds(): number {
|
|
||||||
const match: RegExpMatchArray | null =
|
|
||||||
jwt.expiresIn.match(/^(\d+)([smhd])$/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error("Invalid expiresIn format in jwt config");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, value, unit] = match;
|
|
||||||
const num: number = parseInt(value, 10);
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case "s":
|
|
||||||
return num;
|
|
||||||
case "m":
|
|
||||||
return num * 60;
|
|
||||||
case "h":
|
|
||||||
return num * 3600;
|
|
||||||
case "d":
|
|
||||||
return num * 86400;
|
|
||||||
default:
|
|
||||||
throw new Error("Invalid time unit in expiresIn");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionManager: SessionManager = new SessionManager();
|
|
||||||
export { sessionManager };
|
|
110
src/index.ts
110
src/index.ts
|
@ -1,14 +1,12 @@
|
||||||
import { dataType } from "@config/environment";
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
import { logger } from "@helpers/logger";
|
import { readdir } from "node:fs/promises";
|
||||||
import { type ReservedSQL, s3, sql } from "bun";
|
import { resolve } from "node:path";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { dataType, verifyRequiredVariables } from "@config";
|
||||||
import { readdir } from "fs/promises";
|
import { logger } from "@creations.works/logger";
|
||||||
import { resolve } from "path";
|
import { type ReservedSQL, redis, s3, sql } from "bun";
|
||||||
|
|
||||||
import { serverHandler } from "@/server";
|
import { serverHandler } from "@/server";
|
||||||
|
|
||||||
import { redis } from "./helpers/redis";
|
|
||||||
|
|
||||||
async function initializeDatabase(): Promise<void> {
|
async function initializeDatabase(): Promise<void> {
|
||||||
const sqlDir: string = resolve("config", "sql");
|
const sqlDir: string = resolve("config", "sql");
|
||||||
const files: string[] = await readdir(sqlDir);
|
const files: string[] = await readdir(sqlDir);
|
||||||
|
@ -17,9 +15,7 @@ async function initializeDatabase(): Promise<void> {
|
||||||
files
|
files
|
||||||
.filter((file: string): boolean => file.endsWith(".ts"))
|
.filter((file: string): boolean => file.endsWith(".ts"))
|
||||||
.map(async (file: string): Promise<Module> => {
|
.map(async (file: string): Promise<Module> => {
|
||||||
const module: Module["module"] = await import(
|
const module: Module["module"] = await import(resolve(sqlDir, file));
|
||||||
resolve(sqlDir, file)
|
|
||||||
);
|
|
||||||
return { file, module };
|
return { file, module };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -40,63 +36,69 @@ async function initializeDatabase(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
verifyRequiredVariables();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
await sql`SELECT 1;`;
|
||||||
await sql`SELECT 1;`;
|
|
||||||
|
|
||||||
logger.info([
|
logger.info([
|
||||||
"Connected to PostgreSQL on",
|
"Connected to PostgreSQL on",
|
||||||
`${process.env.PGHOST}:${process.env.PGPORT}`,
|
`${process.env.PGHOST}:${process.env.PGPORT}`,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error([
|
||||||
"Could not establish a connection to PostgreSQL:",
|
"Could not establish a connection to PostgreSQL:",
|
||||||
error as Error,
|
error as Error,
|
||||||
]);
|
]);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataType.type === "local" && dataType.path) {
|
try {
|
||||||
if (!existsSync(dataType.path)) {
|
await redis.connect();
|
||||||
try {
|
|
||||||
mkdirSync(dataType.path);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error([
|
|
||||||
"Could not create datasource local directory",
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info([
|
const url = new URL(process.env.REDIS_URL || "redis://localhost:6379");
|
||||||
"Using local datasource directory",
|
const host = url.hostname;
|
||||||
`${dataType.path}`,
|
const port = url.port || "6379";
|
||||||
]);
|
|
||||||
} else {
|
logger.info(["Connected to Redis on", `${host}:${port}`]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Redis connection failed:", error as Error]);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType.type === "local" && dataType.path) {
|
||||||
|
if (!existsSync(dataType.path)) {
|
||||||
try {
|
try {
|
||||||
await s3.write("test", "test");
|
mkdirSync(dataType.path);
|
||||||
await s3.delete("test");
|
|
||||||
|
|
||||||
logger.info([
|
|
||||||
"Connected to S3 with bucket",
|
|
||||||
`${process.env.S3_BUCKET}`,
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error([
|
||||||
"Could not establish a connection to S3 bucket:",
|
"Could not create datasource local directory",
|
||||||
error as Error,
|
error as Error,
|
||||||
]);
|
]);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.initialize();
|
logger.info(["Using local datasource directory", `${dataType.path}`]);
|
||||||
serverHandler.initialize();
|
} else {
|
||||||
await initializeDatabase();
|
try {
|
||||||
} catch (error) {
|
await s3.write("test", "test");
|
||||||
throw error;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.space();
|
||||||
|
|
||||||
|
serverHandler.initialize();
|
||||||
|
await initializeDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: Error) => {
|
main().catch((error: Error) => {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { isUUID } from "@helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@helpers/logger";
|
import { isUUID } from "@lib/char";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
export async function authByToken(
|
export async function authByToken(
|
||||||
request: ExtendedRequest,
|
request: ExtendedRequest,
|
||||||
reservation?: ReservedSQL,
|
reservation?: ReservedSQL,
|
||||||
): Promise<ApiUserSession | null> {
|
): Promise<ApiUserSession | null> {
|
||||||
let selfReservation: boolean = false;
|
let selfReservation = false;
|
||||||
|
let activeReservation: ReservedSQL | undefined = reservation;
|
||||||
|
|
||||||
const authorizationHeader: string | null =
|
const authorizationHeader: string | null =
|
||||||
request.headers.get("Authorization");
|
request.headers.get("Authorization");
|
||||||
|
@ -17,14 +18,14 @@ export async function authByToken(
|
||||||
const authorizationToken: string = authorizationHeader.slice(7).trim();
|
const authorizationToken: string = authorizationHeader.slice(7).trim();
|
||||||
if (!authorizationToken || !isUUID(authorizationToken)) return null;
|
if (!authorizationToken || !isUUID(authorizationToken)) return null;
|
||||||
|
|
||||||
if (!reservation) {
|
if (!activeReservation) {
|
||||||
reservation = await sql.reserve();
|
activeReservation = await sql.reserve();
|
||||||
selfReservation = true;
|
selfReservation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: User[] =
|
const result: User[] =
|
||||||
await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
|
await activeReservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`;
|
||||||
|
|
||||||
if (result.length === 0) return null;
|
if (result.length === 0) return null;
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ export async function authByToken(
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (selfReservation) {
|
if (selfReservation) {
|
||||||
reservation.release();
|
activeReservation.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,8 +2,8 @@ import { DateTime } from "luxon";
|
||||||
|
|
||||||
export function timestampToReadable(timestamp?: number): string {
|
export function timestampToReadable(timestamp?: number): string {
|
||||||
const date: Date =
|
const date: Date =
|
||||||
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
|
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
|
||||||
if (isNaN(date.getTime())) return "Invalid Date";
|
if (Number.isNaN(date.getTime())) return "Invalid Date";
|
||||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
return date.toISOString().replace("T", " ").replace("Z", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ export function parseDuration(input: string): DurationObject {
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const value: number = parseInt(match[1], 10);
|
const value: number = Number.parseInt(match[1], 10);
|
||||||
const unit: string = match[2];
|
const unit: string = match[2];
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
|
@ -84,18 +84,14 @@ export function isValidTimezone(timezone: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRandomString(length?: number): string {
|
export function generateRandomString(length?: number): string {
|
||||||
if (!length) {
|
const finalLength: number = length ?? Math.floor(Math.random() * 10) + 5;
|
||||||
length = length || Math.floor(Math.random() * 10) + 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
const characters: string =
|
const characters =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
let result: string = "";
|
let result = "";
|
||||||
|
|
||||||
for (let i: number = 0; i < length; i++) {
|
for (let i = 0; i < finalLength; i++) {
|
||||||
result += characters.charAt(
|
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
Math.floor(Math.random() * characters.length),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -172,9 +168,7 @@ export function nameWithoutExtension(fileName: string): string {
|
||||||
if (lastDotIndex <= 0) return fileName;
|
if (lastDotIndex <= 0) return fileName;
|
||||||
|
|
||||||
const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase();
|
const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase();
|
||||||
return knownExtensions.has(ext)
|
return knownExtensions.has(ext) ? fileName.slice(0, lastDotIndex) : fileName;
|
||||||
? fileName.slice(0, lastDotIndex)
|
|
||||||
: fileName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsExif(mimeType: string, extension: string): boolean {
|
export function supportsExif(mimeType: string, extension: string): boolean {
|
||||||
|
@ -206,18 +200,22 @@ export function supportsThumbnail(mimeType: string): boolean {
|
||||||
return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType);
|
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
|
// Commands
|
||||||
export function parseArgs(): Record<string, string | boolean> {
|
export function parseArgs(): Record<string, string | boolean> {
|
||||||
const args: string[] = process.argv.slice(2);
|
const args: string[] = process.argv.slice(2);
|
||||||
const parsedArgs: Record<string, string | boolean> = {};
|
const parsedArgs: Record<string, string | boolean> = {};
|
||||||
|
|
||||||
for (let i: number = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
if (args[i].startsWith("--")) {
|
if (args[i].startsWith("--")) {
|
||||||
const key: string = args[i].slice(2);
|
const key: string = args[i].slice(2);
|
||||||
const value: string | boolean =
|
const value: string | boolean =
|
||||||
args[i + 1] && !args[i + 1].startsWith("--")
|
args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true;
|
||||||
? args[i + 1]
|
|
||||||
: true;
|
|
||||||
parsedArgs[key] = value;
|
parsedArgs[key] = value;
|
||||||
if (value !== true) i++;
|
if (value !== true) i++;
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { parseArgs } from "@helpers/char";
|
import { parseArgs } from "@lib/char";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
|
@ -24,8 +24,7 @@ import { type ReservedSQL, sql } from "bun";
|
||||||
error.message.includes("foreign key constraint")
|
error.message.includes("foreign key constraint")
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
`Could not clear table "${table}" due to foreign key constraints.\n` +
|
`Could not clear table "${table}" due to foreign key constraints.\nTry using --cascade if you want to remove dependent records.`,
|
||||||
"Try using --cascade if you want to remove dependent records.",
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("Could not clear table:", error);
|
console.error("Could not clear table:", error);
|
145
src/lib/jwt.ts
Normal file
145
src/lib/jwt.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import { environment, jwt } from "@config";
|
||||||
|
import { redis } from "bun";
|
||||||
|
import { createDecoder, createSigner, createVerifier } from "fast-jwt";
|
||||||
|
|
||||||
|
const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration });
|
||||||
|
const verifier = createVerifier({ key: jwt.secret });
|
||||||
|
const decoder = createDecoder();
|
||||||
|
|
||||||
|
export async function createSession(
|
||||||
|
payload: UserSession,
|
||||||
|
userAgent: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const token = signer(payload);
|
||||||
|
const sessionKey = `session:${payload.id}:${token}`;
|
||||||
|
await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent }));
|
||||||
|
await redis.expire(sessionKey, getExpirationInSeconds());
|
||||||
|
return generateCookie(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(
|
||||||
|
request: Request,
|
||||||
|
): Promise<UserSession | null> {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) return null;
|
||||||
|
const keys = await redis.keys(`session:*:${token}`);
|
||||||
|
if (!keys.length) return null;
|
||||||
|
const raw = await redis.get(keys[0]);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSession(
|
||||||
|
request: Request,
|
||||||
|
payload: UserSession,
|
||||||
|
userAgent: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) throw new Error("Session token not found");
|
||||||
|
const keys = await redis.keys(`session:*:${token}`);
|
||||||
|
if (!keys.length) throw new Error("Session not found or expired");
|
||||||
|
await redis.set(keys[0], JSON.stringify({ ...payload, userAgent }));
|
||||||
|
await redis.expire(keys[0], getExpirationInSeconds());
|
||||||
|
return generateCookie(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySession(token: string): Promise<UserSession> {
|
||||||
|
const keys = await redis.keys(`session:*:${token}`);
|
||||||
|
if (!keys.length) throw new Error("Session not found or expired");
|
||||||
|
return verifier(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decodeSession(token: string): Promise<UserSession> {
|
||||||
|
return decoder(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateSession(request: Request): Promise<void> {
|
||||||
|
const token = extractToken(request);
|
||||||
|
if (!token) return;
|
||||||
|
const keys = await redis.keys(`session:*:${token}`);
|
||||||
|
if (!keys.length) return;
|
||||||
|
await redis.del(keys[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateSessionById(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const keys = await redis.keys(`session:*:${sessionId}`);
|
||||||
|
if (!keys.length) return false;
|
||||||
|
await redis.del(keys[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateAllSessionsForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const keys = await redis.keys(`session:${userId}:*`);
|
||||||
|
if (keys.length === 0) return 0;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
await redis.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
function extractToken(request: Request): string | null {
|
||||||
|
return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCookie(
|
||||||
|
token: string,
|
||||||
|
maxAge = getExpirationInSeconds(),
|
||||||
|
options?: {
|
||||||
|
secure?: boolean;
|
||||||
|
httpOnly?: boolean;
|
||||||
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
|
path?: string;
|
||||||
|
domain?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
secure = !environment.development,
|
||||||
|
httpOnly = true,
|
||||||
|
sameSite = environment.development ? "Lax" : "None",
|
||||||
|
path = "/",
|
||||||
|
domain,
|
||||||
|
} = options || {};
|
||||||
|
|
||||||
|
let cookie = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`;
|
||||||
|
if (httpOnly) cookie += "; HttpOnly";
|
||||||
|
if (secure) cookie += "; Secure";
|
||||||
|
if (sameSite) cookie += `; SameSite=${sameSite}`;
|
||||||
|
if (domain) cookie += `; Domain=${domain}`;
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpirationInSeconds(): number {
|
||||||
|
const match = jwt.expiration.match(/^(\d+)([smhd])$/);
|
||||||
|
if (!match) throw new Error("Invalid expiresIn format in jwt config");
|
||||||
|
const [, value, unit] = match;
|
||||||
|
const num = Number(value);
|
||||||
|
switch (unit) {
|
||||||
|
case "s":
|
||||||
|
return num;
|
||||||
|
case "m":
|
||||||
|
return num * 60;
|
||||||
|
case "h":
|
||||||
|
return num * 3600;
|
||||||
|
case "d":
|
||||||
|
return num * 86400;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid time unit in expiresIn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionManager = {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
updateSession,
|
||||||
|
verifySession,
|
||||||
|
decodeSession,
|
||||||
|
invalidateSession,
|
||||||
|
invalidateSessionById,
|
||||||
|
invalidateAllSessionsForUser,
|
||||||
|
};
|
9
src/lib/validators/avatar.ts
Normal file
9
src/lib/validators/avatar.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export function isValidTypeOrExtension(
|
||||||
|
type: string,
|
||||||
|
extension: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) &&
|
||||||
|
["jpeg", "jpg", "png", "gif", "webp"].includes(extension)
|
||||||
|
);
|
||||||
|
}
|
18
src/lib/validators/email.ts
Normal file
18
src/lib/validators/email.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
const emailRestrictions: { regex: RegExp } = {
|
||||||
|
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidEmail(email: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!email) {
|
||||||
|
return { valid: false, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailRestrictions.regex.test(email)) {
|
||||||
|
return { valid: false, error: "Invalid email address" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
5
src/lib/validators/index.ts
Normal file
5
src/lib/validators/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "@lib/validators/name";
|
||||||
|
export * from "@lib/validators/email";
|
||||||
|
export * from "@lib/validators/password";
|
||||||
|
export * from "@lib/validators/invite";
|
||||||
|
export * from "@lib/validators/avatar";
|
37
src/lib/validators/invite.ts
Normal file
37
src/lib/validators/invite.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
const inviteRestrictions: { min: number; max: number; regex: RegExp } = {
|
||||||
|
min: 4,
|
||||||
|
max: 15,
|
||||||
|
regex: /^[a-zA-Z0-9]+$/,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidInvite(invite: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!invite) {
|
||||||
|
return { valid: false, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.length < inviteRestrictions.min) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invite code must be at least ${inviteRestrictions.min} characters long`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.length > inviteRestrictions.max) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invite code can't be longer than ${inviteRestrictions.max} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inviteRestrictions.regex.test(invite)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Invite code contains invalid characters",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
31
src/lib/validators/name.ts
Normal file
31
src/lib/validators/name.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// ? should support non english characters but won't mess up the url
|
||||||
|
export const userNameRestrictions: {
|
||||||
|
length: { min: number; max: number };
|
||||||
|
regex: RegExp;
|
||||||
|
} = {
|
||||||
|
length: { min: 3, max: 20 },
|
||||||
|
regex: /^[\p{L}\p{N}._-]+$/u,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidUsername(username: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!username) {
|
||||||
|
return { valid: false, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < userNameRestrictions.length.min) {
|
||||||
|
return { valid: false, error: "Username is too short" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length > userNameRestrictions.length.max) {
|
||||||
|
return { valid: false, error: "Username is too long" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userNameRestrictions.regex.test(username)) {
|
||||||
|
return { valid: false, error: "Username contains invalid characters" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
40
src/lib/validators/password.ts
Normal file
40
src/lib/validators/password.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
const passwordRestrictions: {
|
||||||
|
length: { min: number; max: number };
|
||||||
|
regex: RegExp;
|
||||||
|
} = {
|
||||||
|
length: { min: 12, max: 64 },
|
||||||
|
regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidPassword(password: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!password) {
|
||||||
|
return { valid: false, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < passwordRestrictions.length.min) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Password must be at least ${passwordRestrictions.length.min} characters long`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > passwordRestrictions.length.max) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Password can't be longer than ${passwordRestrictions.length.max} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordRestrictions.regex.test(password)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import { dataType } from "@config/environment.ts";
|
import { join, resolve } from "node:path";
|
||||||
import { logger } from "@helpers/logger.ts";
|
import { dataType } from "@config";
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
import { type BunFile, s3, sql } from "bun";
|
import { type BunFile, s3, sql } from "bun";
|
||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import imageThumbnail from "image-thumbnail";
|
import imageThumbnail from "image-thumbnail";
|
||||||
import { join, resolve } from "path";
|
|
||||||
|
|
||||||
declare var self: Worker;
|
declare let self: Worker;
|
||||||
|
|
||||||
async function generateVideoThumbnail(
|
async function generateVideoThumbnail(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
@ -22,10 +22,7 @@ async function generateVideoThumbnail(
|
||||||
.format("mjpeg")
|
.format("mjpeg")
|
||||||
.output(thumbnailPath)
|
.output(thumbnailPath)
|
||||||
.on("error", (error: Error) => {
|
.on("error", (error: Error) => {
|
||||||
logger.error([
|
logger.error(["failed to generate thumbnail", error as Error]);
|
||||||
"failed to generate thumbnail",
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
reject(error);
|
reject(error);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -50,40 +47,34 @@ async function generateImageThumbnail(
|
||||||
thumbnailPath: string,
|
thumbnailPath: string,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
return new Promise(
|
return new Promise(
|
||||||
async (
|
(
|
||||||
resolve: (value: ArrayBuffer) => void,
|
resolve: (value: ArrayBuffer) => void,
|
||||||
reject: (reason: Error) => void,
|
reject: (reason: Error) => void,
|
||||||
) => {
|
): void => {
|
||||||
try {
|
const options = {
|
||||||
const options: {
|
height: 320,
|
||||||
responseType: "buffer";
|
responseType: "buffer" as const,
|
||||||
height: number;
|
jpegOptions: {
|
||||||
jpegOptions: {
|
force: true,
|
||||||
force: boolean;
|
quality: 60,
|
||||||
quality: number;
|
},
|
||||||
};
|
};
|
||||||
} = {
|
|
||||||
height: 320,
|
|
||||||
responseType: "buffer",
|
|
||||||
jpegOptions: {
|
|
||||||
force: true,
|
|
||||||
quality: 60,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const thumbnailBuffer: Buffer = await imageThumbnail(
|
imageThumbnail(filePath, options)
|
||||||
filePath,
|
.then(
|
||||||
options,
|
(thumbnailBuffer: Buffer): Promise<ArrayBuffer> =>
|
||||||
);
|
Bun.write(thumbnailPath, thumbnailBuffer.buffer).then(
|
||||||
|
(): Promise<ArrayBuffer> => Bun.file(thumbnailPath).arrayBuffer(),
|
||||||
await Bun.write(thumbnailPath, thumbnailBuffer.buffer);
|
),
|
||||||
resolve(await Bun.file(thumbnailPath).arrayBuffer());
|
)
|
||||||
|
.then((arrayBuffer: ArrayBuffer) => {
|
||||||
await Bun.file(filePath).unlink();
|
resolve(arrayBuffer);
|
||||||
await Bun.file(thumbnailPath).unlink();
|
return Promise.all([
|
||||||
} catch (error) {
|
Bun.file(filePath).unlink(),
|
||||||
reject(error as Error);
|
Bun.file(thumbnailPath).unlink(),
|
||||||
}
|
]);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -104,20 +95,14 @@ async function createThumbnails(files: FileEntry[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
fileArrayBuffer = await Bun.file(filePath).arrayBuffer();
|
fileArrayBuffer = await Bun.file(filePath).arrayBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
logger.error([
|
logger.error(["Could not generate thumbnail for file:", fileName]);
|
||||||
"Could not generate thumbnail for file:",
|
|
||||||
fileName,
|
|
||||||
]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
fileArrayBuffer = await s3.file(fileName).arrayBuffer();
|
fileArrayBuffer = await s3.file(fileName).arrayBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
logger.error([
|
logger.error(["Could not generate thumbnail for file:", fileName]);
|
||||||
"Could not generate thumbnail for file:",
|
|
||||||
fileName,
|
|
||||||
]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,10 +134,7 @@ async function createThumbnails(files: FileEntry[]): Promise<void> {
|
||||||
: await generateImageThumbnail(tempFilePath, tempThumbnailPath);
|
: await generateImageThumbnail(tempFilePath, tempThumbnailPath);
|
||||||
|
|
||||||
if (!thumbnailArrayBuffer) {
|
if (!thumbnailArrayBuffer) {
|
||||||
logger.error([
|
logger.error(["Could not generate thumbnail for file:", fileName]);
|
||||||
"Could not generate thumbnail for file:",
|
|
||||||
fileName,
|
|
||||||
]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,5 +186,5 @@ self.onmessage = async (event: MessageEvent): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
self.onerror = (error: ErrorEvent): void => {
|
self.onerror = (error: ErrorEvent): void => {
|
||||||
logger.error(error);
|
logger.error(["An error occurred in the thumbnail worker:", error.message]);
|
||||||
};
|
};
|
|
@ -1,161 +0,0 @@
|
||||||
import {
|
|
||||||
isValidEmail,
|
|
||||||
isValidPassword,
|
|
||||||
isValidUsername,
|
|
||||||
} from "@config/sql/users";
|
|
||||||
import { password as bunPassword, type ReservedSQL, sql } from "bun";
|
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
|
||||||
method: "POST",
|
|
||||||
accepts: "application/json",
|
|
||||||
returns: "application/json",
|
|
||||||
needsBody: "json",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handler(
|
|
||||||
request: ExtendedRequest,
|
|
||||||
requestBody: unknown,
|
|
||||||
): Promise<Response> {
|
|
||||||
if (request.session) {
|
|
||||||
if ((request.session as ApiUserSession).is_api) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 403,
|
|
||||||
error: "You cannot log in while using an authorization token",
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 403,
|
|
||||||
error: "Already logged in",
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, email, password } = requestBody as {
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!password || (!username && !email)) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 400,
|
|
||||||
error: "Expected username or email, and password",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
const validations: UserValidation[] = [
|
|
||||||
{ check: isValidUsername(username), field: "Username" },
|
|
||||||
{ check: isValidEmail(email), field: "Email" },
|
|
||||||
{ check: isValidPassword(password), field: "Password" },
|
|
||||||
];
|
|
||||||
|
|
||||||
validations.forEach(({ check }: UserValidation): void => {
|
|
||||||
if (!check.valid && check.error) {
|
|
||||||
errors.push(check.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 400,
|
|
||||||
errors,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reservation: ReservedSQL = await sql.reserve();
|
|
||||||
let user: User | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
user = await reservation`
|
|
||||||
SELECT * FROM users
|
|
||||||
WHERE (username = ${username} OR email = ${email})
|
|
||||||
LIMIT 1;
|
|
||||||
`.then((rows: User[]): User | null => rows[0]);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
await bunPassword.verify("fake", await bunPassword.hash("fake"));
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 401,
|
|
||||||
error: "Invalid username, email, or password",
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordMatch: boolean = await bunPassword.verify(
|
|
||||||
password,
|
|
||||||
user.password,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!passwordMatch) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 401,
|
|
||||||
error: "Invalid username, email, or password",
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(["Error logging in", error as Error]);
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
code: 500,
|
|
||||||
error: "An error occurred while logging in",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (reservation) reservation.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionCookie: string = await sessionManager.createSession(
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
email_verified: user.email_verified,
|
|
||||||
roles: user.roles[0].split(","),
|
|
||||||
avatar: user.avatar,
|
|
||||||
timezone: user.timezone,
|
|
||||||
authorization_token: user.authorization_token,
|
|
||||||
},
|
|
||||||
request.headers.get("User-Agent") || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
code: 200,
|
|
||||||
},
|
|
||||||
{ status: 200, headers: { "Set-Cookie": sessionCookie } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { handler, routeDef };
|
|
|
@ -1,15 +0,0 @@
|
||||||
const routeDef: RouteDef = {
|
|
||||||
method: "GET",
|
|
||||||
accepts: "*/*",
|
|
||||||
returns: "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handler(): Promise<Response> {
|
|
||||||
// TODO: Put something useful here
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
message: "Hello, World!",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { handler, routeDef };
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { sql } from "bun";
|
import { redis, sql } from "bun";
|
||||||
|
|
||||||
import { isUUID } from "@/helpers/char";
|
import { sessionManager } from "@/lib/jwt";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { redis } from "@/helpers/redis";
|
import { isUUID } from "@lib/char";
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -37,11 +36,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verificationData: unknown = await redis
|
const raw: string | null = await redis.get(`email:verify:${code}`);
|
||||||
.getInstance()
|
|
||||||
.get("JSON", `email:verify:${code}`);
|
|
||||||
|
|
||||||
if (!verificationData) {
|
if (!raw) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -52,11 +49,24 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user_id: userId } = verificationData as {
|
let verificationData: { user_id: string };
|
||||||
user_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
await redis.getInstance().delete("JSON", `email:verify:${code}`);
|
try {
|
||||||
|
verificationData = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Malformed verification data",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id: userId } = verificationData;
|
||||||
|
|
||||||
|
await redis.del(`email:verify:${code}`);
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET email_verified = true
|
SET email_verified = true
|
|
@ -1,7 +1,7 @@
|
||||||
import { randomUUIDv7, sql } from "bun";
|
import { randomUUIDv7, sql } from "bun";
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { redis } from "@/helpers/redis";
|
import { redis } from "bun";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -51,11 +51,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const code: string = randomUUIDv7();
|
const code: string = randomUUIDv7();
|
||||||
await redis.getInstance().set(
|
await redis.set(
|
||||||
"JSON",
|
|
||||||
`email:verify:${code}`,
|
`email:verify:${code}`,
|
||||||
{ user_id: request.session.id },
|
JSON.stringify({ user_id: request.session.id }),
|
||||||
60 * 60 * 2, // 2 hours
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Send email when email service is implemented
|
// TODO: Send email when email service is implemented
|
|
@ -1,20 +1,166 @@
|
||||||
import { getSetting } from "@config/sql/settings";
|
import {
|
||||||
import { renderEjsTemplate } from "@helpers/ejs";
|
isValidEmail,
|
||||||
|
isValidPassword,
|
||||||
|
isValidUsername,
|
||||||
|
} from "@lib/validators";
|
||||||
|
import { type ReservedSQL, password as bunPassword, sql } from "bun";
|
||||||
|
|
||||||
|
import { sessionManager } from "@/lib/jwt";
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
accepts: "*/*",
|
accepts: "application/json",
|
||||||
returns: "text/html",
|
returns: "application/json",
|
||||||
|
needsBody: "json",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handler(): Promise<Response> {
|
async function handler(
|
||||||
const ejsTemplateData: EjsTemplateData = {
|
request: ExtendedRequest,
|
||||||
title: "Hello, World!",
|
requestBody: unknown,
|
||||||
instance_name:
|
): Promise<Response> {
|
||||||
(await getSetting("instance_name")) || "Unnamed Instance",
|
if (request.session) {
|
||||||
|
if ((request.session as ApiUserSession).is_api) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "You cannot log in while using an authorization token",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Already logged in",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, email, password } = requestBody as {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
return await renderEjsTemplate("auth/login", ejsTemplateData);
|
if (!password || (!username && !email)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Expected username or email, and password",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const validations: UserValidation[] = [
|
||||||
|
username ? { check: isValidUsername(username), field: "Username" } : null,
|
||||||
|
email ? { check: isValidEmail(email), field: "Email" } : null,
|
||||||
|
password ? { check: isValidPassword(password), field: "Password" } : null,
|
||||||
|
].filter(Boolean) as UserValidation[];
|
||||||
|
|
||||||
|
for (const { check } of validations) {
|
||||||
|
if (!check.valid && check.error) {
|
||||||
|
errors.push(check.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username && !email) {
|
||||||
|
errors.push("Either a username or an email is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
errors,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
|
let user: User | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
[user] = await reservation`
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE (username = ${username} OR email = ${email})
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
await bunPassword.verify("fake", await bunPassword.hash("fake"));
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 401,
|
||||||
|
error: "Invalid username, email, or password",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch: boolean = await bunPassword.verify(
|
||||||
|
password,
|
||||||
|
user.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 401,
|
||||||
|
error: "Invalid username, email, or password",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Error logging in", error as Error]);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "An error occurred while logging in",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (reservation) reservation.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookie: string = await sessionManager.createSession(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
email_verified: user.email_verified,
|
||||||
|
roles: user.roles[0].split(","),
|
||||||
|
avatar: user.avatar,
|
||||||
|
timezone: user.timezone,
|
||||||
|
authorization_token: user.authorization_token,
|
||||||
|
},
|
||||||
|
request.headers.get("User-Agent") || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
},
|
||||||
|
{ status: 200, headers: { "Set-Cookie": sessionCookie } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handler, routeDef };
|
export { handler, routeDef };
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
import { sessionManager } from "@/lib/jwt";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
|
@ -4,12 +4,12 @@ import {
|
||||||
isValidInvite,
|
isValidInvite,
|
||||||
isValidPassword,
|
isValidPassword,
|
||||||
isValidUsername,
|
isValidUsername,
|
||||||
} from "@config/sql/users";
|
} from "@lib/validators";
|
||||||
import { password as bunPassword, type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, password as bunPassword, sql } from "bun";
|
||||||
|
|
||||||
import { isValidTimezone } from "@/helpers/char";
|
import { sessionManager } from "@/lib/jwt";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
import { isValidTimezone } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -49,17 +49,17 @@ async function handler(
|
||||||
{ check: isValidPassword(password), field: "Password" },
|
{ check: isValidPassword(password), field: "Password" },
|
||||||
];
|
];
|
||||||
|
|
||||||
validations.forEach(({ check }: UserValidation): void => {
|
for (const { check } of validations) {
|
||||||
if (!check.valid && check.error) {
|
if (!check.valid && check.error) {
|
||||||
errors.push(check.error);
|
errors.push(check.error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const normalizedUsername: string = username.normalize("NFC");
|
const normalizedUsername: string = username.normalize("NFC");
|
||||||
const reservation: ReservedSQL = await sql.reserve();
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
let firstUser: boolean = false;
|
let firstUser = false;
|
||||||
let inviteData: Invite | null = null;
|
let inviteData: Invite | null = null;
|
||||||
let roles: string[] = [];
|
const roles: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const registrationEnabled: boolean =
|
const registrationEnabled: boolean =
|
||||||
|
@ -69,13 +69,16 @@ async function handler(
|
||||||
|
|
||||||
firstUser =
|
firstUser =
|
||||||
Number(
|
Number(
|
||||||
(await reservation`SELECT COUNT(*) AS count FROM users;`)[0]
|
(await reservation`SELECT COUNT(*) AS count FROM users;`)[0]?.count,
|
||||||
?.count,
|
|
||||||
) === 0;
|
) === 0;
|
||||||
|
|
||||||
|
let inviteValid = true;
|
||||||
if (!firstUser && invite) {
|
if (!firstUser && invite) {
|
||||||
const inviteValidation: { valid: boolean; error?: string } =
|
const inviteValidation: { valid: boolean; error?: string } =
|
||||||
isValidInvite(invite);
|
isValidInvite(invite);
|
||||||
|
|
||||||
|
inviteValid = inviteValidation.valid;
|
||||||
|
|
||||||
if (!inviteValidation.valid && inviteValidation.error) {
|
if (!inviteValidation.valid && inviteValidation.error) {
|
||||||
errors.push(inviteValidation.error);
|
errors.push(inviteValidation.error);
|
||||||
}
|
}
|
||||||
|
@ -89,10 +92,12 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
roles.push("user");
|
roles.push("user");
|
||||||
if (firstUser) roles.push("admin");
|
if (firstUser) {
|
||||||
|
roles.push("admin");
|
||||||
|
roles.push("superadmin");
|
||||||
|
}
|
||||||
|
|
||||||
const result: { usernameExists: boolean; emailExists: boolean }[] =
|
const [result] = await reservation`
|
||||||
await reservation`
|
|
||||||
SELECT
|
SELECT
|
||||||
EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${normalizedUsername})) AS "usernameExists",
|
EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${normalizedUsername})) AS "usernameExists",
|
||||||
EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists";
|
EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists";
|
||||||
|
@ -104,15 +109,13 @@ async function handler(
|
||||||
errors.push("Username or email already exists");
|
errors.push("Username or email already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invite && !firstUser) {
|
if (invite && inviteValid && !firstUser) {
|
||||||
const result: Invite[] =
|
[inviteData] =
|
||||||
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!inviteData) {
|
||||||
errors.push("Invalid invite");
|
errors.push("Invalid invite");
|
||||||
}
|
}
|
||||||
|
|
||||||
inviteData = result[0];
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push("An error occurred while checking for existing users");
|
errors.push("An error occurred while checking for existing users");
|
||||||
|
@ -140,13 +143,13 @@ async function handler(
|
||||||
: (await getSetting("default_timezone", reservation)) || "UTC";
|
: (await getSetting("default_timezone", reservation)) || "UTC";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: User[] = await reservation`
|
[user] = await reservation`
|
||||||
INSERT INTO users (username, email, password, invited_by, roles, timezone)
|
INSERT INTO users (username, email, password, invited_by, roles, timezone)
|
||||||
VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${setTimezone})
|
VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${setTimezone})
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!user) {
|
||||||
logger.error("User was not created");
|
logger.error("User was not created");
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
|
@ -158,8 +161,6 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
user = result[0];
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.error("User was not created");
|
logger.error("User was not created");
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -185,10 +186,7 @@ async function handler(
|
||||||
if (inviteData?.role) roles.push(inviteData.role);
|
if (inviteData?.role) roles.push(inviteData.role);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error(["Error inserting user into the database:", error as Error]);
|
||||||
"Error inserting user into the database:",
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
38
src/routes/auth/session.ts
Normal file
38
src/routes/auth/session.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "GET",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
if (!request.session) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Not logged in",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session } = request;
|
||||||
|
|
||||||
|
if ((session as ApiUserSession).is_api === true) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "You cannot use this endpoint with an authorization token",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { routeDef, handler };
|
181
src/routes/files.ts
Normal file
181
src/routes/files.ts
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
import { type ReservedSQL, type SQLQuery, sql } from "bun";
|
||||||
|
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
|
import { isUUID } from "@lib/char";
|
||||||
|
|
||||||
|
function isValidSort(sortBy: string): boolean {
|
||||||
|
const validSorts: string[] = [
|
||||||
|
"size",
|
||||||
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
"views",
|
||||||
|
"name",
|
||||||
|
"original_name",
|
||||||
|
"mime_type",
|
||||||
|
"extension",
|
||||||
|
];
|
||||||
|
return validSorts.includes(sortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSortOrder(sortOrder: string): string {
|
||||||
|
const validSortOrder: { [key: string]: string } = {
|
||||||
|
asc: "ASC",
|
||||||
|
desc: "DESC",
|
||||||
|
ascending: "ASC",
|
||||||
|
descending: "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
return validSortOrder[sortOrder.toLowerCase()] || "DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeLike: (value: string) => string = (value: string): string =>
|
||||||
|
value.replace(/[%_\\]/g, "\\$&");
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "GET",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
const {
|
||||||
|
user: user_id,
|
||||||
|
count = "25",
|
||||||
|
page = "0",
|
||||||
|
sort_by = "created_at",
|
||||||
|
sort_order = "DESC",
|
||||||
|
search_value,
|
||||||
|
} = request.query as {
|
||||||
|
user: string;
|
||||||
|
count: string;
|
||||||
|
page: string;
|
||||||
|
sort_by: string;
|
||||||
|
sort_order: string;
|
||||||
|
search_value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValidSort(sort_by)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Invalid sort_by value",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLookup: string | undefined = user_id || request.session?.id;
|
||||||
|
|
||||||
|
if (!userLookup) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Please provide a user ID or log in",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isId: boolean = isUUID(userLookup);
|
||||||
|
|
||||||
|
if (!isId) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Invalid user ID",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf: boolean = request.session?.id === userLookup;
|
||||||
|
const isAdmin: boolean = request.session
|
||||||
|
? request.session.roles.includes("admin")
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!isSelf && !isAdmin) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Unauthorized",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeCount: number = Math.min(Number.parseInt(count) || 25, 100);
|
||||||
|
const safePage: number = Math.max(Number.parseInt(page) || 0, 0);
|
||||||
|
const offset: number = safePage * safeCount;
|
||||||
|
const sortColumn: string = sort_by || "created_at";
|
||||||
|
const order: "ASC" | "DESC" = validSortOrder(sort_order) as "ASC" | "DESC";
|
||||||
|
const safeSearchValue: string = escapeLike(search_value || "");
|
||||||
|
|
||||||
|
let files: FileEntry[];
|
||||||
|
let totalPages = 0;
|
||||||
|
let totalFiles = 0;
|
||||||
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ! i really dont understand why bun wont accept reservation(order)`
|
||||||
|
function orderBy(field_name: string, orderBy: "ASC" | "DESC"): SQLQuery {
|
||||||
|
return reservation`ORDER BY ${reservation(field_name)} ${orderBy === "ASC" ? reservation`ASC` : reservation`DESC`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
files = await reservation`
|
||||||
|
SELECT
|
||||||
|
* FROM files
|
||||||
|
WHERE owner = ${userLookup} AND
|
||||||
|
(name ILIKE '%' || ${safeSearchValue} || '%' OR
|
||||||
|
original_name ILIKE '%' || ${safeSearchValue} || '%')
|
||||||
|
${orderBy(sortColumn, order)}
|
||||||
|
LIMIT ${safeCount} OFFSET ${offset};
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 404,
|
||||||
|
error: "No files found",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[{ count: totalFiles }] = await reservation`
|
||||||
|
SELECT COUNT(*)::int as count FROM files
|
||||||
|
WHERE owner = ${userLookup};
|
||||||
|
`;
|
||||||
|
|
||||||
|
totalPages = Math.ceil(totalFiles / safeCount);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Error fetching files", error as Error]);
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "Internal server error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
reservation.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
total_files: totalFiles,
|
||||||
|
total_pages: totalPages,
|
||||||
|
files,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -1,9 +1,9 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
import { s3, sql, type SQLQuery } from "bun";
|
import { dataType } from "@config";
|
||||||
import { resolve } from "path";
|
import { type SQLQuery, s3, sql } from "bun";
|
||||||
|
|
||||||
import { isUUID } from "@/helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@/helpers/logger";
|
import { isUUID } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
@ -124,7 +124,9 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
const isAdmin: boolean =
|
||||||
|
request.session.roles.includes("admin") ||
|
||||||
|
request.session.roles.includes("superadmin");
|
||||||
const { query: file } = request.params as { query: string };
|
const { query: file } = request.params as { query: string };
|
||||||
let { files } = requestBody as { files: string[] | string };
|
let { files } = requestBody as { files: string[] | string };
|
||||||
// const { password } = request.query as { password: string };
|
// const { password } = request.query as { password: string };
|
||||||
|
@ -134,26 +136,14 @@ async function handler(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (file && !(typeof file === "string" && file.length === 0)) {
|
if (file && !(typeof file === "string" && file.length === 0)) {
|
||||||
await processFile(
|
await processFile(request, file, isAdmin, failedFiles, successfulFiles);
|
||||||
request,
|
|
||||||
file,
|
|
||||||
isAdmin,
|
|
||||||
failedFiles,
|
|
||||||
successfulFiles,
|
|
||||||
);
|
|
||||||
} else if (files) {
|
} else if (files) {
|
||||||
files = Array.isArray(files)
|
files = Array.isArray(files)
|
||||||
? files
|
? files
|
||||||
: files.split(/[, ]+/).filter(Boolean);
|
: files.split(/[, ]+/).filter(Boolean);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await processFile(
|
await processFile(request, file, isAdmin, failedFiles, successfulFiles);
|
||||||
request,
|
|
||||||
file,
|
|
||||||
isAdmin,
|
|
||||||
failedFiles,
|
|
||||||
successfulFiles,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
|
@ -1,16 +1,17 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
|
import { dataType } from "@config";
|
||||||
import { getSetting } from "@config/sql/settings";
|
import { getSetting } from "@config/sql/settings";
|
||||||
import {
|
import {
|
||||||
|
type SQLQuery,
|
||||||
password as bunPassword,
|
password as bunPassword,
|
||||||
randomUUIDv7,
|
randomUUIDv7,
|
||||||
s3,
|
s3,
|
||||||
sql,
|
sql,
|
||||||
type SQLQuery,
|
|
||||||
} from "bun";
|
} from "bun";
|
||||||
import { exiftool } from "exiftool-vendored";
|
import { exiftool } from "exiftool-vendored";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
import {
|
import {
|
||||||
generateRandomString,
|
generateRandomString,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
|
@ -19,8 +20,7 @@ import {
|
||||||
nameWithoutExtension,
|
nameWithoutExtension,
|
||||||
supportsExif,
|
supportsExif,
|
||||||
supportsThumbnail,
|
supportsThumbnail,
|
||||||
} from "@/helpers/char";
|
} from "@lib/char";
|
||||||
import { logger } from "@/helpers/logger";
|
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -97,9 +97,7 @@ async function removeExifData(
|
||||||
LocationName: null,
|
LocationName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await exiftool.write(tempInputPath, tagsToRemove, [
|
await exiftool.write(tempInputPath, tagsToRemove, ["-overwrite_original"]);
|
||||||
"-overwrite_original",
|
|
||||||
]);
|
|
||||||
|
|
||||||
return await Bun.file(tempInputPath).arrayBuffer();
|
return await Bun.file(tempInputPath).arrayBuffer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -161,9 +159,9 @@ async function processFile(
|
||||||
};
|
};
|
||||||
|
|
||||||
const extension: string | null = getExtension(file.name);
|
const extension: string | null = getExtension(file.name);
|
||||||
let rawName: string | null = nameWithoutExtension(file.name);
|
const rawName: string | null = nameWithoutExtension(file.name);
|
||||||
const maxViews: number | null =
|
const maxViews: number | null =
|
||||||
parseInt(user_provided_max_views, 10) || null;
|
Number.parseInt(user_provided_max_views, 10) || null;
|
||||||
|
|
||||||
if (!rawName) {
|
if (!rawName) {
|
||||||
failedFiles.push({
|
failedFiles.push({
|
||||||
|
@ -176,13 +174,9 @@ async function processFile(
|
||||||
let hashedPassword: string | null = null;
|
let hashedPassword: string | null = null;
|
||||||
|
|
||||||
if (user_provided_password) {
|
if (user_provided_password) {
|
||||||
try {
|
hashedPassword = await bunPassword.hash(user_provided_password, {
|
||||||
hashedPassword = await bunPassword.hash(user_provided_password, {
|
algorithm: "argon2id",
|
||||||
algorithm: "argon2id",
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomUUID: string = randomUUIDv7();
|
const randomUUID: string = randomUUIDv7();
|
||||||
|
@ -190,7 +184,7 @@ async function processFile(
|
||||||
? user_provided_tags
|
? user_provided_tags
|
||||||
: (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []);
|
: (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []);
|
||||||
|
|
||||||
let uploadEntry: FileUpload = {
|
const uploadEntry: FileUpload = {
|
||||||
id: randomUUID as UUID,
|
id: randomUUID as UUID,
|
||||||
owner: session.id as UUID,
|
owner: session.id as UUID,
|
||||||
name: rawName,
|
name: rawName,
|
||||||
|
@ -201,9 +195,7 @@ async function processFile(
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
favorite: user_wants_favorite === "true" || user_wants_favorite === "1",
|
favorite: user_wants_favorite === "true" || user_wants_favorite === "1",
|
||||||
tags: tags,
|
tags: tags,
|
||||||
expires_at: delete_short_string
|
expires_at: delete_short_string ? getNewTimeUTC(delete_short_string) : null,
|
||||||
? getNewTimeUTC(delete_short_string)
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (name_format === "date") {
|
if (name_format === "date") {
|
||||||
|
@ -221,7 +213,7 @@ async function processFile(
|
||||||
// ? Should work not sure about non-english characters
|
// ? Should work not sure about non-english characters
|
||||||
const sanitizedFileName: string = rawName
|
const sanitizedFileName: string = rawName
|
||||||
.normalize("NFD")
|
.normalize("NFD")
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
.replace(/\p{Mn}/gu, "")
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
|
@ -280,7 +272,7 @@ async function processFile(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
path = "/uploads/" + uuidWithExtension;
|
path = `/uploads/${uuidWithExtension}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await s3.write(path, fileBuffer);
|
await s3.write(path, fileBuffer);
|
||||||
|
@ -296,7 +288,7 @@ async function processFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: FileUpload[] = await sql`
|
const [result] = await sql`
|
||||||
INSERT INTO files ( id, owner, folder, name, original_name, mime_type, extension, size, max_views, password, favorite, tags, expires_at )
|
INSERT INTO files ( id, owner, folder, name, original_name, mime_type, extension, size, max_views, password, favorite, tags, expires_at )
|
||||||
VALUES (
|
VALUES (
|
||||||
${uploadEntry.id}, ${uploadEntry.owner}, ${folder_identifier}, ${uploadEntry.name},
|
${uploadEntry.id}, ${uploadEntry.owner}, ${folder_identifier}, ${uploadEntry.name},
|
||||||
|
@ -308,7 +300,7 @@ async function processFile(
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!result) {
|
||||||
failedFiles.push({
|
failedFiles.push({
|
||||||
reason: "Failed to create file entry",
|
reason: "Failed to create file entry",
|
||||||
file: key,
|
file: key,
|
||||||
|
@ -325,7 +317,7 @@ async function processFile(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadEntry.password) delete uploadEntry.password;
|
if (uploadEntry.password) uploadEntry.password = undefined;
|
||||||
|
|
||||||
uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`;
|
uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`;
|
||||||
successfulFiles.push(uploadEntry);
|
successfulFiles.push(uploadEntry);
|
||||||
|
@ -366,9 +358,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
requestBody.append(
|
requestBody.append(
|
||||||
"file",
|
"file",
|
||||||
new Blob([body], { type: request.actualContentType }),
|
new Blob([body], { type: request.actualContentType }),
|
||||||
request.actualContentType === "text/plain"
|
request.actualContentType === "text/plain" ? "file.txt" : "file.json",
|
||||||
? "file.txt"
|
|
||||||
: "file.json",
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -442,20 +432,16 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter(
|
const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter(
|
||||||
(file: FileUpload): boolean =>
|
(file: FileUpload): boolean => supportsThumbnail(file.mime_type as string),
|
||||||
supportsThumbnail(file.mime_type as string),
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
(await getSetting("enable_thumbnails")) === "true" &&
|
(await getSetting("enable_thumbnails")) === "true" &&
|
||||||
filesThatSupportThumbnails.length > 0
|
filesThatSupportThumbnails.length > 0
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const worker: Worker = new Worker(
|
const worker: Worker = new Worker("./src/helpers/workers/thumbnails", {
|
||||||
"./src/helpers/workers/thumbnails.ts",
|
type: "module",
|
||||||
{
|
});
|
||||||
type: "module",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
files: filesThatSupportThumbnails,
|
files: filesThatSupportThumbnails,
|
||||||
});
|
});
|
|
@ -1,4 +1,4 @@
|
||||||
import { renderEjsTemplate } from "@helpers/ejs";
|
import { frontendUrl } from "@config";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -7,15 +7,14 @@ const routeDef: RouteDef = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
if (!request.session) {
|
return Response.json(
|
||||||
return Response.redirect("/auth/login");
|
{
|
||||||
}
|
success: true,
|
||||||
|
code: 200,
|
||||||
const ejsTemplateData: EjsTemplateData = {
|
message: `This is the api for ${frontendUrl}`,
|
||||||
title: "Hello, World!",
|
},
|
||||||
};
|
{ status: 200 },
|
||||||
|
);
|
||||||
return await renderEjsTemplate("index", ejsTemplateData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handler, routeDef };
|
export { handler, routeDef };
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { isValidUsername } from "@config/sql/users";
|
import { isValidUsername } from "@lib/validators";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
import { isUUID } from "@/helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@/helpers/logger";
|
import { isUUID } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -28,7 +28,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: GetUser | null = null;
|
let user: GetUser | null = null;
|
||||||
let isSelf: boolean = false;
|
let isSelf = false;
|
||||||
const isId: boolean = isUUID(query);
|
const isId: boolean = isUUID(query);
|
||||||
const normalized: string = isId ? query : query.normalize("NFC");
|
const normalized: string = isId ? query : query.normalize("NFC");
|
||||||
const isAdmin: boolean = request.session
|
const isAdmin: boolean = request.session
|
||||||
|
@ -49,11 +49,11 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const reservation: ReservedSQL = await sql.reserve();
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: GetUser[] = isId
|
[user] = isId
|
||||||
? await reservation`SELECT * FROM users WHERE id = ${normalized}`
|
? await reservation`SELECT * FROM users WHERE id = ${normalized}`
|
||||||
: await reservation`SELECT * FROM users WHERE username = ${normalized}`;
|
: await reservation`SELECT * FROM users WHERE username = ${normalized}`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!user) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -64,8 +64,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
user = result[0];
|
|
||||||
|
|
||||||
isSelf = request.session ? user.id === request.session.id : false;
|
isSelf = request.session ? user.id === request.session.id : false;
|
||||||
|
|
||||||
const files: { count: bigint }[] =
|
const files: { count: bigint }[] =
|
||||||
|
@ -112,9 +110,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete user.password;
|
user.password = undefined;
|
||||||
delete user.authorization_token;
|
user.authorization_token = undefined;
|
||||||
if (!isSelf) delete user.email;
|
if (!isSelf) user.email = undefined;
|
||||||
|
|
||||||
user.roles = user.roles ? user.roles[0].split(",") : [];
|
user.roles = user.roles ? user.roles[0].split(",") : [];
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { getSetting } from "@config/sql/settings";
|
import { getSetting } from "@config/sql/settings";
|
||||||
import { sql } from "bun";
|
import { sql } from "bun";
|
||||||
|
|
||||||
import { generateRandomString, getNewTimeUTC } from "@/helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@/helpers/logger";
|
import { generateRandomString, getNewTimeUTC } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -37,7 +37,9 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
const isAdmin: boolean =
|
||||||
|
request.session.roles.includes("admin") ||
|
||||||
|
request.session.roles.includes("superadmin");
|
||||||
|
|
||||||
if (!isAdmin && !getSetting("allow_user_invites")) {
|
if (!isAdmin && !getSetting("allow_user_invites")) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -67,21 +69,19 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expirationDate: string | null = expires
|
const expirationDate: string | null = expires ? getNewTimeUTC(expires) : null;
|
||||||
? getNewTimeUTC(expires)
|
|
||||||
: null;
|
|
||||||
const maxUses: number = Number(max_uses) || 1;
|
const maxUses: number = Number(max_uses) || 1;
|
||||||
const inviteRole: string = role || "user";
|
const inviteRole: string = role || "user";
|
||||||
|
|
||||||
let invite: Invite | null = null;
|
let invite: Invite | null = null;
|
||||||
try {
|
try {
|
||||||
const result: Invite[] = await sql`
|
[invite] = await sql`
|
||||||
INSERT INTO invites (created_by, expiration, max_uses, role, id)
|
INSERT INTO invites (created_by, expiration, max_uses, role, id)
|
||||||
VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)})
|
VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)})
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!invite) {
|
||||||
logger.error("Invite failed to create");
|
logger.error("Invite failed to create");
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -93,8 +93,6 @@ async function handler(
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
invite = result[0];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["Error creating invite:", error as Error]);
|
logger.error(["Error creating invite:", error as Error]);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { isValidInvite } from "@config/sql/users";
|
import { isValidInvite } from "@lib/validators";
|
||||||
import { type ReservedSQL, sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
@ -21,7 +21,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
const isAdmin: boolean =
|
||||||
|
request.session.roles.includes("admin") ||
|
||||||
|
request.session.roles.includes("superadmin");
|
||||||
const { invite } = request.params as { invite: string };
|
const { invite } = request.params as { invite: string };
|
||||||
|
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
|
@ -52,10 +54,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
let inviteData: Invite | null = null;
|
let inviteData: Invite | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: Invite[] =
|
[inviteData] =
|
||||||
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!inviteData) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -66,8 +68,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
inviteData = result[0];
|
|
||||||
|
|
||||||
if (!isAdmin && inviteData.created_by !== request.session.id) {
|
if (!isAdmin && inviteData.created_by !== request.session.id) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
|
@ -1,9 +1,9 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
|
import { dataType } from "@config";
|
||||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
import { isUUID, nameWithoutExtension } from "@/helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@/helpers/logger";
|
import { isUUID, nameWithoutExtension } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json === "true" || json === "1") {
|
if (json === "true" || json === "1") {
|
||||||
delete fileData.password;
|
fileData.password = undefined;
|
||||||
fileData.tags = fileData.tags = fileData.tags[0]?.trim()
|
fileData.tags = fileData.tags = fileData.tags[0]?.trim()
|
||||||
? fileData.tags[0].split(",").filter((tag: string) => tag.trim())
|
? fileData.tags[0].split(",").filter((tag: string) => tag.trim())
|
||||||
: [];
|
: [];
|
||||||
|
@ -139,9 +139,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
} else {
|
} else {
|
||||||
path = resolve(
|
path = resolve(
|
||||||
dataType.path,
|
dataType.path,
|
||||||
`${fileData.id}${
|
`${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`,
|
||||||
fileData.extension ? `.${fileData.extension}` : ""
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -157,9 +155,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
|
||||||
return new Response(bunStream, {
|
return new Response(bunStream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": shouldShowThumbnail
|
"Content-Type": shouldShowThumbnail ? "image/jpeg" : fileData.mime_type,
|
||||||
? "image/jpeg"
|
|
||||||
: fileData.mime_type,
|
|
||||||
"Content-Disposition":
|
"Content-Disposition":
|
||||||
downloadFile === "true" || downloadFile === "1"
|
downloadFile === "true" || downloadFile === "1"
|
||||||
? `attachment; filename="${fileData.original_name || fileData.name}"`
|
? `attachment; filename="${fileData.original_name || fileData.name}"`
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { setSetting } from "@config/sql/settings";
|
import { setSetting } from "@config/sql/settings";
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -53,7 +53,8 @@ async function handler(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
code: 400,
|
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 },
|
{ status: 400 },
|
||||||
);
|
);
|
|
@ -1,10 +1,10 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
import { isValidUsername } from "@config/sql/users";
|
import { dataType } from "@config";
|
||||||
|
import { isValidUsername } from "@lib/validators";
|
||||||
import { type BunFile, type ReservedSQL, sql } from "bun";
|
import { type BunFile, type ReservedSQL, sql } from "bun";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char";
|
import { logger } from "@creations.works/logger";
|
||||||
import { logger } from "@/helpers/logger";
|
import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -71,11 +71,12 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
if (json === "true" || json === "1") {
|
if (json === "true" || json === "1") {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: true, code: 200,
|
success: true,
|
||||||
|
code: 200,
|
||||||
avatar: {
|
avatar: {
|
||||||
...avatar,
|
...avatar,
|
||||||
url: `${getBaseUrl(request)}/user/avatar/${user.id}`,
|
url: `${getBaseUrl(request)}/user/avatar/${user.id}`,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
|
import { dataType } from "@config";
|
||||||
import { s3, sql } from "bun";
|
import { s3, sql } from "bun";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
import { sessionManager } from "@/lib/jwt";
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
import { logger } from "@creations.works/logger";
|
||||||
|
|
||||||
async function deleteAvatar(
|
async function deleteAvatar(
|
||||||
request: ExtendedRequest,
|
request: ExtendedRequest,
|
||||||
|
@ -21,9 +21,7 @@ async function deleteAvatar(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (dataType.type === "local" && dataType.path) {
|
if (dataType.type === "local" && dataType.path) {
|
||||||
await Bun.file(
|
await Bun.file(resolve(dataType.path, "avatars", fileName)).unlink();
|
||||||
resolve(dataType.path, "avatars", fileName),
|
|
||||||
).unlink();
|
|
||||||
} else {
|
} else {
|
||||||
await s3.delete(`/avatars/${fileName}`);
|
await s3.delete(`/avatars/${fileName}`);
|
||||||
}
|
}
|
||||||
|
@ -61,7 +59,9 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userID: UUID = (request.query.user as UUID) || request.session.id;
|
const userID: UUID = (request.query.user as UUID) || request.session.id;
|
||||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
const isAdmin: boolean =
|
||||||
|
request.session.roles.includes("admin") ||
|
||||||
|
request.session.roles.includes("superadmin");
|
||||||
|
|
||||||
if (request.session.id !== userID && !isAdmin) {
|
if (request.session.id !== userID && !isAdmin) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -115,16 +115,15 @@ 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) {
|
} catch (error) {
|
||||||
logger.error(["Error processing delete request:", error as Error]);
|
logger.error(["Error processing delete request:", error as Error]);
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { dataType } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
import { isValidTypeOrExtension } from "@config/sql/avatars";
|
import { dataType } from "@config";
|
||||||
import { getSetting } from "@config/sql/settings";
|
import { getSetting } from "@config/sql/settings";
|
||||||
|
import { isValidTypeOrExtension } from "@lib/validators";
|
||||||
import { s3, sql } from "bun";
|
import { s3, sql } from "bun";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
import { getBaseUrl, getExtension } from "@/helpers/char";
|
import { sessionManager } from "@/lib/jwt";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
import { getBaseUrl, getExtension } from "@lib/char";
|
||||||
|
|
||||||
async function processFile(
|
async function processFile(
|
||||||
file: File,
|
file: File,
|
||||||
|
@ -50,10 +50,7 @@ async function processFile(
|
||||||
await s3.delete(`/avatars/${existingFileName}`);
|
await s3.delete(`/avatars/${existingFileName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error(["Error deleting existing avatar file:", error as Error]);
|
||||||
"Error deleting existing avatar file:",
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,9 +135,7 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const file: File | null =
|
const file: File | null =
|
||||||
(formData.get("file") as File) ||
|
(formData.get("file") as File) || (formData.get("avatar") as File) || null;
|
||||||
(formData.get("avatar") as File) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (!file.type || file.type === "") {
|
if (!file.type || file.type === "") {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -206,17 +201,16 @@ 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) {
|
} catch (error) {
|
||||||
logger.error(["Error processing file:", error as Error]);
|
logger.error(["Error processing file:", error as Error]);
|
||||||
|
|
161
src/server.ts
161
src/server.ts
|
@ -1,17 +1,16 @@
|
||||||
import { environment } from "@config/environment";
|
import { resolve } from "node:path";
|
||||||
import { logger } from "@helpers/logger";
|
import { environment } from "@config";
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
import {
|
import {
|
||||||
type BunFile,
|
type BunFile,
|
||||||
FileSystemRouter,
|
FileSystemRouter,
|
||||||
type MatchedRoute,
|
type MatchedRoute,
|
||||||
type Serve,
|
type Serve,
|
||||||
} from "bun";
|
} from "bun";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
|
import { sessionManager } from "@/lib/jwt";
|
||||||
import { webSocketHandler } from "@/websocket";
|
import { webSocketHandler } from "@/websocket";
|
||||||
|
import { authByToken } from "@lib/auth";
|
||||||
import { authByToken } from "./helpers/auth";
|
|
||||||
import { sessionManager } from "./helpers/sessions";
|
|
||||||
|
|
||||||
class ServerHandler {
|
class ServerHandler {
|
||||||
private router: FileSystemRouter;
|
private router: FileSystemRouter;
|
||||||
|
@ -41,11 +40,7 @@ class ServerHandler {
|
||||||
maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon
|
maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(`Server running at ${environment.fqdn}`);
|
||||||
`Server running at http://${server.hostname}:${server.port}`,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logRoutes();
|
this.logRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,10 +58,15 @@ class ServerHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async serveStaticFile(pathname: string): Promise<Response> {
|
private async serveStaticFile(
|
||||||
try {
|
request: ExtendedRequest,
|
||||||
let filePath: string;
|
pathname: string,
|
||||||
|
ip: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
let filePath: string;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
if (pathname === "/favicon.ico") {
|
if (pathname === "/favicon.ico") {
|
||||||
filePath = resolve("public", "assets", "favicon.ico");
|
filePath = resolve("public", "assets", "favicon.ico");
|
||||||
} else {
|
} else {
|
||||||
|
@ -77,47 +77,87 @@ class ServerHandler {
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
||||||
const contentType: string =
|
const contentType: string = file.type || "application/octet-stream";
|
||||||
file.type || "application/octet-stream";
|
|
||||||
|
|
||||||
return new Response(fileContent, {
|
response = new Response(fileContent, {
|
||||||
headers: { "Content-Type": contentType },
|
headers: { "Content-Type": contentType },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`File not found: ${filePath}`);
|
logger.warn(`File not found: ${filePath}`);
|
||||||
return new Response("Not Found", { status: 404 });
|
response = new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error([`Error serving static file: ${pathname}`, error as Error]);
|
||||||
`Error serving static file: ${pathname}`,
|
response = new Response("Internal Server Error", { status: 500 });
|
||||||
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(
|
private async handleRequest(
|
||||||
request: ExtendedRequest,
|
request: Request,
|
||||||
server: BunServer,
|
server: BunServer,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
request.startPerf = performance.now();
|
if (request.method === "OPTIONS") {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": environment.frontendUrl,
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
request.headers.get("Access-Control-Request-Headers") || "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
|
||||||
|
extendedRequest.startPerf = performance.now();
|
||||||
|
|
||||||
|
const headers = request.headers;
|
||||||
|
let ip = server.requestIP(request)?.address;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
|
||||||
|
ip =
|
||||||
|
headers.get("CF-Connecting-IP")?.trim() ||
|
||||||
|
headers.get("X-Real-IP")?.trim() ||
|
||||||
|
headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
|
||||||
|
"unknown";
|
||||||
|
}
|
||||||
|
|
||||||
const pathname: string = new URL(request.url).pathname;
|
const pathname: string = new URL(request.url).pathname;
|
||||||
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
||||||
return await this.serveStaticFile(pathname);
|
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
const match: MatchedRoute | null = this.router.match(request);
|
const match: MatchedRoute | null = this.router.match(request);
|
||||||
let requestBody: unknown = {};
|
let requestBody: unknown = {};
|
||||||
let response: Response;
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const { filePath, params, query } = match;
|
const { filePath, params, query } = match;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const routeModule: RouteModule = await import(filePath);
|
const routeModule: RouteModule = await import(filePath);
|
||||||
const contentType: string | null =
|
const contentType: string | null = request.headers.get("Content-Type");
|
||||||
request.headers.get("Content-Type");
|
|
||||||
const actualContentType: string | null = contentType
|
const actualContentType: string | null = contentType
|
||||||
? contentType.split(";")[0].trim()
|
? contentType.split(";")[0].trim()
|
||||||
: null;
|
: null;
|
||||||
|
@ -144,9 +184,7 @@ class ServerHandler {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(Array.isArray(routeModule.routeDef.method) &&
|
(Array.isArray(routeModule.routeDef.method) &&
|
||||||
!routeModule.routeDef.method.includes(
|
!routeModule.routeDef.method.includes(request.method)) ||
|
||||||
request.method,
|
|
||||||
)) ||
|
|
||||||
(!Array.isArray(routeModule.routeDef.method) &&
|
(!Array.isArray(routeModule.routeDef.method) &&
|
||||||
routeModule.routeDef.method !== request.method)
|
routeModule.routeDef.method !== request.method)
|
||||||
) {
|
) {
|
||||||
|
@ -171,9 +209,7 @@ class ServerHandler {
|
||||||
if (Array.isArray(expectedContentType)) {
|
if (Array.isArray(expectedContentType)) {
|
||||||
matchesAccepts =
|
matchesAccepts =
|
||||||
expectedContentType.includes("*/*") ||
|
expectedContentType.includes("*/*") ||
|
||||||
expectedContentType.includes(
|
expectedContentType.includes(actualContentType || "");
|
||||||
actualContentType || "",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
matchesAccepts =
|
matchesAccepts =
|
||||||
expectedContentType === "*/*" ||
|
expectedContentType === "*/*" ||
|
||||||
|
@ -194,19 +230,15 @@ class ServerHandler {
|
||||||
{ status: 406 },
|
{ status: 406 },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
request.params = params;
|
extendedRequest.params = params;
|
||||||
request.query = query;
|
extendedRequest.query = query;
|
||||||
request.actualContentType = actualContentType;
|
extendedRequest.actualContentType = actualContentType;
|
||||||
|
|
||||||
request.session =
|
extendedRequest.session =
|
||||||
(await authByToken(request)) ||
|
(await authByToken(extendedRequest)) ||
|
||||||
(await sessionManager.getSession(request));
|
(await sessionManager.getSession(request));
|
||||||
|
|
||||||
response = await routeModule.handler(
|
response = await routeModule.handler(request, requestBody, server);
|
||||||
request,
|
|
||||||
requestBody,
|
|
||||||
server,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (routeModule.routeDef.returns !== "*/*") {
|
if (routeModule.routeDef.returns !== "*/*") {
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
|
@ -217,10 +249,7 @@ class ServerHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error([
|
logger.error([`Error handling route ${request.url}:`, error as Error]);
|
||||||
`Error handling route ${request.url}:`,
|
|
||||||
error as Error,
|
|
||||||
]);
|
|
||||||
|
|
||||||
response = Response.json(
|
response = Response.json(
|
||||||
{
|
{
|
||||||
|
@ -242,28 +271,22 @@ class ServerHandler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Headers = response.headers;
|
if (response?.headers) {
|
||||||
let ip: string | null = server.requestIP(request)?.address || null;
|
response.headers.set(
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
if (!ip) {
|
environment.frontendUrl,
|
||||||
ip =
|
);
|
||||||
headers.get("CF-Connecting-IP") ||
|
response.headers.set(
|
||||||
headers.get("X-Real-IP") ||
|
"Access-Control-Allow-Methods",
|
||||||
headers.get("X-Forwarded-For") ||
|
"GET, POST, PUT, DELETE, OPTIONS",
|
||||||
null;
|
);
|
||||||
|
response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
|
response.headers.set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
request.headers.get("Access-Control-Request-Headers") || "Content-Type",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.custom(
|
|
||||||
`[${request.method}]`,
|
|
||||||
`(${response.status})`,
|
|
||||||
[
|
|
||||||
request.url,
|
|
||||||
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
|
||||||
ip || "unknown",
|
|
||||||
],
|
|
||||||
"90",
|
|
||||||
);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<%- include("../global", { styles: ["auth/login"], scripts: [] }) %>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1><%= instance_name %></h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<form id="login-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Email</label>
|
|
||||||
<input type="email" name="email" id="email" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" name="password" id="password" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,31 +0,0 @@
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="color-scheme" content="dark">
|
|
||||||
|
|
||||||
<% if (title) { %>
|
|
||||||
<title><%= title %></title>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/public/css/global.css">
|
|
||||||
|
|
||||||
<% if (typeof styles !== "undefined") { %>
|
|
||||||
<% styles.forEach(style => { %>
|
|
||||||
<link rel="stylesheet" href="/public/css/<%= style %>.css">
|
|
||||||
<% }) %>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<% if (typeof scripts !== "undefined") { %>
|
|
||||||
<% scripts.forEach(script => { %>
|
|
||||||
<% if (typeof script === "string") { %>
|
|
||||||
<script src="/public/js/<%= script %>.js" defer></script>
|
|
||||||
<% } else if (Array.isArray(script)) { %>
|
|
||||||
<% if (script[1]) { %>
|
|
||||||
<script src="/public/js/<%= script[0] %>.js" defer></script>
|
|
||||||
<% } else { %>
|
|
||||||
<script src="/public/js/<%= script[0] %>.js"></script>
|
|
||||||
<% } %>
|
|
||||||
<% } %>
|
|
||||||
<% }) %>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<script src="/public/js/global.js"></script>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<%- include("global", { styles: [], scripts: [] }) %>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { logger } from "@creations.works/logger";
|
||||||
import { type ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
|
|
||||||
class WebSocketHandler {
|
class WebSocketHandler {
|
||||||
public handleMessage(ws: ServerWebSocket, message: string): void {
|
public handleMessage(ws: ServerWebSocket, message: string): void {
|
||||||
|
@ -20,11 +20,7 @@ class WebSocketHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleClose(
|
public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
|
||||||
ws: ServerWebSocket,
|
|
||||||
code: number,
|
|
||||||
reason: string,
|
|
||||||
): void {
|
|
||||||
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
|
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
/** @type {import('stylelint').Config} */
|
|
||||||
export default {
|
|
||||||
extends: ["stylelint-config-standard"],
|
|
||||||
rules: {
|
|
||||||
"color-function-notation": "modern",
|
|
||||||
"font-family-name-quotes": "always-where-required",
|
|
||||||
"declaration-empty-line-before": "never",
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -2,28 +2,15 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["src/*"],
|
||||||
"src/*"
|
"@config": ["config/index.ts"],
|
||||||
],
|
"@config/*": ["config/*"],
|
||||||
"@config/*": [
|
"@types/*": ["types/*"],
|
||||||
"config/*"
|
"@lib/*": ["src/lib/*"]
|
||||||
],
|
|
||||||
"@types/*": [
|
|
||||||
"types/*"
|
|
||||||
],
|
|
||||||
"@helpers/*": [
|
|
||||||
"src/helpers/*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"typeRoots": [
|
"typeRoots": ["./src/types", "./node_modules/@types"],
|
||||||
"./src/types",
|
|
||||||
"./node_modules/@types"
|
|
||||||
],
|
|
||||||
// Enable latest features
|
// Enable latest features
|
||||||
"lib": [
|
"lib": ["ESNext", "DOM"],
|
||||||
"ESNext",
|
|
||||||
"DOM"
|
|
||||||
],
|
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
@ -41,11 +28,7 @@
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", "types", "config"]
|
||||||
"src",
|
|
||||||
"types",
|
|
||||||
"config"
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
2
types/config.d.ts
vendored
2
types/config.d.ts
vendored
|
@ -2,6 +2,8 @@ type Environment = {
|
||||||
port: number;
|
port: number;
|
||||||
host: string;
|
host: string;
|
||||||
development: boolean;
|
development: boolean;
|
||||||
|
fqdn: string;
|
||||||
|
frontendUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserValidation = {
|
type UserValidation = {
|
||||||
|
|
3
types/ejs.d.ts
vendored
3
types/ejs.d.ts
vendored
|
@ -1,3 +0,0 @@
|
||||||
interface EjsTemplateData {
|
|
||||||
[key: string]: string | number | boolean | object | undefined | null;
|
|
||||||
}
|
|
9
types/logger.d.ts
vendored
9
types/logger.d.ts
vendored
|
@ -1,9 +0,0 @@
|
||||||
type ILogMessagePart = { value: string; color: string };
|
|
||||||
|
|
||||||
type ILogMessageParts = {
|
|
||||||
level: ILogMessagePart;
|
|
||||||
filename: ILogMessagePart;
|
|
||||||
readableTimestamp: ILogMessagePart;
|
|
||||||
message: ILogMessagePart;
|
|
||||||
[key: string]: ILogMessagePart;
|
|
||||||
};
|
|
Loading…
Add table
Add a link
Reference in a new issue