organized routes and routes and added rate limiter

This commit is contained in:
Milo Schwartz
2024-10-02 00:04:40 -04:00
parent f1e77dfe42
commit 1a91dbb89c
45 changed files with 241 additions and 181 deletions

View File

@@ -0,0 +1,2 @@
export * from "./login";
export * from "./signup";

View File

@@ -0,0 +1,97 @@
import { verify } from "@node-rs/argon2";
import lucia from "@server/auth";
import db from "@server/db";
import { users } from "@server/db/schema";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
export const loginBodySchema = z.object({
email: z.string().email(),
password: z.string(),
});
export async function login(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedBody = loginBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { email, password } = parsedBody.data;
const sessionId = req.cookies[lucia.sessionCookieName];
const { session: existingSession } = await lucia.validateSession(sessionId);
if (existingSession) {
return res.status(HttpCode.OK).send(
response<null>({
data: null,
success: true,
error: false,
message: "Already logged in",
status: HttpCode.OK,
}),
);
}
const existingUserRes = await db
.select()
.from(users)
.where(eq(users.email, email));
if (!existingUserRes || !existingUserRes.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address does not exist",
),
);
}
const existingUser = existingUserRes[0];
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 500)); // delay to prevent brute force attacks
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The password you entered is incorrect",
),
);
}
const session = await lucia.createSession(existingUser.id, {});
res.appendHeader(
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
return res.status(HttpCode.OK).send(
response<null>({
data: null,
success: true,
error: false,
message: "Logged in successfully",
status: HttpCode.OK,
}),
);
}

View File

@@ -0,0 +1,93 @@
import { NextFunction, Request, Response } from "express";
import db from "@server/db";
import { hash } from "@node-rs/argon2";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { generateId } from "lucia";
import { users } from "@server/db/schema";
import lucia from "@server/auth";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import response from "@server/utils/response";
import { SqliteError } from "better-sqlite3";
export const signupBodySchema = z.object({
email: z.string().email(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(31, { message: "Password must be at most 31 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
- At least one uppercase English letter.
- At least one lowercase English letter.
- At least one digit.
- At least one special character.`,
}),
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
export async function signup(req: Request, res: Response, next: NextFunction): Promise<any> {
const parsedBody = signupBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { email, password } = parsedBody.data;
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const userId = generateId(15);
try {
await db.insert(users).values({
id: userId,
email: email,
passwordHash,
});
const session = await lucia.createSession(userId, {});
res.appendHeader(
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
return res.status(HttpCode.OK).send(
response<null>({
data: null,
success: true,
error: false,
message: "User created successfully",
status: HttpCode.OK,
}),
);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists",
),
);
} else {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create user",
),
);
}
}
}

View File

@@ -1,9 +0,0 @@
import { Router } from "express";
const badger = Router();
badger.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
});
export default badger;

View File

@@ -1,17 +1,48 @@
import { Router } from "express";
import gerbil from "./gerbil/gerbil";
import pangolin from "./pangolin/pangolin";
import global from "./global/global";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as target from "./target";
import * as user from "./user";
import * as auth from "./auth";
import HttpCode from "@server/types/HttpCode";
const unauth = Router();
// Root routes
export const unauthenticated = Router();
unauth.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
unauthenticated.get("/", (_, res) => {
res.status(HttpCode.OK).json({ message: "Healthy" });
});
unauth.use("/newt", gerbil);
unauth.use("/pangolin", pangolin);
// Authenticated Root routes
export const authenticated = Router();
unauth.use("/", global)
authenticated.put("/site", site.createSite);
authenticated.get("/site/:siteId", site.getSite);
authenticated.post("/site/:siteId", site.updateSite);
authenticated.delete("/site/:siteId", site.deleteSite);
export default unauth;
authenticated.put("/org", org.createOrg);
authenticated.get("/org/:orgId", org.getOrg);
authenticated.post("/org/:orgId", org.updateOrg);
authenticated.delete("/org/:orgId", org.deleteOrg);
authenticated.put("/resource", resource.createResource);
authenticated.get("/resource/:resourceId", resource.getResource);
authenticated.post("/resource/:resourceId", resource.updateResource);
authenticated.delete("/resource/:resourceId", resource.deleteResource);
authenticated.put("/target", target.createTarget);
authenticated.get("/target/:targetId", target.getTarget);
authenticated.post("/target/:targetId", target.updateTarget);
authenticated.delete("/target/:targetId", target.deleteTarget);
authenticated.get("/user/:userId", user.getUser);
authenticated.delete("/user/:userId", user.deleteUser);
// Auth routes
const authRouter = Router();
unauthenticated.use("/auth", authRouter);
authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login);

View File

@@ -1,14 +0,0 @@
import { Router } from "express";
import { getConfig } from "./getConfig";
import { receiveBandwidth } from "./receiveBandwidth";
const gerbil = Router();
gerbil.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
});
gerbil.get("/get-config", getConfig);
gerbil.post("/receive-bandwidth", receiveBandwidth);
export default gerbil;

View File

@@ -0,0 +1,2 @@
export * from "./getConfig";
export * from "./receiveBandwidth";

View File

@@ -1,58 +0,0 @@
import { Router } from "express";
import { signup } from "@server/auth/signup";
import { login } from "@server/auth/login";
import { getSite } from "./site/getSite";
import { createSite } from "./site/createSite";
import { updateSite } from "./site/updateSite";
import { deleteSite } from "./site/deleteSite";
import { getOrg } from "./org/getOrg";
import { createOrg } from "./org/createOrg";
import { updateOrg } from "./org/updateOrg";
import { deleteOrg } from "./org/deleteOrg";
import { getResource } from "./resource/getResource";
import { createResource } from "./resource/createResource";
import { updateResource } from "./resource/updateResource";
import { deleteResource } from "./resource/deleteResource";
import { getTarget } from "./target/getTarget";
import { createTarget } from "./target/createTarget";
import { updateTarget } from "./target/updateTarget";
import { deleteTarget } from "./target/deleteTarget";
import { getUser } from "./user/getUser";
import { createUser } from "./user/createUser";
import { updateUser } from "./user/updateUser";
import { deleteUser } from "./user/deleteUser";
const global = Router();
global.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
});
global.put("/site", createSite);
global.get("/site/:siteId", getSite);
global.post("/site/:siteId", updateSite);
global.delete("/site/:siteId", deleteSite);
global.put("/org", createOrg);
global.get("/org/:orgId", getOrg);
global.post("/org/:orgId", updateOrg);
global.delete("/org/:orgId", deleteOrg);
global.put("/resource", createResource);
global.get("/resource/resourceId", getResource);
global.post("/resource/resourceId", updateResource);
global.delete("/resource/resourceId", deleteResource);
global.put("/target", createTarget);
global.get("/target/:targetId", getTarget);
global.post("/target/:targetId", updateTarget);
global.delete("/target/:targetId", deleteTarget);
global.get("/user/:userId", getUser);
global.delete("/user/:userId", deleteUser);
// auth
global.post("/signup", signup);
global.post("/login", login);
export default global;

View File

@@ -1,17 +1,23 @@
import { Router } from "express";
import gerbil from "./gerbil/gerbil";
import badger from "./badger/badger";
import { traefikConfigProvider } from "@server/traefik-config-provider";
import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik";
import HttpCode from "@server/types/HttpCode";
const unauth = Router();
// Root routes
const internalRouter = Router();
unauth.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
internalRouter.get("/", (_, res) => {
res.status(HttpCode.OK).json({ message: "Healthy" });
});
unauth.use("/badger", badger);
unauth.use("/gerbil", gerbil);
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
unauth.get("/traefik-config-provider", traefikConfigProvider);
// Gerbil routes
const gerbilRouter = Router();
export default unauth;
gerbilRouter.get("/get-config", gerbil.getConfig);
gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
internalRouter.use("/gerbil", gerbilRouter);
export default internalRouter;

View File

@@ -1,9 +0,0 @@
import { Router } from "express";
const newt = Router();
newt.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
});
export default newt;

View File

@@ -11,7 +11,7 @@ const createOrgSchema = z.object({
domain: z.string().min(1).max(255),
});
export async function createOrg(req: Request, res: Response, next: NextFunction) {
export async function createOrg(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedBody = createOrgSchema.safeParse(req.body);
if (!parsedBody.success) {
@@ -42,4 +42,4 @@ export async function createOrg(req: Request, res: Response, next: NextFunction)
} catch (error) {
next(error);
}
}
}

View File

@@ -11,7 +11,7 @@ const deleteOrgSchema = z.object({
orgId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function deleteOrg(req: Request, res: Response, next: NextFunction) {
export async function deleteOrg(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = deleteOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -50,4 +50,4 @@ export async function deleteOrg(req: Request, res: Response, next: NextFunction)
} catch (error) {
next(error);
}
}
}

View File

@@ -11,7 +11,7 @@ const getOrgSchema = z.object({
orgId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function getOrg(req: Request, res: Response, next: NextFunction) {
export async function getOrg(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = getOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -51,4 +51,4 @@ export async function getOrg(req: Request, res: Response, next: NextFunction) {
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from "./getOrg";
export * from "./createOrg";
export * from "./deleteOrg";
export * from "./updateOrg";

View File

@@ -18,7 +18,7 @@ const updateOrgBodySchema = z.object({
message: "At least one field must be provided for update"
});
export async function updateOrg(req: Request, res: Response, next: NextFunction) {
export async function updateOrg(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = updateOrgParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -69,4 +69,4 @@ export async function updateOrg(req: Request, res: Response, next: NextFunction)
} catch (error) {
next(error);
}
}
}

View File

@@ -1,9 +0,0 @@
import { Router } from "express";
const pangolin = Router();
pangolin.get("/", (_, res) => {
res.status(200).json({ message: "Healthy" });
});
export default pangolin;

View File

@@ -13,7 +13,7 @@ const createResourceSchema = z.object({
subdomain: z.string().min(1).max(255).optional(),
});
export async function createResource(req: Request, res: Response, next: NextFunction) {
export async function createResource(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request body
const parsedBody = createResourceSchema.safeParse(req.body);
@@ -51,4 +51,4 @@ export async function createResource(req: Request, res: Response, next: NextFunc
} catch (error) {
next(error);
}
}
}

View File

@@ -12,7 +12,7 @@ const deleteResourceSchema = z.object({
resourceId: z.string().uuid()
});
export async function deleteResource(req: Request, res: Response, next: NextFunction) {
export async function deleteResource(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = deleteResourceSchema.safeParse(req.params);
@@ -53,4 +53,4 @@ export async function deleteResource(req: Request, res: Response, next: NextFunc
} catch (error) {
next(error);
}
}
}

View File

@@ -12,7 +12,7 @@ const getResourceSchema = z.object({
resourceId: z.string().uuid()
});
export async function getResource(req: Request, res: Response, next: NextFunction) {
export async function getResource(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = getResourceSchema.safeParse(req.params);
@@ -54,4 +54,4 @@ export async function getResource(req: Request, res: Response, next: NextFunctio
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from "./getResource";
export * from "./createResource";
export * from "./deleteResource";
export * from "./updateResource";

View File

@@ -20,7 +20,7 @@ const updateResourceBodySchema = z.object({
message: "At least one field must be provided for update"
});
export async function updateResource(req: Request, res: Response, next: NextFunction) {
export async function updateResource(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = updateResourceParamsSchema.safeParse(req.params);
@@ -74,4 +74,4 @@ export async function updateResource(req: Request, res: Response, next: NextFunc
} catch (error) {
next(error);
}
}
}

View File

@@ -4,7 +4,7 @@ import HttpCode from '@server/types/HttpCode';
// define zod type here
export async function createSite(req: Request, res: Response, next: NextFunction) {
export async function createSite(req: Request, res: Response, next: NextFunction): Promise<any> {
return res.status(HttpCode.OK).send(
response<null>({
data: null,

View File

@@ -12,7 +12,7 @@ const deleteSiteSchema = z.object({
siteId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function deleteSite(req: Request, res: Response, next: NextFunction) {
export async function deleteSite(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = deleteSiteSchema.safeParse(req.params);
@@ -53,4 +53,4 @@ export async function deleteSite(req: Request, res: Response, next: NextFunction
} catch (error) {
next(error);
}
}
}

View File

@@ -12,7 +12,7 @@ const getSiteSchema = z.object({
siteId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function getSite(req: Request, res: Response, next: NextFunction) {
export async function getSite(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = getSiteSchema.safeParse(req.params);
@@ -54,4 +54,4 @@ export async function getSite(req: Request, res: Response, next: NextFunction) {
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from "./getSite";
export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";

View File

@@ -25,7 +25,7 @@ const updateSiteBodySchema = z.object({
message: "At least one field must be provided for update"
});
export async function updateSite(req: Request, res: Response, next: NextFunction) {
export async function updateSite(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
// Validate request parameters
const parsedParams = updateSiteParamsSchema.safeParse(req.params);
@@ -79,4 +79,4 @@ export async function updateSite(req: Request, res: Response, next: NextFunction
} catch (error) {
next(error);
}
}
}

View File

@@ -15,7 +15,7 @@ const createTargetSchema = z.object({
enabled: z.boolean().default(true),
});
export async function createTarget(req: Request, res: Response, next: NextFunction) {
export async function createTarget(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedBody = createTargetSchema.safeParse(req.body);
if (!parsedBody.success) {
@@ -43,4 +43,4 @@ export async function createTarget(req: Request, res: Response, next: NextFuncti
} catch (error) {
next(error);
}
}
}

View File

@@ -11,7 +11,7 @@ const deleteTargetSchema = z.object({
targetId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function deleteTarget(req: Request, res: Response, next: NextFunction) {
export async function deleteTarget(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = deleteTargetSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -50,4 +50,4 @@ export async function deleteTarget(req: Request, res: Response, next: NextFuncti
} catch (error) {
next(error);
}
}
}

View File

@@ -11,7 +11,7 @@ const getTargetSchema = z.object({
targetId: z.string().transform(Number).pipe(z.number().int().positive())
});
export async function getTarget(req: Request, res: Response, next: NextFunction) {
export async function getTarget(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = getTargetSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -51,4 +51,4 @@ export async function getTarget(req: Request, res: Response, next: NextFunction)
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from "./getTarget";
export * from "./createTarget";
export * from "./deleteTarget";
export * from "./updateTarget";

View File

@@ -21,7 +21,7 @@ const updateTargetBodySchema = z.object({
message: "At least one field must be provided for update"
});
export async function updateTarget(req: Request, res: Response, next: NextFunction) {
export async function updateTarget(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = updateTargetParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -72,4 +72,4 @@ export async function updateTarget(req: Request, res: Response, next: NextFuncti
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,52 @@
export type DynamicTraefikConfig = {
http?: Http;
};
export type Http = {
routers?: Routers;
services?: Services;
middlewares?: Middlewares;
};
export type Routers = {
[key: string]: Router;
};
export type Router = {
entryPoints: string[];
middlewares: string[];
service: string;
rule: string;
};
export type Services = {
[key: string]: Service;
};
export type Service = {
loadBalancer: LoadBalancer;
};
export type LoadBalancer = {
servers: Server[];
};
export type Server = {
url: string;
};
export type Middlewares = {
[key: string]: MiddlewarePlugin;
};
export type MiddlewarePlugin = {
plugin: Plugin;
};
export type Plugin = {
[key: string]: MiddlewarePluginConfig;
};
export type MiddlewarePluginConfig = {
[key: string]: any;
};

View File

@@ -0,0 +1,83 @@
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 logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
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}`);
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build traefik config",
});
}
}
export function buildTraefikConfig(
targets: schema.Target[],
): DynamicTraefikConfig {
const middlewareName = "badger";
if (!targets.length) {
return {};
}
const http: DynamicTraefikConfig["http"] = {
routers: {},
services: {},
middlewares: {
[middlewareName]: {
plugin: {
[middlewareName]: {
// These are temporary values
apiAddress:
"http://host.docker.internal:3001/api/v1/badger",
validToken: "abc123",
},
},
},
},
};
for (const target of targets) {
const routerName = `router-${target.targetId}`;
const serviceName = `service-${target.targetId}`;
http.routers![routerName] = {
entryPoints: [target.method],
middlewares: [middlewareName],
service: serviceName,
rule: `Host(\`${target.resourceId}\`)`, // assuming resourceId is a valid full hostname
};
http.services![serviceName] = {
loadBalancer: {
servers: [
{ url: `${target.method}://${target.ip}:${target.port}` },
],
},
};
}
return { http } as DynamicTraefikConfig;
}
export async function getAllTargets(): Promise<schema.Target[]> {
const all = await db
.select()
.from(schema.targets)
.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;
}

View File

@@ -0,0 +1 @@
export * from "./getTraefikConfig";

View File

@@ -11,7 +11,7 @@ const deleteUserSchema = z.object({
userId: z.string().uuid()
});
export async function deleteUser(req: Request, res: Response, next: NextFunction) {
export async function deleteUser(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = deleteUserSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -50,4 +50,4 @@ export async function deleteUser(req: Request, res: Response, next: NextFunction
} catch (error) {
next(error);
}
}
}

View File

@@ -11,7 +11,7 @@ const getUserSchema = z.object({
userId: z.string().uuid()
});
export async function getUser(req: Request, res: Response, next: NextFunction) {
export async function getUser(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = getUserSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -54,4 +54,4 @@ export async function getUser(req: Request, res: Response, next: NextFunction) {
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./getUser";
export * from "./deleteUser";