diff --git a/.gitignore b/.gitignore index 7fdae351..69f4f292 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ migrations package-lock.json tsconfig.tsbuildinfo config.yml +dist +.dist \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bc6caf39..cd801929 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,8 @@ RUN npm install --omit=dev COPY --from=builder /app/.next ./.next COPY --from=builder /app/dist ./dist -COPY server/db/names.json /app/dist/names.json + +COPY ./config/config.example.yml ./dist/config.example.yml +COPY ./server/db/names.json ./dist/names.json CMD ["npm", "start"] diff --git a/config/config.example.yml b/config/config.example.yml index b226969a..b989cb29 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,16 +1,20 @@ app: name: Pangolin - environment: dev base_url: http://localhost:3000 - log_level: debug - save_logs: "false" + log_level: warning + save_logs: false server: - external_port: "3000" - internal_port: "3001" - internal_hostname: localhost - secure_cookies: "false" + external_port: 3000 + internal_port: 3001 + internal_hostname: pangolin + secure_cookies: true + +traefik: + cert_resolver: letsencrypt + http_entrypoint: web + https_entrypoint: websecure rate_limit: - window_minutes: "1" - max_requests: "100" + window_minutes: 1 + max_requests: 100 \ No newline at end of file diff --git a/package.json b/package.json index 2f3113e1..7e7c4202 100644 --- a/package.json +++ b/package.json @@ -1,96 +1,100 @@ { - "name": "@fossorial/pangolin", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch server/index.ts", - "db:generate": "drizzle-kit generate", - "db:push": "npx tsx server/db/migrate.ts", - "db:hydrate": "npx tsx scripts/hydrate.ts", - "db:studio": "drizzle-kit studio", - "build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs", - "start": "ENVIRONMENT=prod node dist/server.mjs", - "email": "email dev --dir server/emails/templates --port 3002" - }, - "dependencies": { - "@esbuild-plugins/tsconfig-paths": "0.1.2", - "@hookform/resolvers": "3.9.0", - "@node-rs/argon2": "1.8.3", - "@oslojs/crypto": "1.0.1", - "@oslojs/encoding": "1.1.0", - "@radix-ui/react-avatar": "1.1.1", - "@radix-ui/react-checkbox": "1.1.2", - "@radix-ui/react-dialog": "1.1.2", - "@radix-ui/react-dropdown-menu": "2.1.2", - "@radix-ui/react-icons": "1.3.0", - "@radix-ui/react-label": "2.1.0", - "@radix-ui/react-popover": "1.1.2", - "@radix-ui/react-radio-group": "1.2.1", - "@radix-ui/react-select": "2.1.2", - "@radix-ui/react-separator": "1.1.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-switch": "1.1.1", - "@radix-ui/react-toast": "1.2.2", - "@react-email/components": "0.0.25", - "@react-email/tailwind": "0.1.0", - "@tanstack/react-table": "8.20.5", - "axios": "1.7.7", - "better-sqlite3": "11.3.0", - "class-variance-authority": "0.7.0", - "clsx": "2.1.1", - "cmdk": "1.0.0", - "cookie-parser": "1.4.6", - "cors": "2.8.5", - "drizzle-orm": "0.33.0", - "esbuild": "0.20.1", - "esbuild-node-externals": "1.13.0", - "express": "4.21.0", - "express-rate-limit": "7.4.0", - "glob": "11.0.0", - "helmet": "7.1.0", - "http-errors": "2.0.0", - "input-otp": "1.2.4", - "js-yaml": "4.1.0", - "lucide-react": "0.447.0", - "moment": "2.30.1", - "next": "14.2.13", - "next-themes": "0.3.0", - "node-fetch": "3.3.2", - "nodemailer": "6.9.15", - "oslo": "1.2.1", - "react": "^18", - "react-dom": "^18", - "react-hook-form": "7.53.0", - "rebuild": "0.1.2", - "tailwind-merge": "2.5.3", - "tailwindcss-animate": "1.0.7", - "winston": "3.14.2", - "winston-daily-rotate-file": "5.0.0", - "yargs": "17.7.2", - "zod": "3.23.8", - "zod-validation-error": "3.4.0" - }, - "devDependencies": { - "@dotenvx/dotenvx": "1.14.2", - "@types/better-sqlite3": "7.6.11", - "@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", - "@types/react-dom": "^18", - "@types/yargs": "17.0.33", - "drizzle-kit": "0.24.2", - "eslint": "^8", - "eslint-config-next": "14.2.13", - "postcss": "^8", - "react-email": "3.0.1", - "tailwindcss": "^3.4.1", - "tsc-alias": "1.8.10", - "tsx": "4.19.1", - "typescript": "^5" - } + "name": "@fossorial/pangolin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "ENVIRONMENT=dev tsx watch server/index.ts", + "db:generate": "drizzle-kit generate", + "db:push": "npx tsx server/db/migrate.ts", + "db:hydrate": "npx tsx scripts/hydrate.ts", + "db:studio": "drizzle-kit studio", + "build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs", + "start": "ENVIRONMENT=prod node dist/server.mjs", + "email": "email dev --dir server/emails/templates --port 3002" + }, + "dependencies": { + "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@hookform/resolvers": "3.9.0", + "@node-rs/argon2": "1.8.3", + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@radix-ui/react-avatar": "1.1.1", + "@radix-ui/react-checkbox": "1.1.2", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-dropdown-menu": "2.1.2", + "@radix-ui/react-icons": "1.3.0", + "@radix-ui/react-label": "2.1.0", + "@radix-ui/react-popover": "1.1.2", + "@radix-ui/react-radio-group": "1.2.1", + "@radix-ui/react-select": "2.1.2", + "@radix-ui/react-separator": "1.1.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-switch": "1.1.1", + "@radix-ui/react-toast": "1.2.2", + "@react-email/components": "0.0.25", + "@react-email/tailwind": "0.1.0", + "@tanstack/react-table": "8.20.5", + "axios": "1.7.7", + "better-sqlite3": "11.3.0", + "class-variance-authority": "0.7.0", + "clsx": "2.1.1", + "cmdk": "1.0.0", + "cookie-parser": "1.4.6", + "cors": "2.8.5", + "drizzle-orm": "0.33.0", + "esbuild": "0.20.1", + "esbuild-node-externals": "1.13.0", + "express": "4.21.0", + "express-rate-limit": "7.4.0", + "glob": "11.0.0", + "helmet": "7.1.0", + "http-errors": "2.0.0", + "input-otp": "1.2.4", + "js-yaml": "4.1.0", + "lucide-react": "0.447.0", + "moment": "2.30.1", + "next": "15.0.1", + "next-themes": "0.3.0", + "node-fetch": "3.3.2", + "nodemailer": "6.9.15", + "oslo": "1.2.1", + "react": "19.0.0-rc-69d4b800-20241021", + "react-dom": "19.0.0-rc-69d4b800-20241021", + "react-hook-form": "7.53.0", + "rebuild": "0.1.2", + "tailwind-merge": "2.5.3", + "tailwindcss-animate": "1.0.7", + "winston": "3.14.2", + "winston-daily-rotate-file": "5.0.0", + "yargs": "17.7.2", + "zod": "3.23.8", + "zod-validation-error": "3.4.0" + }, + "devDependencies": { + "@dotenvx/dotenvx": "1.14.2", + "@types/better-sqlite3": "7.6.11", + "@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": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", + "@types/yargs": "17.0.33", + "drizzle-kit": "0.24.2", + "eslint": "^8", + "eslint-config-next": "15.0.1", + "postcss": "^8", + "react-email": "3.0.1", + "tailwindcss": "^3.4.1", + "tsc-alias": "1.8.10", + "tsx": "4.19.1", + "typescript": "^5" + }, + "overrides": { + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" + } } diff --git a/server/config.ts b/server/config.ts index 0543d162..560b5406 100644 --- a/server/config.ts +++ b/server/config.ts @@ -3,58 +3,57 @@ import { fromError } from "zod-validation-error"; import path from "path"; import fs from "fs"; import yaml from "js-yaml"; +import { fileURLToPath } from "url"; +import { signup } from "./routers/auth"; + +export const __FILENAME = fileURLToPath(import.meta.url); +export const __DIRNAME = path.dirname(__FILENAME); export const APP_PATH = path.join("config"); +const portSchema = z.number().positive().gt(0).lte(65535); + const environmentSchema = z.object({ app: z.object({ name: z.string(), - environment: z.enum(["dev", "prod"]), base_url: z.string().url(), - base_domain: z.string(), log_level: z.enum(["debug", "info", "warn", "error"]), - save_logs: z.string().transform((val) => val === "true"), + save_logs: z.boolean(), }), 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()), + external_port: portSchema, + internal_port: portSchema, internal_hostname: z.string(), - secure_cookies: z.string().transform((val) => val === "true"), + secure_cookies: z.boolean(), + signup_secret: z.string().optional(), + }), + traefik: z.object({ + http_entrypoint: z.string(), + https_entrypoint: z.string().optional(), + cert_resolver: z.string().optional(), + prefer_wildcard_cert: z.boolean().optional(), }), 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()), + window_minutes: z.number().positive().gt(0), + max_requests: z.number().positive().gt(0), }), 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_port: portSchema.optional(), smtp_user: z.string().optional(), smtp_pass: z.string().optional(), no_reply: z.string().email().optional(), }) .optional(), + flags: z + .object({ + allow_org_subdomain_changing: z.boolean().optional(), + require_email_verification: z.boolean().optional(), + disable_signup_without_invite: z.boolean().optional(), + require_signup_secret: z.boolean().optional(), + }) + .optional(), }); const loadConfig = (configPath: string) => { @@ -65,7 +64,7 @@ const loadConfig = (configPath: string) => { } catch (error) { if (error instanceof Error) { throw new Error( - `Error loading configuration file: ${error.message}`, + `Error loading configuration file: ${error.message}` ); } throw error; @@ -81,6 +80,30 @@ if (fs.existsSync(configFilePath1)) { } else if (fs.existsSync(configFilePath2)) { environment = loadConfig(configFilePath2); } +if (!environment) { + const exampleConfigPath = path.join(__DIRNAME, "config.example.yml"); + if (fs.existsSync(exampleConfigPath)) { + try { + const exampleConfigContent = fs.readFileSync( + exampleConfigPath, + "utf8" + ); + fs.writeFileSync(configFilePath1, exampleConfigContent, "utf8"); + environment = loadConfig(configFilePath1); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Error creating configuration file from example: ${error.message}` + ); + } + throw error; + } + } else { + throw new Error( + "No configuration file found and no example configuration available" + ); + } +} if (!environment) { throw new Error("No configuration file found"); @@ -95,12 +118,16 @@ if (!parsedConfig.success) { process.env.NEXT_PUBLIC_EXTERNAL_API_BASE_URL = new URL( "/api/v1", - parsedConfig.data.app.base_url, + parsedConfig.data.app.base_url ).href; process.env.NEXT_PUBLIC_INTERNAL_API_BASE_URL = new URL( "/api/v1", - `http://${parsedConfig.data.server.internal_hostname}:${parsedConfig.data.server.external_port}`, + `http://${parsedConfig.data.server.internal_hostname}:${parsedConfig.data.server.external_port}` ).href; process.env.NEXT_PUBLIC_APP_NAME = parsedConfig.data.app.name; +process.env.NEXT_PUBLIC_FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data + .flags?.require_email_verification + ? "true" + : "false"; export default parsedConfig.data; diff --git a/server/db/names.ts b/server/db/names.ts index f5e253bc..cd6d8908 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,27 +1,26 @@ -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; -import { readFileSync } from 'fs'; -import { db } from '@server/db'; -import { sites } from './schema'; -import { eq, and } from 'drizzle-orm'; - -// Get the directory name of the current module -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import { join } from "path"; +import { readFileSync } from "fs"; +import { db } from "@server/db"; +import { sites } from "./schema"; +import { eq, and } from "drizzle-orm"; +import { __DIRNAME } from "@server/config"; // Load the names from the names.json file -const file = join(__dirname, 'names.json'); -export const names = JSON.parse(readFileSync(file, 'utf-8')); +const file = join(__DIRNAME, "names.json"); +export const names = JSON.parse(readFileSync(file, "utf-8")); export async function getUniqueName(orgId: string): Promise { let loops = 0; while (true) { if (loops > 100) { - throw new Error('Could not generate a unique name'); + throw new Error("Could not generate a unique name"); } const name = generateName(); - const count = await db.select({ niceId: sites.niceId, orgId: sites.orgId }).from(sites).where(and(eq(sites.niceId, name), eq(sites.orgId, orgId))); + const count = await db + .select({ niceId: sites.niceId, orgId: sites.orgId }) + .from(sites) + .where(and(eq(sites.niceId, name), eq(sites.orgId, orgId))); if (count.length === 0) { return name; } @@ -31,7 +30,12 @@ export async function getUniqueName(orgId: string): Promise { export function generateName(): string { return ( - names.descriptors[Math.floor(Math.random() * names.descriptors.length)] + "-" + + names.descriptors[ + Math.floor(Math.random() * names.descriptors.length) + ] + + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] - ).toLowerCase().replace(/\s/g, '-'); -} \ No newline at end of file + ) + .toLowerCase() + .replace(/\s/g, "-"); +} diff --git a/server/db/schema.ts b/server/db/schema.ts index 29cda4df..4ddb7f13 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -24,7 +24,8 @@ export const sites = sqliteTable("sites", { }); export const resources = sqliteTable("resources", { - resourceId: text("resourceId", { length: 2048 }).primaryKey(), + resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), + fullDomain: text("fullDomain", { length: 2048 }), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade", }), @@ -45,6 +46,7 @@ export const targets = sqliteTable("targets", { port: integer("port").notNull(), protocol: text("protocol"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), }); export const exitNodes = sqliteTable("exitNodes", { diff --git a/server/index.ts b/server/index.ts index ec8f97f6..9bfd1ccc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,7 +16,7 @@ import cookieParser from "cookie-parser"; import { User } from "@server/db/schema"; import { ensureActions } from "./db/ensureActions"; -const dev = config.app.environment !== "prod"; +const dev = process.env.ENVIRONMENT !== "prod"; const app = next({ dev }); const handle = app.getRequestHandler(); @@ -39,8 +39,8 @@ app.prepare().then(() => { if (!dev) { externalServer.use( rateLimitMiddleware({ - windowMin: 1, - max: 100, + windowMin: config.rate_limit.window_minutes, + max: config.rate_limit.max_requests, type: "IP_ONLY", }), ); @@ -88,7 +88,7 @@ app.prepare().then(() => { internalServer.use(errorHandlerMiddleware); }); -declare global { +declare global { // TODO: eventually make seperate types that extend express.Request namespace Express { interface Request { user?: User; @@ -97,4 +97,4 @@ declare global { userOrgIds?: string[]; } } -} +} \ No newline at end of file diff --git a/server/middlewares/formatError.ts b/server/middlewares/formatError.ts index e6b9454f..0f62520d 100644 --- a/server/middlewares/formatError.ts +++ b/server/middlewares/formatError.ts @@ -8,10 +8,10 @@ export const errorHandlerMiddleware: ErrorRequestHandler = ( error, req, res: Response, - next: NextFunction, + next: NextFunction ) => { const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR; - if (config.app.environment !== "prod") { + if (process.env.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: config.app.environment === "prod" ? null : error.stack, + stack: process.env.ENVIRONMENT === "prod" ? null : error.stack, }); }; diff --git a/server/middlewares/verifyUser.ts b/server/middlewares/verifyUser.ts index 3b2b8963..8657b779 100644 --- a/server/middlewares/verifyUser.ts +++ b/server/middlewares/verifyUser.ts @@ -6,11 +6,12 @@ import { users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import config from "@server/config"; export const verifySessionUserMiddleware = async ( req: any, res: Response, - next: NextFunction, + next: NextFunction ) => { const { session, user } = await verifySession(req); if (!session || !user) { @@ -24,16 +25,19 @@ export const verifySessionUserMiddleware = async ( if (!existingUser || !existingUser[0]) { return next( - createHttpError(HttpCode.BAD_REQUEST, "User does not exist"), + createHttpError(HttpCode.BAD_REQUEST, "User does not exist") ); } req.user = existingUser[0]; req.session = session; - if (!existingUser[0].emailVerified) { + if ( + !existingUser[0].emailVerified && + config.flags?.require_email_verification + ) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Email is not verified"), // Might need to change the response type? + createHttpError(HttpCode.BAD_REQUEST, "Email is not verified") // Might need to change the response type? ); } diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index a6fb1937..b1793b18 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -15,6 +15,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { verifyTotpCode } from "@server/auth/2fa"; +import config from "@server/config"; export const loginBodySchema = z.object({ email: z.string().email(), @@ -32,7 +33,7 @@ export type LoginResponse = { export async function login( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = loginBodySchema.safeParse(req.body); @@ -40,8 +41,8 @@ export async function login( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -67,8 +68,8 @@ export async function login( return next( createHttpError( HttpCode.BAD_REQUEST, - "Username or password is incorrect", - ), + "Username or password is incorrect" + ) ); } @@ -82,14 +83,14 @@ export async function login( timeCost: 2, outputLen: 32, parallelism: 1, - }, + } ); if (!validPassword) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Username or password is incorrect", - ), + "Username or password is incorrect" + ) ); } @@ -107,15 +108,15 @@ export async function login( const validOTP = await verifyTotpCode( code, existingUser.twoFactorSecret!, - existingUser.userId, + existingUser.userId ); if (!validOTP) { return next( createHttpError( HttpCode.BAD_REQUEST, - "The two-factor code you entered is incorrect", - ), + "The two-factor code you entered is incorrect" + ) ); } } @@ -126,7 +127,10 @@ export async function login( res.appendHeader("Set-Cookie", cookie); - if (!existingUser.emailVerified) { + if ( + !existingUser.emailVerified && + config.flags?.require_email_verification + ) { return response(res, { data: { emailVerificationRequired: true }, success: true, @@ -147,8 +151,8 @@ export async function login( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to authenticate user", - ), + "Failed to authenticate user" + ) ); } } diff --git a/server/routers/auth/requestEmailVerificationCode.ts b/server/routers/auth/requestEmailVerificationCode.ts index 00329b04..7aab3c72 100644 --- a/server/routers/auth/requestEmailVerificationCode.ts +++ b/server/routers/auth/requestEmailVerificationCode.ts @@ -4,6 +4,7 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/utils"; import { User } from "@server/db/schema"; import { sendEmailVerificationCode } from "./sendEmailVerificationCode"; +import config from "@server/config"; export type RequestEmailVerificationCodeResponse = { codeSent: boolean; @@ -12,8 +13,17 @@ export type RequestEmailVerificationCodeResponse = { export async function requestEmailVerificationCode( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { + if (!config.flags?.require_email_verification) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email verification is not enabled" + ) + ); + } + try { const user = req.user as User; @@ -21,8 +31,8 @@ export async function requestEmailVerificationCode( return next( createHttpError( HttpCode.BAD_REQUEST, - "Email is already verified", - ), + "Email is already verified" + ) ); } @@ -41,8 +51,8 @@ export async function requestEmailVerificationCode( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to send email verification code", - ), + "Failed to send email verification code" + ) ); } } diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index c0639c05..536d9c17 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -19,6 +19,7 @@ import { serializeSessionCookie, } from "@server/auth"; import { ActionsEnum } from "@server/auth/actions"; +import config from "@server/config"; export const signupBodySchema = z.object({ email: z.string().email(), @@ -28,13 +29,13 @@ export const signupBodySchema = z.object({ export type SignUpBody = z.infer; export type SignUpResponse = { - emailVerificationRequired: boolean; + emailVerificationRequired?: boolean; }; export async function signup( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = signupBodySchema.safeParse(req.body); @@ -42,8 +43,8 @@ export async function signup( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -64,6 +65,15 @@ export async function signup( .where(eq(users.email, email)); if (existing && existing.length > 0) { + if (!config.flags?.require_email_verification) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A user with that email address already exists" + ) + ); + } + const user = existing[0]; // If the user is already verified, we don't want to create a new user @@ -71,8 +81,8 @@ export async function signup( return next( createHttpError( HttpCode.BAD_REQUEST, - "A user with that email address already exists", - ), + "A user with that email address already exists" + ) ); } @@ -85,8 +95,8 @@ export async function signup( return next( createHttpError( HttpCode.BAD_REQUEST, - "A verification email was already sent to this email address. Please check your email for the verification code.", - ), + "A verification email was already sent to this email address. Please check your email for the verification code." + ) ); } else { // If the user was created more than 2 hours ago, we want to delete the old user and create a new one @@ -101,7 +111,7 @@ export async function signup( dateCreated: moment().toISOString(), }); - // give the user their default permissions: + // give the user their default permissions: // await db.insert(userActions).values({ // userId: userId, // actionId: ActionsEnum.createOrg, @@ -113,15 +123,25 @@ export async function signup( const cookie = serializeSessionCookie(token); res.appendHeader("Set-Cookie", cookie); - sendEmailVerificationCode(email, userId); + if (config.flags?.require_email_verification) { + sendEmailVerificationCode(email, userId); + + return response(res, { + data: { + emailVerificationRequired: true, + }, + success: true, + error: false, + message: `User created successfully. We sent an email to ${email} with a verification code.`, + status: HttpCode.OK, + }); + } return response(res, { - data: { - emailVerificationRequired: true, - }, + data: {}, success: true, error: false, - message: `User created successfully. We sent an email to ${email} with a verification code.`, + message: "User created successfully", status: HttpCode.OK, }); } catch (e) { @@ -129,15 +149,15 @@ export async function signup( return next( createHttpError( HttpCode.BAD_REQUEST, - "A user with that email address already exists", - ), + "A user with that email address already exists" + ) ); } else { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create user", - ), + "Failed to create user" + ) ); } } diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index 38ba13e4..6df3d489 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -8,6 +8,7 @@ import { db } from "@server/db"; import { User, emailVerificationCodes, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; +import config from "@server/config"; export const verifyEmailBody = z.object({ code: z.string(), @@ -22,16 +23,25 @@ export type VerifyEmailResponse = { export async function verifyEmail( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { + if (!config.flags?.require_email_verification) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email verification is not enabled" + ) + ); + } + const parsedBody = verifyEmailBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -41,7 +51,7 @@ export async function verifyEmail( if (user.emailVerified) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Email is already verified"), + createHttpError(HttpCode.BAD_REQUEST, "Email is already verified") ); } @@ -63,8 +73,8 @@ export async function verifyEmail( return next( createHttpError( HttpCode.BAD_REQUEST, - "Invalid verification code", - ), + "Invalid verification code" + ) ); } @@ -81,8 +91,8 @@ export async function verifyEmail( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to verify email", - ), + "Failed to verify email" + ) ); } } diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 44dea0e5..3940c5f1 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -2,10 +2,9 @@ import { Request, Response } from "express"; import db from "@server/db"; import * as schema from "@server/db/schema"; import { DynamicTraefikConfig } from "./configSchema"; -import { and, like, eq } from "drizzle-orm"; +import { and, like, eq, isNotNull } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; -import env from "@server/config"; import config from "@server/config"; export async function traefikConfigProvider(_: Request, res: Response) { @@ -22,46 +21,33 @@ export async function traefikConfigProvider(_: Request, res: Response) { } export function buildTraefikConfig( - targets: schema.Target[], + targets: schema.Target[] ): DynamicTraefikConfig { + if (!targets.length) { + return { http: {} } as DynamicTraefikConfig; + } + const middlewareName = "badger"; + const baseDomain = new URL(config.app.base_url).hostname; + + const tls = { + certResolver: config.traefik.cert_resolver, + ...(config.traefik.prefer_wildcard_cert + ? { domains: [baseDomain, `*.${baseDomain}`] } + : {}), + }; + const http: any = { - routers: { - main: { - entryPoints: ["https"], - middlewares: [], - service: "service-main", - rule: "Host(`fossorial.io`)", - tls: { - certResolver: "letsencrypt", - domains: [ - { - main: "fossorial.io", - sans: ["*.fossorial.io"], - }, - ], - }, - }, - }, - services: { - "service-main": { - loadBalancer: { - servers: [ - { - url: `http://${config.server.internal_hostname}:${config.server.external_port}`, - }, - ], - }, - }, - }, + routers: {}, + services: {}, middlewares: { [middlewareName]: { plugin: { [middlewareName]: { apiBaseUrl: new URL( "/api/v1", - `http://${config.server.internal_hostname}:${config.server.internal_port}`, + `http://${config.server.internal_hostname}:${config.server.internal_port}` ).href, appBaseUrl: config.app.base_url, }, @@ -70,23 +56,19 @@ export function buildTraefikConfig( }, }; for (const target of targets) { - const routerName = `router-${target.targetId}`; - const serviceName = `service-${target.targetId}`; + const routerName = `${target.targetId}-router`; + const serviceName = `${target.targetId}-service`; http.routers![routerName] = { - entryPoints: ["https"], + entryPoints: [ + target.ssl + ? config.traefik.https_entrypoint + : config.traefik.http_entrypoint, + ], middlewares: [middlewareName], service: serviceName, rule: `Host(\`${target.resourceId}\`)`, // assuming resourceId is a valid full hostname - tls: { - certResolver: "letsencrypt", - domains: [ - { - main: "fossorial.io", - sans: ["*.fossorial.io"], - }, - ], - }, + ...(target.ssl ? { tls } : {}), }; http.services![serviceName] = { @@ -105,11 +87,15 @@ export async function getAllTargets(): Promise { const all = await db .select() .from(schema.targets) + .innerJoin( + schema.resources, + eq(schema.targets.resourceId, schema.resources.resourceId) + ) .where( and( eq(schema.targets.enabled, true), - like(schema.targets.resourceId, "%.%"), - ), - ); // any resourceId with a dot is a valid hostname; otherwise it's a UUID placeholder - return all; + isNotNull(schema.resources.fullDomain) + ) + ); + return all.map((row) => row.targets); } diff --git a/src/api/cookies.ts b/src/api/cookies.ts index 2986437d..9b1eb9aa 100644 --- a/src/api/cookies.ts +++ b/src/api/cookies.ts @@ -1,7 +1,8 @@ import { cookies } from "next/headers"; -export function authCookieHeader() { - const sessionId = cookies().get("session")?.value ?? null; +export async function authCookieHeader() { + const allCookies = await cookies(); + const sessionId = allCookies.get("session")?.value ?? null; return { headers: { Cookie: `session=${sessionId}` diff --git a/src/api/index.ts b/src/api/index.ts index e45e8abf..792778f6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,7 @@ import axios from "axios"; export const api = axios.create({ - baseURL: "https://fossorial.io/api/v1", + baseURL: process.env.NEXT_PUBLIC_EXTERNAL_API_BASE_URL, timeout: 10000, headers: { "Content-Type": "application/json", @@ -9,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/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 53919214..77d928dc 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -40,23 +40,28 @@ const topNavItems = [ interface ConfigurationLaytoutProps { children: React.ReactNode; - params: { orgId: string }; + params: Promise<{ orgId: string }>; } -export default async function ConfigurationLaytout({ - children, - params, -}: ConfigurationLaytoutProps) { +export default async function ConfigurationLaytout( + props: ConfigurationLaytoutProps +) { + const params = await props.params; + + const { children } = props; + const user = await verifySession(); if (!user) { redirect("/auth/login"); } + const cookie = await authCookieHeader(); + try { await internal.get>( `/org/${params.orgId}`, - authCookieHeader(), + cookie ); } catch { redirect(`/`); @@ -66,7 +71,7 @@ export default async function ConfigurationLaytout({ try { const res = await internal.get>( `/orgs`, - authCookieHeader(), + cookie ); if (res && res.data.data.orgs) { orgs = res.data.data.orgs; diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index bd3ed399..414de54b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,10 +1,11 @@ import { redirect } from "next/navigation"; type OrgPageProps = { - params: { orgId: string }; + params: Promise<{ orgId: string }>; }; -export default async function Page({ params }: OrgPageProps) { +export default async function Page(props: OrgPageProps) { + const params = await props.params; redirect(`/${params.orgId}/sites`); return <>; diff --git a/src/app/[orgId]/resources/[resourceId]/layout.tsx b/src/app/[orgId]/resources/[resourceId]/layout.tsx index 6ba0c6d3..2d29769c 100644 --- a/src/app/[orgId]/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/resources/[resourceId]/layout.tsx @@ -22,20 +22,23 @@ export const metadata: Metadata = { interface SettingsLayoutProps { children: React.ReactNode; - params: { resourceId: string; orgId: string }; + params: Promise<{ resourceId: string; orgId: string }>; } -export default async function SettingsLayout({ - children, - params, -}: SettingsLayoutProps) { +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { + children + } = props; + let resource = null; if (params.resourceId !== "create") { try { const res = await internal.get>( `/resource/${params.resourceId}`, - authCookieHeader(), + await authCookieHeader(), ); resource = res.data.data; } catch { diff --git a/src/app/[orgId]/resources/[resourceId]/page.tsx b/src/app/[orgId]/resources/[resourceId]/page.tsx index 4f679be5..aa0720d9 100644 --- a/src/app/[orgId]/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/resources/[resourceId]/page.tsx @@ -3,11 +3,12 @@ import { Separator } from "@/components/ui/separator"; import { CreateResourceForm } from "./components/CreateResource"; import { GeneralForm } from "./components/GeneralForm"; -export default function SettingsPage({ - params, -}: { - params: { resourceId: string }; -}) { +export default async function SettingsPage( + props: { + params: Promise<{ resourceId: string }>; + } +) { + const params = await props.params; const isCreate = params.resourceId === "create"; return ( diff --git a/src/app/[orgId]/resources/[resourceId]/targets/page.tsx b/src/app/[orgId]/resources/[resourceId]/targets/page.tsx index 23021c38..a1e8415a 100644 --- a/src/app/[orgId]/resources/[resourceId]/targets/page.tsx +++ b/src/app/[orgId]/resources/[resourceId]/targets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, use } from "react"; import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -25,11 +25,12 @@ const isValidIPAddress = (ip: string) => { return ipv4Regex.test(ip); }; -export default function ReverseProxyTargets({ - params, -}: { - params: { resourceId: string }; -}) { +export default function ReverseProxyTargets( + props: { + params: Promise<{ resourceId: string }>; + } +) { + const params = use(props.params); const [targets, setTargets] = useState([]); const [nextId, setNextId] = useState(1); const [ipError, setIpError] = useState(""); diff --git a/src/app/[orgId]/resources/page.tsx b/src/app/[orgId]/resources/page.tsx index 80cf9ba1..e50804ec 100644 --- a/src/app/[orgId]/resources/page.tsx +++ b/src/app/[orgId]/resources/page.tsx @@ -5,15 +5,16 @@ import { AxiosResponse } from "axios"; import { ListResourcesResponse } from "@server/routers/resource"; type ResourcesPageProps = { - params: { orgId: string }; + params: Promise<{ orgId: string }>; }; -export default async function Page({ params }: ResourcesPageProps) { +export default async function Page(props: ResourcesPageProps) { + const params = await props.params; let resources: ListResourcesResponse["resources"] = []; try { const res = await internal.get>( `/org/${params.orgId}/resources`, - authCookieHeader(), + await authCookieHeader(), ); resources = res.data.data.resources; } catch (e) { diff --git a/src/app/[orgId]/sites/[niceId]/layout.tsx b/src/app/[orgId]/sites/[niceId]/layout.tsx index 77db6a48..670b05b4 100644 --- a/src/app/[orgId]/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/sites/[niceId]/layout.tsx @@ -22,20 +22,23 @@ import { ClientLayout } from "./components/ClientLayout"; interface SettingsLayoutProps { children: React.ReactNode; - params: { niceId: string; orgId: string }; + params: Promise<{ niceId: string; orgId: string }>; } -export default async function SettingsLayout({ - children, - params, -}: SettingsLayoutProps) { +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { + children + } = props; + let site = null; if (params.niceId !== "create") { try { const res = await internal.get>( `/org/${params.orgId}/site/${params.niceId}`, - authCookieHeader(), + await authCookieHeader(), ); site = res.data.data; } catch { diff --git a/src/app/[orgId]/sites/[niceId]/page.tsx b/src/app/[orgId]/sites/[niceId]/page.tsx index cc89a287..84154f74 100644 --- a/src/app/[orgId]/sites/[niceId]/page.tsx +++ b/src/app/[orgId]/sites/[niceId]/page.tsx @@ -3,11 +3,12 @@ import { Separator } from "@/components/ui/separator"; import { CreateSiteForm } from "./components/CreateSite"; import { GeneralForm } from "./components/GeneralForm"; -export default function SettingsPage({ - params, -}: { - params: { niceId: string }; -}) { +export default async function SettingsPage( + props: { + params: Promise<{ niceId: string }>; + } +) { + const params = await props.params; const isCreate = params.niceId === "create"; return ( diff --git a/src/app/[orgId]/sites/page.tsx b/src/app/[orgId]/sites/page.tsx index 5029799e..8ba61034 100644 --- a/src/app/[orgId]/sites/page.tsx +++ b/src/app/[orgId]/sites/page.tsx @@ -5,15 +5,16 @@ import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "./components/SitesTable"; type SitesPageProps = { - params: { orgId: string }; + params: Promise<{ orgId: string }>; }; -export default async function Page({ params }: SitesPageProps) { +export default async function Page(props: SitesPageProps) { + const params = await props.params; let sites: ListSitesResponse["sites"] = []; try { const res = await internal.get>( `/org/${params.orgId}/sites`, - authCookieHeader(), + await authCookieHeader(), ); sites = res.data.data.sites; } catch (e) { diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 7153a7fc..48a3fd4d 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -3,11 +3,12 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; -export default async function Page({ - searchParams, -}: { - searchParams: { [key: string]: string | string[] | undefined }; -}) { +export default async function Page( + props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const searchParams = await props.searchParams; const user = await verifySession(); if (user) { diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 90c4dc09..54509286 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -3,11 +3,12 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; -export default async function Page({ - searchParams, -}: { - searchParams: { [key: string]: string | string[] | undefined }; -}) { +export default async function Page( + props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const searchParams = await props.searchParams; const user = await verifySession(); if (user) { diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index dd62d5de..9f5cfa38 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -2,11 +2,14 @@ import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; -export default async function Page({ - searchParams, -}: { - searchParams: { [key: string]: string | string[] | undefined }; +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { + if (process.env.NEXT_PUBLIC_FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") { + redirect("/"); + } + + const searchParams = await props.searchParams; const user = await verifySession(); if (!user) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 030c4c7b..8a27fbcd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { internal } from "@app/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/api/cookies"; import { redirect } from "next/navigation"; +import { verifySession } from "@app/lib/auth/verifySession"; export const metadata: Metadata = { title: `Dashboard - ${process.env.NEXT_PUBLIC_APP_NAME}`, @@ -21,22 +22,26 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - // let orgs: ListOrgsResponse["orgs"] = []; - // try { - // const res = await internal.get>( - // `/orgs`, - // authCookieHeader(), - // ); - // if (res && res.data.data.orgs) { - // orgs = res.data.data.orgs; - // } + const user = await verifySession(); - // if (!orgs.length) { - // redirect(`/setup`); - // } - // } catch (e) { - // console.error("Error fetching orgs", e); - // } + let orgs: ListOrgsResponse["orgs"] = []; + if (user) { + try { + const res = await internal.get>( + `/orgs`, + await authCookieHeader(), + ); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + + if (!orgs.length) { + redirect(`/setup`); + } + } catch (e) { + console.error("Error fetching orgs", e); + } + } return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 36782e4c..297f5de3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,13 +13,14 @@ export default async function Page() { if (!user) { redirect("/auth/login"); + return; } let orgs: ListOrgsResponse["orgs"] = []; try { const res = await internal.get>( `/orgs`, - authCookieHeader(), + await authCookieHeader(), ); if (res && res.data.data.orgs) { orgs = res.data.data.orgs; diff --git a/src/lib/auth/verifySession.ts b/src/lib/auth/verifySession.ts index 6fd454a3..5b488fa3 100644 --- a/src/lib/auth/verifySession.ts +++ b/src/lib/auth/verifySession.ts @@ -2,13 +2,13 @@ import { internal } from "@app/api"; import { authCookieHeader } from "@app/api/cookies"; import { GetUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import { cookies } from "next/headers"; export async function verifySession(): Promise { - const sessionId = cookies().get("session")?.value ?? null; - try { - const res = await internal.get>("/user", authCookieHeader()); + const res = await internal.get>( + "/user", + await authCookieHeader() + ); return res.data.data; } catch { diff --git a/tsconfig.json b/tsconfig.json index 7c1798e8..d92e7275 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,6 @@ { "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,29 +14,17 @@ "incremental": true, "baseUrl": "src", "paths": { - "@server/*": [ - "../server/*" - ], - "@app/*": [ - "*" - ], - "@/*": [ - "./*" - ] + "@server/*": ["../server/*"], + "@app/*": ["*"], + "@/*": ["./*"] }, "plugins": [ { "name": "next" } - ] + ], + "target": "ES2017" }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}