Merge pull request #16 from mcndt/server-refactor

refactor: split Express app into controller pattern
This commit is contained in:
Maxime Cannoodt 2022-08-09 19:46:12 +02:00 committed by GitHub
commit 9c2f52325a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1379 additions and 2267 deletions

View File

@ -1,99 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
require("dotenv/config");
const express_1 = __importDefault(require("express"));
const client_1 = require("@prisma/client");
const util_1 = require("./util");
const helmet_1 = __importDefault(require("helmet"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
// Initialize middleware clients
const prisma = new client_1.PrismaClient();
const app = (0, express_1.default)();
app.use(express_1.default.json());
app.use((0, helmet_1.default)({
crossOriginResourcePolicy: {
policy: process.env.ENVIRONMENT == "dev" ? "cross-origin" : "same-origin",
},
}));
// Apply rate limiting
const postLimiter = (0, express_rate_limit_1.default)({
windowMs: parseInt(process.env.POST_LIMIT_WINDOW_SECONDS) * 1000,
max: parseInt(process.env.POST_LIMIT),
standardHeaders: true,
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// start the Express server
app.listen(process.env.PORT, () => {
console.log(`server started at port ${process.env.PORT}`);
});
// Post new encrypted note
app.post("/note/", postLimiter, (req, res) => __awaiter(void 0, void 0, void 0, function* () {
try {
const note = req.body;
const savedNote = yield prisma.encryptedNote.create({
data: Object.assign(Object.assign({}, note), { expire_time: (0, util_1.addDays)(new Date(), 30) }),
});
console.log(`[POST] Saved note <${savedNote.id}> for <${req.ip}>`);
res.json({
view_url: `${process.env.FRONTEND_URL}/note/${savedNote.id}`,
expire_time: savedNote.expire_time,
});
}
catch (err) {
console.error(err.stack);
res.status(500).send("Something went wrong.");
}
}));
// Get encrypted note
app.get("/note/:id", (req, res) => __awaiter(void 0, void 0, void 0, function* () {
const note = yield prisma.encryptedNote.findUnique({
where: { id: req.params.id },
});
if (note != null) {
res.send(note);
console.log(`[GET] Retrieved note <${note.id}> for <${req.ip}>`);
}
res.status(404).send();
}));
// Default response for any other request
app.use((req, res, next) => {
res.status(500).send();
});
// // Error handling
// app.use((err, req, res, next) => {
// console.error(err.stack);
// res.status(500).send("Something broke!");
// });
// Clean up expired notes periodically
const interval = Math.max(parseInt(process.env.CLEANUP_INTERVAL_SECONDS) || 1, 1) *
1000;
setInterval(() => __awaiter(void 0, void 0, void 0, function* () {
try {
console.log("[Cleanup] Cleaning up expired notes...");
const deleted = yield prisma.encryptedNote.deleteMany({
where: {
expire_time: {
lte: new Date(),
},
},
});
console.log(`[Cleanup] Deleted ${deleted.count} expired notes.`);
}
catch (err) {
console.error(`[Cleanup] Error cleaning expired notes:`);
console.error(err);
}
}), interval);
//# sourceMappingURL=server.js.map

3117
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
"test:coverage": "dotenv -e .env.test -- vitest run --no-threads --coverage", "test:coverage": "dotenv -e .env.test -- vitest run --no-threads --coverage",
"test:db:reset": "dotenv -e .env.test -- npx prisma migrate reset -f", "test:db:reset": "dotenv -e .env.test -- npx prisma migrate reset -f",
"build": "npx tsc", "build": "npx tsc",
"dev": "npx nodemon ./server.ts | npx pino-colada" "dev": "npx nodemon src/server.ts | npx pino-colada"
}, },
"author": "Maxime Cannoodt (mcndt)", "author": "Maxime Cannoodt (mcndt)",
"license": "MIT", "license": "MIT",
@ -42,6 +42,7 @@
"supertest": "^6.2.3", "supertest": "^6.2.3",
"ts-node": "^10.8.1", "ts-node": "^10.8.1",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.17.1" "vitest": "^0.17.1"
} }
} }

View File

@ -1,8 +1,9 @@
import app, { cleanExpiredNotes } from "./app"; import { app } from "./app";
import request from "supertest"; import supertest from "supertest";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import prisma from "./client"; import prisma from "./db/client";
import { EventType } from "./EventLogger"; import { cleanExpiredNotes } from "./tasks/deleteExpiredNotes";
import { EventType } from "./logging/EventLogger";
// const testNote with base64 ciphertext and hmac // const testNote with base64 ciphertext and hmac
const testNote = { const testNote = {
@ -18,7 +19,7 @@ describe("GET /api/note", () => {
}); });
// Make get request // Make get request
const res = await request(app).get(`/api/note/${id}`); const res = await supertest(app).get(`/api/note/${id}`);
// Validate returned note // Validate returned note
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@ -44,7 +45,7 @@ describe("GET /api/note", () => {
it("responds 404 for invalid ID", async () => { it("responds 404 for invalid ID", async () => {
// Make get request // Make get request
const res = await request(app).get(`/api/note/NaN`); const res = await supertest(app).get(`/api/note/NaN`);
// Validate returned note // Validate returned note
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
@ -66,7 +67,7 @@ describe("GET /api/note", () => {
// Make get requests // Make get requests
const requests = []; const requests = [];
for (let i = 0; i < 51; i++) { for (let i = 0; i < 51; i++) {
requests.push(request(app).get(`/api/note/${id}`)); requests.push(supertest(app).get(`/api/note/${id}`));
} }
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
const responseCodes = responses.map((res) => res.statusCode); const responseCodes = responses.map((res) => res.statusCode);
@ -81,7 +82,7 @@ describe("GET /api/note", () => {
describe("POST /api/note", () => { describe("POST /api/note", () => {
it("returns a view_url on correct POST body", async () => { it("returns a view_url on correct POST body", async () => {
const res = await request(app).post("/api/note").send(testNote); const res = await supertest(app).post("/api/note").send(testNote);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
// Returned body has correct fields // Returned body has correct fields
@ -109,13 +110,13 @@ describe("POST /api/note", () => {
}); });
it("Returns a bad request on invalid POST body", async () => { it("Returns a bad request on invalid POST body", async () => {
const res = await request(app).post("/api/note").send({}); const res = await supertest(app).post("/api/note").send({});
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
it("returns a valid view_url on correct POST body", async () => { it("returns a valid view_url on correct POST body", async () => {
// Make post request // Make post request
let res = await request(app).post("/api/note").send(testNote); let res = await supertest(app).post("/api/note").send(testNote);
// Extract note id from post response // Extract note id from post response
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@ -126,7 +127,7 @@ describe("POST /api/note", () => {
const note_id = (match as RegExpMatchArray)[1]; const note_id = (match as RegExpMatchArray)[1];
// Make get request // Make get request
res = await request(app).get(`/api/note/${note_id}`); res = await supertest(app).get(`/api/note/${note_id}`);
// Validate returned note // Validate returned note
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@ -140,12 +141,12 @@ describe("POST /api/note", () => {
expect(res.body.hmac).toEqual(testNote.hmac); expect(res.body.hmac).toEqual(testNote.hmac);
}); });
it("Applies upload limit to endpoint of 400kb", async () => { it("Applies upload limit to endpoint of 500kb", async () => {
const largeNote = { const largeNote = {
ciphertext: "a".repeat(400 * 1024), ciphertext: "a".repeat(500 * 1024),
hmac: "sample_hmac", hmac: "sample_hmac",
}; };
const res = await request(app).post("/api/note").send(largeNote); const res = await supertest(app).post("/api/note").send(largeNote);
expect(res.statusCode).toBe(413); expect(res.statusCode).toBe(413);
}); });
@ -153,7 +154,7 @@ describe("POST /api/note", () => {
// make more requests than the post limit set in .env.test // make more requests than the post limit set in .env.test
const requests = []; const requests = [];
for (let i = 0; i < 51; i++) { for (let i = 0; i < 51; i++) {
requests.push(request(app).post("/api/note").send(testNote)); requests.push(supertest(app).post("/api/note").send(testNote));
} }
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
const responseCodes = responses.map((res) => res.statusCode); const responseCodes = responses.map((res) => res.statusCode);
@ -177,7 +178,7 @@ describe("Clean expired notes", () => {
}); });
// make request for note and check that response is 200 // make request for note and check that response is 200
let res = await request(app).get(`/api/note/${id}`); let res = await supertest(app).get(`/api/note/${id}`);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
// run cleanup // run cleanup
@ -185,7 +186,7 @@ describe("Clean expired notes", () => {
expect(nDeleted).toBeGreaterThan(0); expect(nDeleted).toBeGreaterThan(0);
// make sure note is gone // make sure note is gone
res = await request(app).get(`/api/note/${id}`); res = await supertest(app).get(`/api/note/${id}`);
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
// sleep 100ms to allow all events to be logged // sleep 100ms to allow all events to be logged

View File

@ -1,20 +1,16 @@
import "dotenv/config"; import "dotenv/config";
import express, { Express, Request, Response } from "express"; import express, { type Express } from "express";
import { EncryptedNote } from "@prisma/client";
import { addDays, getConnectingIp } from "./util";
import helmet from "helmet"; import helmet from "helmet";
import rateLimit from "express-rate-limit";
import pinoHttp from "pino-http"; import pinoHttp from "pino-http";
import logger from "./logger"; import logger from "./logging/logger";
import prisma from "./client"; import { notesRoute } from "./controllers/note/note.router";
import bodyParser from "body-parser"; import { cleanExpiredNotes, cleanInterval } from "./tasks/deleteExpiredNotes";
import { NotePostRequest } from "./model/NotePostRequest";
import { validateOrReject } from "class-validator";
import EventLogger from "./EventLogger";
// Initialize middleware clients // Initialize middleware clients
const app: Express = express(); export const app: Express = express();
app.use(express.json());
// Enable JSON body parsing
app.use(express.json({ limit: "500k" }));
// configure logging // configure logging
app.use( app.use(
@ -32,144 +28,8 @@ app.use(
}) })
); );
// Apply rate limiting // Mount routes
const postLimiter = rateLimit({ app.use("/api/note/", notesRoute);
windowMs: parseFloat(process.env.POST_LIMIT_WINDOW_SECONDS as string) * 1000,
max: parseInt(process.env.POST_LIMIT as string), // Limit each IP to X requests per window
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
const getLimiter = rateLimit({ // Run periodic tasks
windowMs: parseFloat(process.env.GET_LIMIT_WINDOW_SECONDS as string) * 1000, setInterval(cleanExpiredNotes, cleanInterval);
max: parseInt(process.env.GET_LIMIT as string), // Limit each IP to X requests per window
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Apply 400kB upload limit on POST
app.use(bodyParser.json({ limit: "400k" }));
// Get encrypted note
app.get("/api/note/:id", getLimiter, (req: Request, res: Response, next) => {
const ip = getConnectingIp(req);
prisma.encryptedNote
.findUnique({
where: { id: req.params.id },
})
.then(async (note) => {
if (note != null) {
await EventLogger.readEvent({
success: true,
host: ip,
note_id: note.id,
size_bytes: note.ciphertext.length + note.hmac.length,
});
res.send(note);
} else {
await EventLogger.readEvent({
success: false,
host: ip,
note_id: req.params.id,
error: "Note not found",
});
res.status(404).send();
}
})
.catch(async (err) => {
await EventLogger.readEvent({
success: false,
host: ip,
note_id: req.params.id,
error: err.message,
});
next(err);
});
});
// Post new encrypted note
app.post("/api/note/", postLimiter, (req: Request, res: Response, next) => {
const ip = getConnectingIp(req);
const notePostRequest = new NotePostRequest();
Object.assign(notePostRequest, req.body);
validateOrReject(notePostRequest).catch((err) => {
res.status(400).send(err.message);
});
const note = notePostRequest as EncryptedNote;
const EXPIRE_WINDOW_DAYS = 30;
prisma.encryptedNote
.create({
data: {
...note,
expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS),
},
})
.then(async (savedNote) => {
await EventLogger.writeEvent({
success: true,
host: ip,
note_id: savedNote.id,
size_bytes: savedNote.ciphertext.length + savedNote.hmac.length,
expire_window_days: EXPIRE_WINDOW_DAYS,
});
res.json({
view_url: `${process.env.FRONTEND_URL}/note/${savedNote.id}`,
expire_time: savedNote.expire_time,
});
})
.catch(async (err) => {
await EventLogger.writeEvent({
success: false,
host: ip,
error: err.message,
});
next(err);
});
});
// Clean up expired notes periodically
export async function cleanExpiredNotes(): Promise<number> {
logger.info("[Cleanup] Cleaning up expired notes...");
const toDelete = await prisma.encryptedNote.findMany({
where: {
expire_time: {
lte: new Date(),
},
},
});
return prisma.encryptedNote
.deleteMany({
where: { id: { in: toDelete.map((note) => note.id) } },
})
.then(async (deleted) => {
const logs = toDelete.map(async (note) => {
logger.info(
`[Cleanup] Deleted note ${note.id} with size ${
note.ciphertext.length + note.hmac.length
} bytes`
);
return EventLogger.purgeEvent({
success: true,
note_id: note.id,
size_bytes: note.ciphertext.length + note.hmac.length,
});
});
await Promise.all(logs);
logger.info(`[Cleanup] Deleted ${deleted.count} expired notes.`);
return deleted.count;
})
.catch((err) => {
logger.error(`[Cleanup] Error cleaning expired notes:`);
logger.error(err);
return -1;
});
}
const interval =
Math.max(parseInt(<string>process.env.CLEANUP_INTERVAL_SECONDS) || 1, 1) *
1000;
setInterval(cleanExpiredNotes, interval);
export default app;

View File

@ -0,0 +1,34 @@
import { EncryptedNote } from "@prisma/client";
import prisma from "../../db/client";
export async function getNote(noteId: string): Promise<EncryptedNote | null> {
return prisma.encryptedNote.findUnique({
where: { id: noteId },
});
}
export async function createNote(note: EncryptedNote): Promise<EncryptedNote> {
return prisma.encryptedNote.create({
data: note,
});
}
export async function getExpiredNotes(): Promise<EncryptedNote[]> {
return prisma.encryptedNote.findMany({
where: {
expire_time: {
lte: new Date(),
},
},
});
}
export async function deleteNotes(noteIds: string[]): Promise<number> {
return prisma.encryptedNote
.deleteMany({
where: { id: { in: noteIds } },
})
.then((deleted) => {
return deleted.count;
});
}

View File

@ -0,0 +1,41 @@
import { NextFunction, Request, Response } from "express";
import EventLogger from "../../logging/EventLogger";
import { getConnectingIp } from "../../util";
import { getNote } from "./note.dao";
export async function getNoteController(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const ip = getConnectingIp(req);
getNote(req.params.id)
.then(async (note) => {
if (note != null) {
await EventLogger.readEvent({
success: true,
host: ip,
note_id: note.id,
size_bytes: note.ciphertext.length + note.hmac.length,
});
res.send(note);
} else {
await EventLogger.readEvent({
success: false,
host: ip,
note_id: req.params.id,
error: "Note not found",
});
res.status(404).send();
}
})
.catch(async (err) => {
await EventLogger.readEvent({
success: false,
host: ip,
note_id: req.params.id,
error: err.message,
});
next(err);
});
}

View File

@ -0,0 +1,59 @@
import { EncryptedNote } from "@prisma/client";
import { validateOrReject } from "class-validator";
import { NextFunction, Request, Response } from "express";
import { IsBase64 } from "class-validator";
import { createNote } from "./note.dao";
import { addDays, getConnectingIp } from "../../util";
import EventLogger from "../../logging/EventLogger";
/**
* Request body for creating a note
*/
export class NotePostRequest {
@IsBase64()
ciphertext: string | undefined;
@IsBase64()
hmac: string | undefined;
}
export async function postNoteController(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const ip = getConnectingIp(req);
const notePostRequest = new NotePostRequest();
Object.assign(notePostRequest, req.body);
validateOrReject(notePostRequest).catch((err) => {
res.status(400).send(err.message);
});
const note = notePostRequest as EncryptedNote;
const EXPIRE_WINDOW_DAYS = 30;
createNote({
...note,
expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS),
})
.then(async (savedNote) => {
await EventLogger.writeEvent({
success: true,
host: ip,
note_id: savedNote.id,
size_bytes: savedNote.ciphertext.length + savedNote.hmac.length,
expire_window_days: EXPIRE_WINDOW_DAYS,
});
res.json({
view_url: `${process.env.FRONTEND_URL}/note/${savedNote.id}`,
expire_time: savedNote.expire_time,
});
})
.catch(async (err) => {
await EventLogger.writeEvent({
success: false,
host: ip,
error: err.message,
});
next(err);
});
}

View File

@ -0,0 +1,26 @@
import express from "express";
import rateLimit from "express-rate-limit";
import { getNoteController } from "./note.get.controller";
import { postNoteController } from "./note.post.controller";
export const notesRoute = express.Router();
const jsonParser = express.json({ limit: "500k" });
const postRateLimit = rateLimit({
windowMs: parseFloat(process.env.POST_LIMIT_WINDOW_SECONDS as string) * 1000,
max: parseInt(process.env.POST_LIMIT as string), // Limit each IP to X requests per window
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
const getRateLimit = rateLimit({
windowMs: parseFloat(process.env.GET_LIMIT_WINDOW_SECONDS as string) * 1000,
max: parseInt(process.env.GET_LIMIT as string), // Limit each IP to X requests per window
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
notesRoute.use(jsonParser);
notesRoute.post("", postRateLimit, postNoteController);
notesRoute.get("/:id", getRateLimit, getNoteController);

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import prisma from "../db/client";
import EventLogger, { EventType } from "./EventLogger"; import EventLogger, { EventType } from "./EventLogger";
import prisma from "./client";
describe("Logging write events", () => { describe("Logging write events", () => {
it("Should write a write event to database", async () => { it("Should write a write event to database", async () => {

View File

@ -1,5 +1,5 @@
import prisma from "./client";
import { event } from "@prisma/client"; import { event } from "@prisma/client";
import prisma from "../db/client";
export enum EventType { export enum EventType {
WRITE = "WRITE", WRITE = "WRITE",

View File

@ -1,12 +0,0 @@
import { IsBase64 } from "class-validator";
/**
* Request body for creating a note
*/
export class NotePostRequest {
@IsBase64()
ciphertext: string | undefined;
@IsBase64()
hmac: string | undefined;
}

View File

@ -1,6 +1,6 @@
import "dotenv/config"; import "dotenv/config";
import logger from "./src/logger"; import logger from "./logging/logger";
import app from "./src/app"; import { app } from "./app";
// start the Express server // start the Express server
app.listen(process.env.PORT, () => { app.listen(process.env.PORT, () => {

View File

@ -0,0 +1,36 @@
import { deleteNotes, getExpiredNotes } from "../controllers/note/note.dao";
import EventLogger from "../logging/EventLogger";
import logger from "../logging/logger";
export const cleanInterval =
Math.max(parseInt(<string>process.env.CLEANUP_INTERVAL_SECONDS) || 1, 1) *
1000;
export async function cleanExpiredNotes(): Promise<number> {
logger.info("[Cleanup] Cleaning up expired notes...");
const toDelete = await getExpiredNotes();
return deleteNotes(toDelete.map((n) => n.id))
.then(async (deleteCount) => {
const logs = toDelete.map(async (note) => {
logger.info(
`[Cleanup] Deleted note ${note.id} with size ${
note.ciphertext.length + note.hmac.length
} bytes`
);
return EventLogger.purgeEvent({
success: true,
note_id: note.id,
size_bytes: note.ciphertext.length + note.hmac.length,
});
});
await Promise.all(logs);
logger.info(`[Cleanup] Deleted ${deleteCount} expired notes.`);
return deleteCount;
})
.catch((err) => {
logger.error(`[Cleanup] Error cleaning expired notes:`);
logger.error(err);
return -1;
});
}

View File

@ -1,7 +1,9 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
plugins: [tsconfigPaths()],
test: { test: {
// ... include: ["src/**/*.test.ts"],
}, },
}); });