use config file instead of env

This commit is contained in:
Milo Schwartz 2024-10-12 18:21:31 -04:00
parent 6fb569e2cd
commit d9ae322e2a
No known key found for this signature in database
19 changed files with 189 additions and 209 deletions

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ next-env.d.ts
migrations
package-lock.json
tsconfig.tsbuildinfo
config.yml

15
config/config.example.yml Normal file
View file

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

View file

@ -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"),
},
});

View file

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

View file

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

85
server/config.ts Normal file
View file

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

View file

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

View file

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

View file

@ -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;
},
) {

View file

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

View file

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

View file

@ -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(),

View file

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

View file

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

View file

@ -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",
});
}

View file

@ -24,8 +24,6 @@ export async function verifyUser(
): Promise<any> {
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"),

View file

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

View file

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

View file

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