Merge pull request #16 from mcndt/server-refactor
refactor: split Express app into controller pattern
This commit is contained in:
commit
9c2f52325a
@ -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
|
|
3125
server/package-lock.json
generated
3125
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
|
||||||
|
34
server/src/controllers/note/note.dao.ts
Normal file
34
server/src/controllers/note/note.dao.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
41
server/src/controllers/note/note.get.controller.ts
Normal file
41
server/src/controllers/note/note.get.controller.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
59
server/src/controllers/note/note.post.controller.ts
Normal file
59
server/src/controllers/note/note.post.controller.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
26
server/src/controllers/note/note.router.ts
Normal file
26
server/src/controllers/note/note.router.ts
Normal 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);
|
@ -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 () => {
|
@ -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",
|
@ -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;
|
|
||||||
}
|
|
@ -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, () => {
|
36
server/src/tasks/deleteExpiredNotes.ts
Normal file
36
server/src/tasks/deleteExpiredNotes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user