Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-10-19 19:36:19 +03:00
parent 170a44c377
commit 5f7bffd6e4
17 changed files with 454 additions and 85 deletions

View File

@@ -5,6 +5,8 @@ import multer from "multer";
import { parseWord, PARSER_VERSION } from "./parser.js";
import { scheduleFor } from "./schedules.js";
import { rateLimit, semaphore } from "./limits.js";
import { apiError, describe } from "./errors.js";
import { createHash } from "node:crypto";
import { timetable } from "./timetable.js";
import {
getIndex,
@@ -39,8 +41,9 @@ app.use("/api", (_req, res, 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;
// 4 — compression, ETag revalidation, throttled refresh and import limits.
const API_VERSION = 4;
// 4 — compression, ETag revalidation, throttled refresh and import limits;
// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work.
const API_VERSION = 5;
// Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => {
@@ -54,7 +57,7 @@ app.get("/api/health", async (_req, res) => {
});
});
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
const badRequest = (res, error) => res.status(400).json({ error });
const badRequest = (res, error) => res.status(400).json({ error, code: "bad_request" });
// University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => {
try {
@@ -112,16 +115,28 @@ app.get("/api/timetable", async (req, res, next) => {
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const file = await documentFor(doc);
const calendar = await directoryCalendar();
const range = req.query.range === "semester" ? "semester" : undefined;
const today = todayIso();
// The answer follows only from these: the document's version, the parser and the server
// that read it, the week table, the request and the date. An unchanged one comes back
// as an empty 304 before the document is opened or a single day is computed.
const etag = 'W/"tt-' + createHash("sha1")
.update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, group, from, req.query.days, range, today].join("|"))
.digest("base64url")
.slice(0, 22) + '"';
res.set("ETag", etag);
if (req.fresh) return res.status(304).end();
res.json(
timetable({
file,
schedule: await scheduleFor(file),
calendar: await directoryCalendar(),
calendar,
group,
from,
days: req.query.days,
range: req.query.range === "semester" ? "semester" : undefined,
today: todayIso(),
range,
today,
}),
);
} catch (e) {
@@ -194,39 +209,30 @@ const upload = multer({
app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => {
try {
if (!req.file)
return res.status(400).json({ error: "Выберите файл Word." });
return badRequest(res, "Выберите файл Word.");
// Multer decodes multipart names as latin1 by default.
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
if (!/\.docx?$/i.test(name))
return res
.status(400)
.json({ error: "Поддерживаются файлы .docx и .doc." });
return badRequest(res, "Поддерживаются файлы .docx и .doc.");
res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true })));
} catch (e) {
// A file that cannot be read is the client's problem (422); a busy server keeps its 429.
e.status ??= 422;
next(e);
// A file that cannot be read is the client's problem; a busy server keeps its 429.
next(e.status || e.code === "LIMIT_FILE_SIZE" ? e : apiError(422, "unreadable_file", e.message));
}
});
const dist = fileURLToPath(new URL("../dist/", import.meta.url));
app.use(express.static(dist, { maxAge: 0 }));
app.get("/{*path}", (req, res, next) =>
req.path.startsWith("/api/")
? res.status(404).json({ error: "Адрес не найден." })
? res.status(404).json({ error: "Адрес не найден.", code: "unknown_endpoint" })
: res.sendFile(dist + "index.html", (err) => err && next(err)),
);
app.use((error, _req, res, _next) => {
console.error(error.message);
res
.status(error.code === "LIMIT_FILE_SIZE" ? 413 : error.status || 503)
.json({
error:
error.code === "LIMIT_FILE_SIZE"
? "Файл больше 10 МБ. Выберите документ меньшего размера."
: [404, 422, 429].includes(error.status)
? error.message
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
});
app.use((error, req, res, _next) => {
const { status, code, message } = describe(error);
// Faults of the server keep their stack; expected errors need a line.
if (status >= 500 && code === "internal") console.error(req.method, req.originalUrl, error);
else console.error(`${req.method} ${req.originalUrl} → ${status} ${code}: ${error.message}`);
res.status(status).json({ error: message, code });
});
const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => {