This commit is contained in:
Owen Schwartz 2024-10-26 12:02:34 -04:00
commit c6d4c4db20
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
33 changed files with 458 additions and 366 deletions

2
.gitignore vendored
View file

@ -26,3 +26,5 @@ migrations
package-lock.json
tsconfig.tsbuildinfo
config.yml
dist
.dist

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string> {
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<string> {
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, '-');
}
)
.toLowerCase()
.replace(/\s/g, "-");
}

View file

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

View file

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

View file

@ -8,10 +8,10 @@ export const errorHandlerMiddleware: ErrorRequestHandler = (
error,
req,
res: Response<ErrorResponse>,
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,
});
};

View file

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

View file

@ -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<any> {
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<LoginResponse>(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"
)
);
}
}

View file

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

View file

@ -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<typeof signupBodySchema>;
export type SignUpResponse = {
emailVerificationRequired: boolean;
emailVerificationRequired?: boolean;
};
export async function signup(
req: Request,
res: Response,
next: NextFunction,
next: NextFunction
): Promise<any> {
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<SignUpResponse>(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<SignUpResponse>(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"
)
);
}
}

View file

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

View file

@ -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<schema.Target[]> {
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);
}

View file

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

View file

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

View file

@ -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<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
authCookieHeader(),
cookie
);
} catch {
redirect(`/`);
@ -66,7 +71,7 @@ export default async function ConfigurationLaytout({
try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`,
authCookieHeader(),
cookie
);
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;

View file

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

View file

@ -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<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
authCookieHeader(),
await authCookieHeader(),
);
resource = res.data.data;
} catch {

View file

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

View file

@ -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<ListTargetsResponse["targets"]>([]);
const [nextId, setNextId] = useState(1);
const [ipError, setIpError] = useState("");

View file

@ -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<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
authCookieHeader(),
await authCookieHeader(),
);
resources = res.data.data.resources;
} catch (e) {

View file

@ -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<AxiosResponse<GetSiteResponse>>(
`/org/${params.orgId}/site/${params.niceId}`,
authCookieHeader(),
await authCookieHeader(),
);
site = res.data.data;
} catch {

View file

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

View file

@ -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<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites`,
authCookieHeader(),
await authCookieHeader(),
);
sites = res.data.data.sites;
} catch (e) {

View file

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

View file

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

View file

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

View file

@ -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<AxiosResponse<ListOrgsResponse>>(
// `/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<AxiosResponse<ListOrgsResponse>>(
`/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 (
<html suppressHydrationWarning>

View file

@ -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<AxiosResponse<ListOrgsResponse>>(
`/orgs`,
authCookieHeader(),
await authCookieHeader(),
);
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;

View file

@ -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<GetUserResponse | null> {
const sessionId = cookies().get("session")?.value ?? null;
try {
const res = await internal.get<AxiosResponse<GetUserResponse>>("/user", authCookieHeader());
const res = await internal.get<AxiosResponse<GetUserResponse>>(
"/user",
await authCookieHeader()
);
return res.data.data;
} catch {

View file

@ -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"
]
}
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}