diff --git a/Dockerfile.FE b/Dockerfile.FE new file mode 100644 index 00000000..c1c89bb2 --- /dev/null +++ b/Dockerfile.FE @@ -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" ] diff --git a/build.ts b/build.ts index 2389ce10..28c94ecc 100644 --- a/build.ts +++ b/build.ts @@ -2,6 +2,9 @@ import { $ } from "bun"; import { rawRoutes } from "~routes"; +const feOnly = process.argv.includes("--frontend"); +const serverOnly = process.argv.includes("--server"); + console.log("Building frontend..."); await $`bun fe:build`; diff --git a/bun.lockb b/bun.lockb index 2905d7f8..b4658ef1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.yml index 1898fede..252440ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,16 @@ ---- services: 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: - - ./logs:/app/logs - - ./config:/app/config - - ./uploads:/app/uploads + - ./logs:/app/dist/logs + - ./config:/app/dist/config + - ./uploads:/app/dist/uploads restart: unless-stopped container_name: lysand networks: - lysand-net + db: image: ghcr.io/lysand-org/postgres:main container_name: lysand-db @@ -17,32 +18,33 @@ services: environment: POSTGRES_DB: lysand POSTGRES_USER: lysand - POSTGRES_PASSWORD: lysand + POSTGRES_PASSWORD: _______________ networks: - lysand-net volumes: - ./db-data:/var/lib/postgresql/data + redis: - image: redis:latest + image: redis:alpine container_name: lysand-redis volumes: - ./redis-data:/data restart: unless-stopped networks: - lysand-net + meilisearch: stdin_open: true environment: - - MEILI_MASTER_KEY=add_your_key_here + - MEILI_MASTER_KEY=__________________ tty: true networks: - lysand-net volumes: - ./meili-data:/meili_data - image: getmeili/meilisearch:v1.5 + image: getmeili/meilisearch:v1.7 container_name: lysand-meilisearch restart: unless-stopped networks: - lysand-net: - internal: true + lysand-net: \ No newline at end of file diff --git a/packages/request-parser/index.ts b/packages/request-parser/index.ts index 7e42699d..630ca9be 100644 --- a/packages/request-parser/index.ts +++ b/packages/request-parser/index.ts @@ -136,12 +136,8 @@ export class RequestParser { * @private * @throws Error if body is invalid */ - private async parseJson(): Promise> { - try { - return (await this.request.json()) as T; - } catch { - return {}; - } + private async parseJson(): Promise { + return (await this.request.json()) as T; } /** diff --git a/packages/server-handler/index.ts b/packages/server-handler/index.ts new file mode 100644 index 00000000..582ab420 --- /dev/null +++ b/packages/server-handler/index.ts @@ -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 | Promise; +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; + configManager: { + getConfig: () => Promise; + }; + }, +) => MaybePromise | MaybePromise; + +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; +} + +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 => { + 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) + : 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, + ); + } +}; diff --git a/packages/server-handler/package.json b/packages/server-handler/package.json new file mode 100644 index 00000000..32dcb22c --- /dev/null +++ b/packages/server-handler/package.json @@ -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" } +} diff --git a/packages/server-handler/tests.test.ts b/packages/server-handler/tests.test.ts new file mode 100644 index 00000000..6a312b8b --- /dev/null +++ b/packages/server-handler/tests.test.ts @@ -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"); + }); +}); diff --git a/routes.ts b/routes.ts index 098ca672..5e5655cb 100644 --- a/routes.ts +++ b/routes.ts @@ -1,3 +1,4 @@ +import type { APIRouteExports } from "~packages/server-handler"; import type { RouteHandler } from "./server/api/routes.type"; import type { APIRouteMeta } from "./types/api"; @@ -107,10 +108,6 @@ export const matchRoute = async >(url: string) => { if (!route) return { file: null, matchedRoute: null }; return { - file: (await import(rawRoutes[route.name])) as { - default: RouteHandler; - meta: APIRouteMeta; - }, matchedRoute: route, }; }; diff --git a/server.ts b/server.ts index 9f5fe3fd..68360cff 100644 --- a/server.ts +++ b/server.ts @@ -1,15 +1,9 @@ -import { - clientResponse, - errorResponse, - jsonResponse, - response, -} from "@response"; +import { errorResponse, response } from "@response"; import type { Config } from "config-manager"; import { matches } from "ip-matching"; import type { LogManager, MultiLogManager } from "log-manager"; import { LogLevel } from "log-manager"; -import { RequestParser } from "request-parser"; -import { getFromRequest } from "~database/entities/User"; +import { processRoute } from "~packages/server-handler"; import { matchRoute } from "~routes"; 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 - const { file: filePromise, matchedRoute } = await matchRoute( + const { matchedRoute } = await matchRoute( req.url.replace(".well-known", "well-known"), ); - const file = filePromise; - - 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); + if (matchedRoute && matchedRoute.name !== "/[...404]") { + return await processRoute(matchedRoute, req, logger); } const base_url_with_http = config.http.base_url.replace(