Add new ServerHandler package which handles requests

This commit is contained in:
Jesse Wierzbinski 2024-04-13 21:51:00 -10:00
parent 3cdd685035
commit 327a716b12
No known key found for this signature in database
10 changed files with 616 additions and 110 deletions

45
Dockerfile.FE Normal file
View file

@ -0,0 +1,45 @@
# Bun doesn't run well on Musl but this seems to work
FROM imbios/bun-node:1.1.3-current-alpine as base
# Install dependencies into temp directory
# This will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp
COPY . /temp
WORKDIR /temp
RUN bun install --frozen-lockfile
FROM base as build
# Copy the project
RUN mkdir -p /temp
COPY . /temp
# Copy dependencies
COPY --from=install /temp/node_modules /temp/node_modules
# Build the project
WORKDIR /temp
RUN bun run prod-build
WORKDIR /temp/dist
# Copy production dependencies and source code into final image
FROM oven/bun:1.1.3-alpine
# Create app directory
RUN mkdir -p /app
COPY --from=build /temp/dist /app/dist
COPY entrypoint.sh /app
LABEL org.opencontainers.image.authors "Gaspard Wierzbinski (https://cpluspatch.dev)"
LABEL org.opencontainers.image.source "https://github.com/lysand-org/lysand"
LABEL org.opencontainers.image.vendor "Lysand Org"
LABEL org.opencontainers.image.licenses "AGPL-3.0"
LABEL org.opencontainers.image.title "Lysand Server"
LABEL org.opencontainers.image.description "Lysand Server docker image"
# CD to app
WORKDIR /app
ENV NODE_ENV=production
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
# Run migrations and start the server
CMD [ "start" ]

View file

@ -2,6 +2,9 @@
import { $ } from "bun"; import { $ } from "bun";
import { rawRoutes } from "~routes"; import { rawRoutes } from "~routes";
const feOnly = process.argv.includes("--frontend");
const serverOnly = process.argv.includes("--server");
console.log("Building frontend..."); console.log("Building frontend...");
await $`bun fe:build`; await $`bun fe:build`;

BIN
bun.lockb

Binary file not shown.

View file

@ -1,15 +1,16 @@
---
services: services:
lysand: lysand:
image: ghcr.io/lysand-org/lysand:main build: . # Automatic builds are currently broken, please build from source
# image: ghcr.io/lysand-org/lysand:latest
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/dist/logs
- ./config:/app/config - ./config:/app/dist/config
- ./uploads:/app/uploads - ./uploads:/app/dist/uploads
restart: unless-stopped restart: unless-stopped
container_name: lysand container_name: lysand
networks: networks:
- lysand-net - lysand-net
db: db:
image: ghcr.io/lysand-org/postgres:main image: ghcr.io/lysand-org/postgres:main
container_name: lysand-db container_name: lysand-db
@ -17,32 +18,33 @@ services:
environment: environment:
POSTGRES_DB: lysand POSTGRES_DB: lysand
POSTGRES_USER: lysand POSTGRES_USER: lysand
POSTGRES_PASSWORD: lysand POSTGRES_PASSWORD: _______________
networks: networks:
- lysand-net - lysand-net
volumes: volumes:
- ./db-data:/var/lib/postgresql/data - ./db-data:/var/lib/postgresql/data
redis: redis:
image: redis:latest image: redis:alpine
container_name: lysand-redis container_name: lysand-redis
volumes: volumes:
- ./redis-data:/data - ./redis-data:/data
restart: unless-stopped restart: unless-stopped
networks: networks:
- lysand-net - lysand-net
meilisearch: meilisearch:
stdin_open: true stdin_open: true
environment: environment:
- MEILI_MASTER_KEY=add_your_key_here - MEILI_MASTER_KEY=__________________
tty: true tty: true
networks: networks:
- lysand-net - lysand-net
volumes: volumes:
- ./meili-data:/meili_data - ./meili-data:/meili_data
image: getmeili/meilisearch:v1.5 image: getmeili/meilisearch:v1.7
container_name: lysand-meilisearch container_name: lysand-meilisearch
restart: unless-stopped restart: unless-stopped
networks: networks:
lysand-net: lysand-net:
internal: true

View file

@ -136,12 +136,8 @@ export class RequestParser {
* @private * @private
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
private async parseJson<T>(): Promise<Partial<T>> { private async parseJson<T>(): Promise<T> {
try { return (await this.request.json()) as T;
return (await this.request.json()) as T;
} catch {
return {};
}
} }
/** /**

View file

@ -0,0 +1,178 @@
import { errorResponse, jsonResponse, response } from "@response";
import type { MatchedRoute } from "bun";
import { type Config, config } from "config-manager";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { RequestParser } from "request-parser";
import { type ZodType, z } from "zod";
import { fromZodError } from "zod-validation-error";
import {
type AuthData,
type UserWithRelations,
getFromRequest,
} from "~database/entities/User";
type MaybePromise<T> = T | Promise<T>;
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
export type RouteHandler<
RouteMeta extends APIRouteMetadata,
ZodSchema extends ZodType,
> = (
req: Request,
matchedRoute: MatchedRoute,
extraData: {
auth: {
// If the route doesn't require authentication, set the type to UserWithRelations | null
// Otherwise set to UserWithRelations
user: RouteMeta["auth"]["required"] extends true
? UserWithRelations
: UserWithRelations | null;
token: RouteMeta["auth"]["required"] extends true
? string
: string | null;
};
parsedRequest: z.infer<ZodSchema>;
configManager: {
getConfig: () => Promise<Config>;
};
},
) => MaybePromise<Response> | MaybePromise<object>;
export interface APIRouteMetadata {
allowedMethods: HttpVerb[];
ratelimits: {
max: number;
duration: number;
};
route: string;
auth: {
required: boolean;
requiredOnMethods?: HttpVerb[];
oauthPermissions?: string[];
};
}
export interface APIRouteExports {
meta: APIRouteMetadata;
schema: z.AnyZodObject;
default: RouteHandler<APIRouteMetadata, z.AnyZodObject>;
}
const exampleZodSchema = z.object({
allowedMethods: z.array(z.string()),
ratelimits: z.object({
max: z.number(),
duration: z.number(),
}),
route: z.string(),
auth: z.object({
required: z.boolean(),
}),
});
export const processRoute = async (
matchedRoute: MatchedRoute,
request: Request,
logger: LogManager | MultiLogManager,
): Promise<Response> => {
if (request.method === "OPTIONS") {
return response();
}
const route: APIRouteExports | null = await import(
matchedRoute.filePath
).catch(() => null);
if (!route) {
return errorResponse("Route not found", 404);
}
// Check if the request method is allowed
if (!route.meta.allowedMethods.includes(request.method as HttpVerb)) {
return errorResponse("Method not allowed", 405);
}
let auth: AuthData | null = null;
if (
route.meta.auth.required ||
route.meta.auth.requiredOnMethods?.includes(request.method as HttpVerb)
) {
auth = await getFromRequest(request);
if (!auth.user) {
return errorResponse(
"Unauthorized: access to this method requires an authenticated user",
401,
);
}
}
// Check if Content-Type header is missing in POST, PUT and PATCH requests
if (["POST", "PUT", "PATCH"].includes(request.method)) {
if (!request.headers.has("Content-Type")) {
return errorResponse(
`Content-Type header is missing but required on method ${request.method}`,
400,
);
}
}
const parsedRequest = await new RequestParser(request)
.toObject()
.catch(async (err) => {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
err as Error,
);
return null;
});
if (!parsedRequest) {
return errorResponse(
"The request could not be parsed, it may be malformed",
400,
);
}
const parsingResult = route.schema?.safeParse(parsedRequest);
if (parsingResult && !parsingResult.success) {
// Return a 422 error with the first error message
return errorResponse(fromZodError(parsingResult.error).toString(), 422);
}
try {
const output = await route.default(request, matchedRoute, {
auth: {
token: auth?.token ?? null,
user: auth?.user ?? null,
},
parsedRequest: parsingResult
? (parsingResult.data as z.infer<typeof route.schema>)
: parsedRequest,
configManager: {
getConfig: async () => config as Config,
},
});
// If the output is a normal JS object and not a Response, convert it to a jsonResponse
if (!(output instanceof Response)) {
return jsonResponse(output);
}
return output;
} catch (err) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteHandler",
err as Error,
);
return errorResponse(
`A server error occured: ${(err as Error).message}`,
500,
);
}
};

View file

@ -0,0 +1,6 @@
{
"name": "server-handler",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.1.0" }
}

View file

@ -0,0 +1,363 @@
import { afterAll, describe, expect, it, mock } from "bun:test";
import type { MatchedRoute } from "bun";
import { LogManager } from "log-manager";
import { z } from "zod";
import { getTestUsers } from "~tests/utils";
import { type APIRouteExports, processRoute } from ".";
describe("Route Processor", () => {
it("should return a Response", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output).toBeInstanceOf(Response);
});
it("should return a 404 when the route does not exist", async () => {
const output = await processRoute(
{
filePath: "./nonexistent-route",
} as MatchedRoute,
new Request("https://test.com/nonexistent-route"),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(404);
});
it("should return a 405 when the request method is not allowed", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(405);
});
it("should return a 401 when the route requires authentication but no user is authenticated", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: true,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(401);
});
it("should return a 400 when the Content-Type header is missing in POST, PUT and PATCH requests", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST", "PUT", "PATCH"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(400);
const output2 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PUT",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output2.status).toBe(400);
const output3 = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "PATCH",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output3.status).toBe(400);
});
it("should return a 400 when the request could not be parsed", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "invalid-json",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(400);
});
it("should return a 422 when the request does not match the schema", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response(),
meta: {
allowedMethods: ["POST"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({
foo: z.string(),
}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ bar: "baz" }),
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(422);
});
it("should convert any JS objects returned by the route to a Response", async () => {
mock.module(
"./route",
() =>
({
default: async () => ({ status: 200 }),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(200);
});
it("should handle route errors", async () => {
mock.module(
"./route",
() =>
({
default: async () => {
throw new Error("Route error");
},
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(500);
});
it("should return the route output when everything is valid", async () => {
mock.module(
"./route",
() =>
({
default: async () => new Response("OK"),
meta: {
allowedMethods: ["GET"],
ratelimits: {
max: 100,
duration: 60,
},
route: "/route",
auth: {
required: false,
},
},
schema: z.object({}),
}) as APIRouteExports,
);
const output = await processRoute(
{
filePath: "./route",
} as MatchedRoute,
new Request("https://test.com/route", {
method: "GET",
}),
new LogManager(Bun.file("/dev/null")),
);
expect(output.status).toBe(200);
expect(await output.text()).toBe("OK");
});
});

View file

@ -1,3 +1,4 @@
import type { APIRouteExports } from "~packages/server-handler";
import type { RouteHandler } from "./server/api/routes.type"; import type { RouteHandler } from "./server/api/routes.type";
import type { APIRouteMeta } from "./types/api"; import type { APIRouteMeta } from "./types/api";
@ -107,10 +108,6 @@ export const matchRoute = async <T = Record<string, never>>(url: string) => {
if (!route) return { file: null, matchedRoute: null }; if (!route) return { file: null, matchedRoute: null };
return { return {
file: (await import(rawRoutes[route.name])) as {
default: RouteHandler<T>;
meta: APIRouteMeta;
},
matchedRoute: route, matchedRoute: route,
}; };
}; };

View file

@ -1,15 +1,9 @@
import { import { errorResponse, response } from "@response";
clientResponse,
errorResponse,
jsonResponse,
response,
} from "@response";
import type { Config } from "config-manager"; import type { Config } from "config-manager";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import type { LogManager, MultiLogManager } from "log-manager"; import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager"; import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser"; import { processRoute } from "~packages/server-handler";
import { getFromRequest } from "~database/entities/User";
import { matchRoute } from "~routes"; import { matchRoute } from "~routes";
export const createServer = ( export const createServer = (
@ -97,91 +91,13 @@ export const createServer = (
); );
} }
if (req.method === "OPTIONS") {
return jsonResponse({});
}
// If route is .well-known, remove dot because the filesystem router can't handle dots for some reason // If route is .well-known, remove dot because the filesystem router can't handle dots for some reason
const { file: filePromise, matchedRoute } = await matchRoute( const { matchedRoute } = await matchRoute(
req.url.replace(".well-known", "well-known"), req.url.replace(".well-known", "well-known"),
); );
const file = filePromise; if (matchedRoute && matchedRoute.name !== "/[...404]") {
return await processRoute(matchedRoute, req, logger);
if (matchedRoute && file === undefined) {
await logger.log(
LogLevel.ERROR,
"Server",
`Route file ${matchedRoute.filePath} not found or not registered in the routes file`,
);
return errorResponse("Route not found", 500);
}
if (matchedRoute && matchedRoute.name !== "/[...404]" && file) {
const meta = file.meta;
// Check for allowed requests
// @ts-expect-error Stupid error
if (!meta.allowedMethods.includes(req.method)) {
return errorResponse(
`Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", ",
)}`,
405,
);
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (
(meta.auth.required ||
(meta.auth.requiredOnMethods ?? []).includes(
// @ts-expect-error Stupid error
req.method,
)) &&
!auth.user
) {
return errorResponse("Unauthorized", 401);
}
// Check is Content-Type header is missing in relevant requests
if (["POST", "PUT", "PATCH"].includes(req.method)) {
if (!req.headers.has("Content-Type")) {
return errorResponse(
`Content-Type header is missing but required on method ${req.method}`,
400,
);
}
}
let parsedRequest = {};
try {
parsedRequest = await new RequestParser(req).toObject();
} catch (e) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
e as Error,
);
return errorResponse("Bad request", 400);
}
return await file.default(req.clone(), matchedRoute, {
auth,
parsedRequest,
// To avoid having to rewrite each route
configManager: {
getConfig: () => Promise.resolve(config),
},
});
}
if (new URL(req.url).pathname.startsWith("/api")) {
return errorResponse("Route not found", 404);
} }
const base_url_with_http = config.http.base_url.replace( const base_url_with_http = config.http.base_url.replace(