diff --git a/.gitignore b/.gitignore index 34366f7..7fdae35 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ next-env.d.ts migrations package-lock.json tsconfig.tsbuildinfo +config.yml diff --git a/config/config.example.yml b/config/config.example.yml new file mode 100644 index 0000000..e779f36 --- /dev/null +++ b/config/config.example.yml @@ -0,0 +1,15 @@ +app: + name: Pangolin + environment: dev + base_url: http://localhost:3000 + log_level: debug + save_logs: "false" + secure_cookies: "false" + +server: + external_port: "3000" + internal_port: "3001" + +rate_limit: + window_minutes: "1" + max_requests: "100" diff --git a/drizzle.config.ts b/drizzle.config.ts index 911d35a..7ef7ee9 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "drizzle-kit"; -import environment from "@server/environment"; +import config, { APP_PATH } from "@server/config"; import path from "path"; export default defineConfig({ @@ -8,6 +8,6 @@ export default defineConfig({ out: path.join("server", "migrations"), verbose: true, dbCredentials: { - url: path.join(environment.CONFIG_PATH, "db", "db.sqlite"), + url: path.join(APP_PATH, "db", "db.sqlite"), }, }); diff --git a/package.json b/package.json index c4e8c65..68a85f3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "glob": "11.0.0", "helmet": "7.1.0", "http-errors": "2.0.0", + "js-yaml": "4.1.0", "lucia": "3.2.0", "lucide-react": "0.447.0", "moment": "2.30.1", @@ -62,6 +63,7 @@ "@types/cookie-parser": "1.4.7", "@types/cors": "2.8.17", "@types/express": "5.0.0", + "@types/js-yaml": "4.0.9", "@types/node": "^20", "@types/nodemailer": "6.4.16", "@types/react": "^18", diff --git a/server/auth/index.ts b/server/auth/index.ts index 3fa0c7f..d33b94b 100644 --- a/server/auth/index.ts +++ b/server/auth/index.ts @@ -5,6 +5,7 @@ import { Lucia, TimeSpan } from "lucia"; import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; import db from "@server/db"; import { sessions, users } from "@server/db/schema"; +import config from "@server/config"; const adapter = new DrizzleSQLiteAdapter(db, sessions, users); @@ -18,19 +19,18 @@ export const lucia = new Lucia(adapter, { dateCreated: attributes.dateCreated, }; }, - // getSessionAttributes: (attributes) => { - // return { - // country: attributes.country, - // }; - // }, sessionCookie: { name: "session", expires: false, attributes: { - // secure: environment.ENVIRONMENT === "prod", - // sameSite: "strict", - secure: false, - domain: ".testing123.io", + sameSite: "strict", + secure: config.app.secure_cookies || false, + domain: + "." + + config.app.external_base_url + .split("://")[1] + .split(":")[0] + .split("/")[0], }, }, sessionExpiresIn: new TimeSpan(2, "w"), @@ -42,7 +42,6 @@ declare module "lucia" { interface Register { Lucia: typeof lucia; DatabaseUserAttributes: DatabaseUserAttributes; - DatabaseSessionAttributes: DatabaseSessionAttributes; } } @@ -54,7 +53,3 @@ interface DatabaseUserAttributes { emailVerified: boolean; dateCreated: string; } - -interface DatabaseSessionAttributes { - // country: string; -} diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..0f20f94 --- /dev/null +++ b/server/config.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import path from "path"; +import fs from "fs"; +import yaml from "js-yaml"; + +export const APP_PATH = path.join("config"); + +const environmentSchema = z.object({ + app: z.object({ + name: z.string(), + environment: z.enum(["dev", "prod"]), + external_base_url: z.string().url(), + internal_base_url: z.string().url(), + log_level: z.enum(["debug", "info", "warn", "error"]), + save_logs: z.string().transform((val) => val === "true"), + secure_cookies: z.string().transform((val) => val === "true"), + }), + server: z.object({ + external_port: z + .string() + .transform((val) => parseInt(val, 10)) + .pipe(z.number()), + internal_port: z + .string() + .transform((val) => parseInt(val, 10)) + .pipe(z.number()), + }), + rate_limit: z.object({ + window_minutes: z + .string() + .transform((val) => parseInt(val, 10)) + .pipe(z.number()), + max_requests: z + .string() + .transform((val) => parseInt(val, 10)) + .pipe(z.number()), + }), + email: z + .object({ + smtp_host: z.string().optional(), + smtp_port: z + .string() + .optional() + .transform((val) => { + if (val) { + return parseInt(val, 10); + } + return val; + }) + .pipe(z.number().optional()), + smtp_user: z.string().optional(), + smtp_pass: z.string().optional(), + no_reply: z.string().email().optional(), + }) + .optional(), +}); + +const loadConfig = (configPath: string) => { + try { + const yamlContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(yamlContent); + return config; + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Error loading configuration file: ${error.message}`, + ); + } + throw error; + } +}; + +const configFilePath = path.join(APP_PATH, "config.yml"); + +const environment = loadConfig(configFilePath); + +const parsedConfig = environmentSchema.safeParse(environment); + +if (!parsedConfig.success) { + const errors = fromError(parsedConfig.error); + throw new Error(`Invalid configuration file: ${errors}`); +} + +export default parsedConfig.data; diff --git a/server/db/index.ts b/server/db/index.ts index c25c2be..62be613 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -1,10 +1,10 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "@server/db/schema"; -import environment from "@server/environment"; +import config, { APP_PATH } from "@server/config"; import path from "path"; -const location = path.join(environment.CONFIG_PATH, "db", "db.sqlite"); +const location = path.join(APP_PATH, "db", "db.sqlite"); const sqlite = new Database(location); export const db = drizzle(sqlite, { schema }); diff --git a/server/emails/index.ts b/server/emails/index.ts index 00d6f7b..d2ce549 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -1,15 +1,15 @@ export * from "@server/emails/sendEmail"; import nodemailer from "nodemailer"; -import environment from "@server/environment"; +import config from "@server/config"; import logger from "@server/logger"; function createEmailClient() { if ( - !environment.EMAIL_SMTP_HOST || - !environment.EMAIL_SMTP_PORT || - !environment.EMAIL_SMTP_USER || - !environment.EMAIL_SMTP_PASS + !config.email?.smtp_host || + !config.email?.smtp_pass || + !config.email?.smtp_port || + !config.email?.smtp_user ) { logger.warn( "Email SMTP configuration is missing. Emails will not be sent.", @@ -18,12 +18,12 @@ function createEmailClient() { } return nodemailer.createTransport({ - host: environment.EMAIL_SMTP_HOST, - port: environment.EMAIL_SMTP_PORT, + host: config.email.smtp_host, + port: config.email.smtp_port, secure: false, auth: { - user: environment.EMAIL_SMTP_USER, - pass: environment.EMAIL_SMTP_PASS, + user: config.email.smtp_user, + pass: config.email.smtp_pass, }, }); } diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index 7ea9195..f8719e0 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -6,8 +6,8 @@ import logger from "@server/logger"; export async function sendEmail( template: ReactElement, opts: { - from: string; - to: string; + from: string | undefined; + to: string | undefined; subject: string; }, ) { diff --git a/server/environment.ts b/server/environment.ts deleted file mode 100644 index ab573b9..0000000 --- a/server/environment.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import path from "path"; - -const environmentSchema = z.object({ - ENVIRONMENT: z.enum(["dev", "prod"]), - LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]), - SAVE_LOGS: z.string().transform((val) => val === "true"), - CONFIG_PATH: z.string().transform((val) => { - // validate the path and remove any trailing slashes - const resolvedPath = path.resolve(val); - return resolvedPath.endsWith(path.sep) - ? resolvedPath.slice(0, -1) - : resolvedPath; - }), - EXTERNAL_PORT: z - .string() - .transform((val) => parseInt(val, 10)) - .pipe(z.number()), - INTERNAL_PORT: z - .string() - .transform((val) => parseInt(val, 10)) - .pipe(z.number()), - RATE_LIMIT_WINDOW_MIN: z - .string() - .transform((val) => parseInt(val, 10)) - .pipe(z.number()), - RATE_LIMIT_MAX: z - .string() - .transform((val) => parseInt(val, 10)) - .pipe(z.number()), - APP_NAME: z.string(), - EMAIL_SMTP_HOST: z.string().optional(), - EMAIL_SMTP_PORT: z - .string() - .optional() - .transform((val) => { - if (val) { - return parseInt(val, 10); - } - return val; - }) - .pipe(z.number().optional()), - EMAIL_SMTP_USER: z.string().optional(), - EMAIL_SMTP_PASS: z.string().optional(), - EMAIL_NOREPLY: z.string().email().optional(), - BASE_URL: z - .string() - .optional() - .transform((val) => { - if (!val) { - return `http://localhost:${environment.EXTERNAL_PORT}`; - } - return val; - }) - .pipe(z.string().url()), -}); - -const environment = { - ENVIRONMENT: (process.env.ENVIRONMENT as string) || "dev", - LOG_LEVEL: (process.env.LOG_LEVEL as string) || "debug", - SAVE_LOGS: (process.env.SAVE_LOGS as string) || "false", - CONFIG_PATH: - (process.env.CONFIG_PATH && path.join(process.env.CONFIG_PATH)) || - path.join("config"), - EXTERNAL_PORT: (process.env.EXTERNAL_PORT as string) || "3000", - INTERNAL_PORT: (process.env.INTERNAL_PORT as string) || "3001", - RATE_LIMIT_WINDOW_MIN: (process.env.RATE_LIMIT_WINDOW_MIN as string) || "1", - RATE_LIMIT_MAX: (process.env.RATE_LIMIT_MAX as string) || "100", - APP_NAME: (process.env.APP_NAME as string) || "Pangolin", - EMAIL_SMTP_HOST: process.env.EMAIL_SMTP_HOST as string, - EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT as string, - EMAIL_SMTP_USER: process.env.EMAIL_SMTP_USER as string, - EMAIL_SMTP_PASS: process.env.EMAIL_SMTP_PASS as string, - EMAIL_NOREPLY: process.env.EMAIL_NOREPLY as string, - BASE_URL: process.env.BASE_URL as string, -}; - -const parsedConfig = environmentSchema.safeParse(environment); - -if (!parsedConfig.success) { - const errors = fromError(parsedConfig.error); - throw new Error(`Invalid environment configuration: ${errors}`); -} - -export default parsedConfig.data; diff --git a/server/index.ts b/server/index.ts index 71292f2..04a72fe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from "express"; import next from "next"; import { parse } from "url"; -import environment from "@server/environment"; +import config from "@server/config"; import logger from "@server/logger"; import helmet from "helmet"; import cors from "cors"; @@ -15,16 +15,16 @@ import { authenticated, unauthenticated } from "@server/routers/external"; import cookieParser from "cookie-parser"; import { User } from "@server/db/schema"; -const dev = environment.ENVIRONMENT !== "prod"; +const dev = config.app.environment !== "prod"; const app = next({ dev }); const handle = app.getRequestHandler(); -const externalPort = environment.EXTERNAL_PORT; -const internalPort = environment.INTERNAL_PORT; +const externalPort = config.server.external_port; +const internalPort = config.server.internal_port; + +app.prepare().then(() => { -app.prepare().then(() => { - // External server const externalServer = express(); externalServer.set("trust proxy", 1); diff --git a/server/logger.ts b/server/logger.ts index c02df1c..832c7c9 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -1,5 +1,5 @@ import "winston-daily-rotate-file"; -import environment from "@server/environment"; +import config, { APP_PATH } from "@server/config"; import * as winston from "winston"; import path from "path"; @@ -24,11 +24,11 @@ const transports: any = [ }), ]; -if (environment.SAVE_LOGS) { +if (config.app.save_logs) { transports.push( new winston.transports.DailyRotateFile({ filename: path.join( - environment.CONFIG_PATH, + APP_PATH, "logs", "pangolin-%DATE%.log", ), @@ -43,7 +43,7 @@ if (environment.SAVE_LOGS) { transports.push( new winston.transports.DailyRotateFile({ filename: path.join( - environment.CONFIG_PATH, + APP_PATH, "logs", ".machinelogs-%DATE%.json", ), @@ -63,7 +63,7 @@ if (environment.SAVE_LOGS) { } const logger = winston.createLogger({ - level: environment.LOG_LEVEL.toLowerCase(), + level: config.app.log_level.toLowerCase(), format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/middlewares/formatError.ts b/server/middlewares/formatError.ts index d929e1e..e6b9454 100644 --- a/server/middlewares/formatError.ts +++ b/server/middlewares/formatError.ts @@ -2,7 +2,7 @@ import { ErrorRequestHandler, NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; -import environment from "@server/environment"; +import config from "@server/config"; export const errorHandlerMiddleware: ErrorRequestHandler = ( error, @@ -11,7 +11,7 @@ export const errorHandlerMiddleware: ErrorRequestHandler = ( next: NextFunction, ) => { const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR; - if (environment.ENVIRONMENT !== "prod") { + if (config.app.environment !== "prod") { logger.error(error); } res?.status(statusCode).send({ @@ -20,6 +20,6 @@ export const errorHandlerMiddleware: ErrorRequestHandler = ( error: true, message: error.message || "Internal Server Error", status: statusCode, - stack: environment.ENVIRONMENT === "prod" ? null : error.stack, + stack: config.app.environment === "prod" ? null : error.stack, }); }; diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 72e8f5d..7ea82cf 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -11,7 +11,7 @@ import { User, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { verify } from "@node-rs/argon2"; import { createTOTPKeyURI } from "oslo/otp"; -import env from "@server/environment"; +import config from "@server/config"; export const requestTotpSecretBody = z.object({ password: z.string(), @@ -65,7 +65,7 @@ export async function requestTotpSecret( const hex = crypto.getRandomValues(new Uint8Array(20)); const secret = encodeHex(hex); - const uri = createTOTPKeyURI(env.APP_NAME, user.email, hex); + const uri = createTOTPKeyURI(config.app.name, user.email, hex); await db .update(users) diff --git a/server/routers/auth/sendEmailVerificationCode.ts b/server/routers/auth/sendEmailVerificationCode.ts index a60b2b4..6a9ecd1 100644 --- a/server/routers/auth/sendEmailVerificationCode.ts +++ b/server/routers/auth/sendEmailVerificationCode.ts @@ -5,7 +5,7 @@ import { users, emailVerificationCodes } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { sendEmail } from "@server/emails"; import VerifyEmail from "@server/emails/templates/verifyEmailCode"; -import env from "@server/environment"; +import config from "@server/config"; export async function sendEmailVerificationCode( email: string, @@ -15,7 +15,7 @@ export async function sendEmailVerificationCode( await sendEmail(VerifyEmail({ username: email, verificationCode: code }), { to: email, - from: env.EMAIL_NOREPLY!, + from: config.email?.no_reply, subject: "Verify your email address", }); } diff --git a/server/routers/badger/verifyUser.ts b/server/routers/badger/verifyUser.ts index b4fd686..a157c42 100644 --- a/server/routers/badger/verifyUser.ts +++ b/server/routers/badger/verifyUser.ts @@ -24,8 +24,6 @@ export async function verifyUser( ): Promise { const parsedBody = verifyUserBody.safeParse(req.query); - logger.debug("Parsed body", parsedBody); - if (!parsedBody.success) { return next( createHttpError( @@ -40,9 +38,6 @@ export async function verifyUser( try { const { session, user } = await lucia.validateSession(sessionId); - logger.debug("Session", session); - logger.debug("User", user); - if (!session || !user) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid session"), diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 5d1b217..ca23963 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -5,14 +5,13 @@ import { DynamicTraefikConfig } from "./configSchema"; import { and, like, eq } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import env from "@server/environment"; -import environment from "@server/environment"; +import env from "@server/config"; +import config from "@server/config"; export async function traefikConfigProvider(_: Request, res: Response) { try { const targets = await getAllTargets(); const traefikConfig = buildTraefikConfig(targets); - // logger.debug("Built traefik config"); res.status(HttpCode.OK).json(traefikConfig); } catch (e) { logger.error(`Failed to build traefik config: ${e}`); @@ -32,35 +31,13 @@ export function buildTraefikConfig( } const http: DynamicTraefikConfig["http"] = { - routers: { - "themainwebpage": { - "entryPoints": [ - "http" - ], - "middlewares": [ - ], - "service": "service-themainwebpage", - "rule": "Host(`testing123.io`)" - }, - }, - services: { - "service-themainwebpage": { - "loadBalancer": { - "servers": [ - { - "url": `http://${environment.APP_NAME.toLowerCase()}:3000` - } - ] - } - }, - }, + routers: {}, + services: {}, middlewares: { [middlewareName]: { plugin: { [middlewareName]: { - apiBaseUrl: `http://${environment.APP_NAME.toLowerCase()}:3001/api/v1`, - // appBaseUrl: env.BASE_URL, - appBaseUrl: "http://testing123.io:8081", + apiBaseUrl: config.app.internal_base_url, }, }, }, diff --git a/src/api/index.ts b/src/api/index.ts index aa7fa88..792778f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,10 +1,7 @@ import axios from "axios"; -// const baseURL = `${window.location.protocol}//${window.location.host}/api/v1`; - - export const api = axios.create({ - baseURL: "http://testing123.io:8081/api/v1", + baseURL: process.env.NEXT_PUBLIC_EXTERNAL_API_BASE_URL, timeout: 10000, headers: { "Content-Type": "application/json", @@ -12,7 +9,7 @@ export const api = axios.create({ }); export const internal = axios.create({ - baseURL: "http://pangolin:3000/api/v1", + baseURL: process.env.NEXT_PUBLIC_INTERNAL_API_BASE_URL, timeout: 10000, headers: { "Content-Type": "application/json", diff --git a/src/app/globals.css b/src/app/globals.css index 67d8555..7aa5661 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,49 +4,48 @@ @layer base { :root { - --background: 37 100% 100%; - --foreground: 37 5% 10%; - --card: 37 50% 100%; - --card-foreground: 37 5% 15%; - --popover: 37 100% 100%; - --popover-foreground: 37 100% 10%; - --primary: 37 8% 51%; + --background: 37 0% 95%; + --foreground: 37 0% 10%; + --card: 37 0% 90%; + --card-foreground: 37 0% 15%; + --popover: 37 0% 95%; + --popover-foreground: 37 95% 10%; + --primary: 37 31% 25%; --primary-foreground: 0 0% 100%; - --secondary: 37 30% 90%; + --secondary: 37 10% 74%; --secondary-foreground: 0 0% 0%; - --muted: -1 30% 95%; - --muted-foreground: 37 5% 40%; - --accent: -1 30% 90%; - --accent-foreground: 37 5% 15%; - --destructive: 0 100% 50%; - --destructive-foreground: 37 5% 100%; - --border: 37 30% 82%; - --input: 37 30% 50%; - --ring: 37 8% 51%; - --radius: 0rem; + --muted: -1 10% 85%; + --muted-foreground: 37 0% 40%; + --accent: -1 10% 80%; + --accent-foreground: 37 0% 15%; + --destructive: 0 50% 50%; + --destructive-foreground: 37 0% 90%; + --border: 37 20% 74%; + --input: 37 20% 50%; + --ring: 37 31% 25%; + --radius: 0.5rem; } - .dark { - --background: 37 50% 10%; - --foreground: 37 5% 100%; - --card: 37 50% 10%; - --card-foreground: 37 5% 100%; - --popover: 37 50% 5%; - --popover-foreground: 37 5% 100%; - --primary: 37 8% 51%; + --background: 37 10% 10%; + --foreground: 37 0% 90%; + --card: 37 0% 10%; + --card-foreground: 37 0% 90%; + --popover: 37 10% 5%; + --popover-foreground: 37 0% 90%; + --primary: 37 31% 25%; --primary-foreground: 0 0% 100%; - --secondary: 37 30% 20%; + --secondary: 37 10% 20%; --secondary-foreground: 0 0% 100%; - --muted: -1 30% 25%; - --muted-foreground: 37 5% 65%; - --accent: -1 30% 25%; - --accent-foreground: 37 5% 95%; - --destructive: 0 100% 50%; - --destructive-foreground: 37 5% 100%; - --border: 37 30% 50%; - --input: 37 30% 50%; - --ring: 37 8% 51%; - --radius: 0rem; + --muted: -1 10% 25%; + --muted-foreground: 37 0% 65%; + --accent: -1 10% 25%; + --accent-foreground: 37 0% 90%; + --destructive: 0 50% 50%; + --destructive-foreground: 37 0% 90%; + --border: 37 20% 50%; + --input: 37 20% 50%; + --ring: 37 31% 25%; + --radius: 0.5rem; } } @@ -58,4 +57,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +}