Files
psu-schedule/server/index.js

239 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
indexStatus,
search,
dayOf,
freeRooms,
examsOf,
findGroups,
directoryCalendar,
todayIso,
} from "./directory.js";
import {
getCatalog,
getCalendar,
documentFor,
request,
sourceStatus,
} 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) => {
// 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;
// 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) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
res.json({
ok: true,
apiVersion: API_VERSION,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),
});
});
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
const badRequest = (res, error) => res.status(400).json({ error });
// University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => {
try {
res.json(search(await getIndex(), String(req.query.q || "").slice(0, 60)));
} catch (e) {
next(e);
}
});
app.get("/api/directory/day", async (req, res, next) => {
try {
const date = req.query.date || todayIso();
const teacher = String(req.query.teacher || "").slice(0, 80),
room = String(req.query.room || "").slice(0, 20);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
if (!teacher && !room) return badRequest(res, "Укажите преподавателя или аудиторию.");
res.json(dayOf(await getIndex(), await directoryCalendar(), { teacher, room }, date));
} catch (e) {
next(e);
}
});
app.get("/api/directory/free", async (req, res, next) => {
try {
const date = req.query.date || todayIso(),
pair = Number(req.query.pair);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const calendar = await directoryCalendar();
const result = freeRooms(await getIndex(), calendar, date, pair);
if (!result) return badRequest(res, "Нет такой пары в расписании звонков.");
res.json(result);
} catch (e) {
next(e);
}
});
app.get("/api/groups", async (req, res, next) => {
try {
const text = (name) => String(req.query[name] || "").slice(0, 120);
res.json(
findGroups(await getIndex(), {
q: text("q"),
faculty: text("faculty"),
course: text("course"),
form: text("form"),
}),
);
} catch (e) {
next(e);
}
});
// A group's lessons by date with weeks applied: for native and other clients.
app.get("/api/timetable", async (req, res, next) => {
try {
const doc = String(req.query.doc || ""),
group = String(req.query.group || "").slice(0, 40),
from = req.query.from || todayIso();
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const file = await documentFor(doc);
res.json(
timetable({
file,
schedule: await scheduleFor(file),
calendar: await directoryCalendar(),
group,
from,
days: req.query.days,
range: req.query.range === "semester" ? "semester" : undefined,
today: todayIso(),
}),
);
} catch (e) {
next(e);
}
});
app.get("/api/exams", async (req, res, next) => {
try {
const group = String(req.query.group || "").slice(0, 40);
if (!group) return badRequest(res, "Укажите группу.");
res.json({
group,
exams: examsOf(await getIndex(), group, String(req.query.faculty || "") || null),
});
} catch (e) {
next(e);
}
});
app.get("/api/catalog", async (req, res, next) => {
try {
res.json(await getCatalog(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/calendar", async (req, res, next) => {
try {
res.json(await getCalendar(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/schedule/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.json(await scheduleFor(file, { refresh: req.query.refresh === "1" }));
} catch (e) {
next(e);
}
});
app.get("/api/original/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.set("Content-Type", "application/octet-stream");
res.set(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
);
res.send(await request(file.path));
} catch (e) {
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", importLimit, upload.single("file"), async (req, res, next) => {
try {
if (!req.file)
return res.status(400).json({ error: "Выберите файл 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." });
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);
}
});
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.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
: "Не удалось загрузить расписание. Проверьте подключение и попробуйте ещё раз.",
});
});
const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`API и PWA: http://127.0.0.1:${port}`);
// Read the week table and parse the whole catalog in the background,
// so the first visit and the first search do not wait for the university.
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
});