Merge pull request #4000 from bluewave-labs/feat/express-5-bump
feat: migrate to Express 5
This commit is contained in:
11 files changed
+616
-467
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.0",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
||||||
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
||||||
# binary version mismatch under emulation.
|
# binary version mismatch under emulation.
|
||||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ RUN npm run build
|
|||||||
# ---------------------
|
# ---------------------
|
||||||
# Backend stage
|
# Backend stage
|
||||||
# ---------------------
|
# ---------------------
|
||||||
FROM node:22-slim AS app
|
FROM node:24-slim AS app
|
||||||
|
|
||||||
# Install ping
|
# Install ping
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|||||||
Generated
+486
-445
File diff suppressed because it is too large.
Load diff
+6
-5
@@ -30,7 +30,8 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"dayjs": "^1.11.23",
|
"dayjs": "^1.11.23",
|
||||||
"dockerode": "5.0.0",
|
"dockerode": "5.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "^8.5.1",
|
"express-rate-limit": "^8.5.1",
|
||||||
"gamedig": "5.2.0",
|
"gamedig": "5.2.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
@@ -58,11 +59,11 @@
|
|||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
"mjml": "^5.0.0-alpha.4",
|
"mjml": "^5.0.0-alpha.4",
|
||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "2.4.0",
|
||||||
"nodemailer": "9.0.5",
|
"nodemailer": "9.1.1",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"re2": "^1.24.1",
|
"re2": "^1.24.1",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.35.4",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tldts": "^7.4.10",
|
"tldts": "^7.4.10",
|
||||||
|
|||||||
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
|
|||||||
|
|
||||||
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.query && typeof req.query === "object") {
|
const sanitized = Object.fromEntries(
|
||||||
for (const key of Object.keys(req.query)) {
|
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
|
||||||
const value = req.query[key];
|
);
|
||||||
if (typeof value === "string") {
|
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
|
||||||
req.query[key] = sanitizeInput(value, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
+1
-1
@@ -122,7 +122,7 @@ export const createApp = ({
|
|||||||
setupRoutes(app, controllers, services);
|
setupRoutes(app, controllers, services);
|
||||||
|
|
||||||
// FE routes
|
// FE routes
|
||||||
app.get("*", (req, res) => {
|
app.get("/{*splat}", (req, res) => {
|
||||||
res.sendFile(path.join(frontendPath, "index.html"));
|
res.sendFile(path.join(frontendPath, "index.html"));
|
||||||
});
|
});
|
||||||
app.use(handleErrors(services.logger));
|
app.use(handleErrors(services.logger));
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, jest } from "@jest/globals";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { sanitizeBody, sanitizeInput, sanitizeObject, sanitizeQuery } from "../../../src/api/middleware/sanitization.ts";
|
||||||
|
|
||||||
|
// Express 5 exposes req.query as a getter on the request prototype that re-parses the URL on
|
||||||
|
// every read, so writes into the returned object are lost. The middleware must shadow the
|
||||||
|
// getter with an own property, which is what this fixture checks.
|
||||||
|
const makeExpress5Request = (query: Record<string, unknown>) => {
|
||||||
|
const proto = {};
|
||||||
|
Object.defineProperty(proto, "query", {
|
||||||
|
get: () => ({ ...query }),
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
return Object.create(proto) as Request;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("sanitizeInput", () => {
|
||||||
|
it("strips html tags and keeps the text content", () => {
|
||||||
|
expect(sanitizeInput('<script>alert("x")</script>hello <b>bold</b>')).toBe("hello bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns non-string values unchanged", () => {
|
||||||
|
expect(sanitizeInput(42)).toBe(42);
|
||||||
|
expect(sanitizeInput(null)).toBeNull();
|
||||||
|
expect(sanitizeInput(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeObject", () => {
|
||||||
|
it("sanitizes nested strings and arrays, leaving other values untouched", () => {
|
||||||
|
const result = sanitizeObject({
|
||||||
|
name: "<img src=x onerror=alert(1)>Alice",
|
||||||
|
count: 3,
|
||||||
|
active: true,
|
||||||
|
items: [{ note: "<i>one</i>" }, { note: "two" }],
|
||||||
|
nested: { note: "<p>hi</p>" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: "Alice",
|
||||||
|
count: 3,
|
||||||
|
active: true,
|
||||||
|
items: [{ note: "one" }, { note: "two" }],
|
||||||
|
nested: { note: "hi" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns primitives and null unchanged", () => {
|
||||||
|
expect(sanitizeObject("<b>x</b>")).toBe("<b>x</b>");
|
||||||
|
expect(sanitizeObject(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeBody middleware", () => {
|
||||||
|
it("replaces req.body with a sanitized copy and calls next", () => {
|
||||||
|
const req = { body: { name: "<b>Alice</b>", nested: { note: "<i>x</i>" } } } as unknown as Request;
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeBody()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.body).toEqual({ name: "Alice", nested: { note: "x" } });
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves req.body alone when there is no body", () => {
|
||||||
|
const req = {} as unknown as Request;
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeBody()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.body).toBeUndefined();
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeQuery middleware", () => {
|
||||||
|
it("sanitizes string values so that later reads of req.query see them", () => {
|
||||||
|
const req = makeExpress5Request({ filter: "<script>x</script>term", page: "2" });
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({ filter: "term", page: "2" });
|
||||||
|
expect(req.query).toEqual({ filter: "term", page: "2" }); // stable across reads
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sanitize inside array values", () => {
|
||||||
|
const req = makeExpress5Request({ type: ["<b>http</b>", "ping"] });
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({ type: ["<b>http</b>", "ping"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty query", () => {
|
||||||
|
const req = makeExpress5Request({});
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
|
||||||
|
sanitizeQuery()(req, {} as Response, next);
|
||||||
|
|
||||||
|
expect(req.query).toEqual({});
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user