feat: Implement caching and rate limiting for API requests
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import express from "express";
|
||||
import compression from "compression";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import multer from "multer";
|
||||
import { parseWord, PARSER_VERSION } from "./parser.js";
|
||||
import { scheduleFor } from "./schedules.js";
|
||||
import { rateLimit, semaphore } from "./limits.js";
|
||||
import { timetable } from "./timetable.js";
|
||||
import {
|
||||
getIndex,
|
||||
@@ -24,13 +26,21 @@ import {
|
||||
} from "./source.js";
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
// Behind Caddy or a tunnel on this host, the client's address comes from X-Forwarded-For;
|
||||
// only proxies on private networks are believed, so a client cannot forge its own.
|
||||
app.set("trust proxy", "loopback, linklocal, uniquelocal");
|
||||
// gzip or brotli: a semester of lessons is about 200 KB of JSON and shrinks tenfold.
|
||||
app.use(compression());
|
||||
app.use("/api", (_req, res, next) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
// Clients may keep a response but must ask again each time; Express's ETag turns an
|
||||
// unchanged answer into an empty 304, so a phone re-downloads a timetable only when it changed.
|
||||
res.set("Cache-Control", "no-cache");
|
||||
next();
|
||||
});
|
||||
// Raised whenever an endpoint is added or changed, so a deployment is easy to verify:
|
||||
// 2 — /api/timetable for the Android client; 3 — its range=semester.
|
||||
const API_VERSION = 3;
|
||||
// 2 — /api/timetable for the Android client; 3 — its range=semester;
|
||||
// 4 — compression, ETag revalidation, throttled refresh and import limits.
|
||||
const API_VERSION = 4;
|
||||
|
||||
// Liveness plus what the server currently reads from the university, without network calls.
|
||||
app.get("/api/health", async (_req, res) => {
|
||||
@@ -165,11 +175,23 @@ app.get("/api/original/:id", async (req, res, next) => {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
// Parsing runs a LibreOffice conversion for old .doc files: two at a time, a few more wait,
|
||||
// and each address may import ten files in ten minutes.
|
||||
const importLimit = rateLimit({
|
||||
max: 10,
|
||||
windowMs: 10 * 60 * 1000,
|
||||
message: "Слишком много файлов подряд. Попробуйте через несколько минут.",
|
||||
});
|
||||
const parsing = semaphore({
|
||||
limit: 2,
|
||||
queue: 4,
|
||||
message: "Сервер занят разбором других файлов. Попробуйте через минуту.",
|
||||
});
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
|
||||
});
|
||||
app.post("/api/import", upload.single("file"), async (req, res, next) => {
|
||||
app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file)
|
||||
return res.status(400).json({ error: "Выберите файл Word." });
|
||||
@@ -179,9 +201,10 @@ app.post("/api/import", upload.single("file"), async (req, res, next) => {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Поддерживаются файлы .docx и .doc." });
|
||||
res.json(await parseWord(req.file.buffer, { name, uploaded: true }));
|
||||
res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true })));
|
||||
} catch (e) {
|
||||
e.status = 422;
|
||||
// A file that cannot be read is the client's problem (422); a busy server keeps its 429.
|
||||
e.status ??= 422;
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
@@ -200,11 +223,9 @@ app.use((error, _req, res, _next) => {
|
||||
error:
|
||||
error.code === "LIMIT_FILE_SIZE"
|
||||
? "Файл больше 10 МБ. Выберите документ меньшего размера."
|
||||
: error.status === 422
|
||||
: [404, 422, 429].includes(error.status)
|
||||
? error.message
|
||||
: error.status === 404
|
||||
? error.message
|
||||
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
|
||||
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
|
||||
});
|
||||
});
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
|
||||
Reference in New Issue
Block a user