From e28d65310de184595da72ada937780e2c7ef3fe2 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Tue, 20 Oct 2026 23:14:32 +0300 Subject: [PATCH] feat: Implement Telegram alerts for catalog updates and failures --- .gitignore | 1 + README.md | 29 ++++++- docker-compose.yml | 6 ++ server/alerts.js | 175 ++++++++++++++++++++++++++++++++++++++ server/directory.js | 32 +++++-- server/index.js | 18 +++- server/source.js | 137 ++++++++++++++++++++++------- server/watch.js | 43 ++++++++++ test/support/nextcloud.js | 30 ++++++- test/watch.test.js | 113 ++++++++++++++++++++++++ 10 files changed, 541 insertions(+), 43 deletions(-) create mode 100644 server/alerts.js create mode 100644 server/watch.js create mode 100644 test/watch.test.js diff --git a/.gitignore b/.gitignore index af5a37d..3823d79 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ xcuserdata/ DerivedData/ *.xcuserstate +.env diff --git a/README.md b/README.md index 8967d08..3e3b7e5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,34 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` **Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново. -Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована. +**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. В `/api/health` поле `catalog.lastCrawl` показывает последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` показывает время и ошибку последней проверки. + +**Оповещения в Telegram.** Если заданы `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`, сервер пишет владельцу после проверок каталога: +- появился новый документ — с его группами и подозрительными ячейками; +- документ не читается; +- документ перезалит, и в нём появились подозрительные ячейки, которых раньше не было; +- каталог перешёл на новый учебный год, или папка нового года задерживается; +- университет не отвечает дольше часа, а потом снова отвечает. + +Первая проверка только сообщает, что оповещения включены, и называет число документов и подозрительных ячеек. Если сообщение не ушло, оно повторится при следующей проверке. С `PSU_PUBLIC_URL` в сообщениях есть ссылка на `/api/diagnostics` документа. + +Настройка: +1. Создайте бота у @BotFather и получите токен. +2. Напишите боту любое сообщение. +3. Возьмите `chat.id` из ответа `https://api.telegram.org/bot<токен>/getUpdates`. +4. Положите значения в `.env` рядом с `docker-compose.yml` (файл в `.gitignore`): + +```sh +TELEGRAM_BOT_TOKEN=123456:ABC... +TELEGRAM_CHAT_ID=123456789 +PSU_PUBLIC_URL=https://psu.rumblur.by +``` + +5. Перезапустите контейнер: `docker compose up -d`. + +Состояние оповещений — `telegram` в `/api/health`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает. + +Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована. Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ. diff --git a/docker-compose.yml b/docker-compose.yml index d377e21..e31d023 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,12 @@ services: HOST: 0.0.0.0 PORT: 3001 TZ: Europe/Minsk + # Оповещения владельцу в Telegram (см. README): токен бота от @BotFather + # и id чата. Значения удобно держать в файле .env рядом с этим файлом. + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + # Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by + PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-} # Путь к LibreOffice для импорта старых .doc уже есть в образе. volumes: # Кэш каталога, таблицы недель и распознанных документов. diff --git a/server/alerts.js b/server/alerts.js new file mode 100644 index 0000000..539ec0a --- /dev/null +++ b/server/alerts.js @@ -0,0 +1,175 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { DATA } from "./paths.js"; +import { diagnose } from "./diagnostics.js"; + +// Messages to the server's owner in Telegram, so a new document format, a typo the parser cannot +// read or a university that stopped answering is known the same day, not when a student notices. +// Off unless TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set. + +const TOKEN = process.env.TELEGRAM_BOT_TOKEN || ""; +const CHAT = process.env.TELEGRAM_CHAT_ID || ""; +// Tests point this at a local stand-in. +const API = process.env.TELEGRAM_API || "https://api.telegram.org"; +// With the public address, messages link to /api/diagnostics of the document. +const PUBLIC = (process.env.PSU_PUBLIC_URL || "").replace(/\/$/, ""); +// The university is often down for a few minutes; only a longer outage is worth a message. +const DOWN_AFTER = Number(process.env.PSU_ALERT_SOURCE_DOWN_MS ?? 60 * 60 * 1000); +const STATE = "alerts.json"; +const MAX_TEXT = 3900; +const MAX_DOCS = 8; +const MAX_PROBLEMS = 4; +const DAYS = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"]; + +/** For /api/health: whether messages go out and how the last one went. */ +export const telegram = { enabled: Boolean(TOKEN && CHAT), sent: 0, lastSentAt: null, lastError: null }; + +/** What the last message knew: every document with its date and its suspicious cells. */ +let state; +let sourceNotified = false; + +async function load() { + if (state !== undefined) return state; + try { + state = JSON.parse(await readFile(DATA + STATE, "utf8")); + } catch { + state = null; + } + return state; +} + +async function save(next) { + state = next; + await mkdir(DATA, { recursive: true }); + await writeFile(DATA + STATE, JSON.stringify(next)); +} + +/** Sends a message; false when it did not go out (the error is kept for /api/health). */ +export async function send(text) { + if (!telegram.enabled) return false; + try { + const response = await fetch(`${API}/bot${TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: CHAT, + text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n…" : text, + disable_web_page_preview: true, + }), + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(`Telegram ответил ${response.status}: ${(await response.text()).slice(0, 200)}`); + telegram.sent++; + telegram.lastSentAt = new Date().toISOString(); + telegram.lastError = null; + return true; + } catch (error) { + // Never the token in a log or in /api/health. + telegram.lastError = String(error.message).replaceAll(TOKEN, "…"); + console.error(`Telegram: ${telegram.lastError}`); + return false; + } +} + +const signature = (p) => `${p.code}|${(p.groups || []).join(",")}|${p.day ?? ""}|${p.start ?? ""}|${p.raw ?? ""}`; +const link = (id) => (PUBLIC ? `\n${PUBLIC}/api/diagnostics?doc=${id}` : ""); +const problemLine = (p) => + `• ${[(p.groups || []).join(", "), p.day ? `${DAYS[p.day]} ${p.start || ""}`.trim() : ""].filter(Boolean).join(", ")}: ${p.message}` + + (p.raw ? `\n «${p.raw.replace(/\s+/g, " ").slice(0, 90)}»` : ""); + +/** The catalog's documents as the index read them. */ +function snapshot(idx, catalog) { + const docs = {}; + for (const { id, file, schedule } of idx.docs) + docs[id] = { + name: file.name, + faculty: file.faculty || schedule.source.faculty || "", + modified: file.modified, + groups: schedule.groups, + problems: diagnose(schedule), + }; + for (const f of idx.failed) { + const file = catalog.files.find((c) => c.id === f.id); + docs[f.id] = { name: f.name, faculty: file?.faculty || "", modified: file?.modified || null, groups: [], problems: [], failed: f.error }; + } + return docs; +} + +/** + * After every check of the catalog: a message about documents that appeared, documents that do + * not read, republished documents with suspicious cells they did not have, and a new academic + * year. The first run only says that messages are on. A message that does not go out is tried + * again on the next check. + */ +export async function checkCatalog(idx, catalog) { + if (!telegram.enabled || !idx) return; + const was = await load(); + const docs = snapshot(idx, catalog); + const stored = (d) => ({ ...d, problems: d.problems.map(signature) }); + const next = { + docs: Object.fromEntries(Object.entries(docs).map(([id, d]) => [id, stored(d)])), + year: catalog.year, + yearWarning: was?.yearWarning || null, + }; + if (!was) { + const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0); + const failed = Object.values(docs).filter((d) => d.failed).length; + const sent = await send( + `Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` + + (failed ? `, не читаются: ${failed}` : "") + + `, подозрительных ячеек: ${problems}.` + + (PUBLIC ? `\n${PUBLIC}/api/diagnostics` : ""), + ); + if (sent) await save(next); + return; + } + + const sections = []; + for (const [id, now] of Object.entries(docs)) { + const before = was.docs?.[id]; + if (before && before.modified === now.modified && (before.failed || null) === (now.failed || null)) continue; + const title = `${now.name}${now.faculty ? ` (${now.faculty})` : ""}`; + if (now.failed) { + sections.push(`Не читается: ${title}\n${now.failed}`); + continue; + } + const known = new Set(before?.problems || []); + const added = now.problems.filter((p) => !known.has(signature(p))); + const lines = added.slice(0, MAX_PROBLEMS).map(problemLine); + if (added.length > MAX_PROBLEMS) lines.push(`• и ещё ${added.length - MAX_PROBLEMS}`); + if (!before) + sections.push( + `Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` + + (added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}` : "") + + (added.length ? link(id) : ""), + ); + else if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`); + } + if (was.year && catalog.year && was.year !== catalog.year) sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`); + // The new year's folder is late: the catalog still shows last year's documents. + if (catalog.currentYear === false && catalog.expectedYear && was.yearWarning !== catalog.expectedYear) { + sections.unshift(`Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`); + next.yearWarning = catalog.expectedYear; + } + + if (!sections.length) return save(next); + const shown = sections.slice(0, MAX_DOCS); + if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`); + if (await send(`Расписание ПГУ\n\n${shown.join("\n\n")}`)) await save(next); +} + +/** + * The university's catalog failing for longer than an hour, and its return. Kept in memory: after + * a restart a long outage is reported again, which is better than missing it. + */ +export async function checkSource(failure) { + if (!telegram.enabled) return; + if (failure) { + const since = Date.parse(failure.since); + if (!sourceNotified && Number.isFinite(since) && Date.now() - since >= DOWN_AFTER) { + const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" }); + sourceNotified = await send( + `Каталог университета не отвечает с ${at}.\n${failure.error}\nПриложения показывают сохранённые копии расписаний.`, + ); + } + } else if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false; +} diff --git a/server/directory.js b/server/directory.js index b42db13..d5957c0 100644 --- a/server/directory.js +++ b/server/directory.js @@ -154,13 +154,13 @@ async function build(catalog) { try { docs.push({ id: file.id, file, schedule: await scheduleFor(file) }); } catch (error) { - failed.push({ name: file.name, error: error.message }); + failed.push({ id: file.id, name: file.name, error: error.message }); } } }), ); index = { - catalogAt: catalog.updatedAt, + catalogVersion: versionOf(catalog), year: catalog.year, builtAt: new Date().toISOString(), total: catalog.files.length, @@ -171,16 +171,36 @@ async function build(catalog) { return index; } -/** The current index; rebuilt in the background when the catalog changes. */ -export async function getIndex() { - const catalog = await getCatalog(); - if ((!index || index.catalogAt !== catalog.updatedAt) && !building) +// A catalog's documents and their dates; older cached catalogs have only the time of the crawl. +const versionOf = (catalog) => catalog.version || catalog.updatedAt; + +function rebuild(catalog) { + if (!building) building = build(catalog).finally(() => { building = null; }); + return building; +} + +/** + * The current index; rebuilt in the background when a document of the catalog changes. A check + * that finds the catalog as it was keeps the index, so its ETags, and the clients' caches, hold. + */ +export async function getIndex() { + const catalog = await getCatalog(); + if (!index || index.catalogVersion !== versionOf(catalog)) rebuild(catalog); if (!index) await building; return index; } + +/** The index of the catalog as it is now, waiting for the rebuild a change needs (the watcher). */ +export async function syncIndex(catalog) { + if (!index || index.catalogVersion !== versionOf(catalog)) { + if (building) await building; + if (!index || index.catalogVersion !== versionOf(catalog)) await rebuild(catalog); + } + return index; +} export const indexStatus = () => index ? { diff --git a/server/index.js b/server/index.js index 6697093..1dc0fd2 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,8 @@ import { createHash } from "node:crypto"; import { timetable } from "./timetable.js"; import { catalogDiagnostics } from "./diagnostics.js"; import { securityHeaders, requestTimeout } from "./protect.js"; +import { startWatch, watchOnce, watchStatus } from "./watch.js"; +import { telegram } from "./alerts.js"; import { getIndex, indexStatus, @@ -67,8 +69,9 @@ app.use("/api", (_req, res, next) => { // 7 — /api/diagnostics, cells the parser could not read with confidence; // 8 — /api/ready, and `ready` in /api/health; // 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers; -// 10 — parserVersion in /api/catalog. -const API_VERSION = 10; +// 10 — parserVersion in /api/catalog; +// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health. +const API_VERSION = 11; /** * What the server still waits for before it answers from memory: the catalog, the week table @@ -89,6 +92,9 @@ app.get("/api/health", async (req, res) => { parserVersion: PARSER_VERSION, ...sources, directory: indexStatus(), + // The server's own checks of the catalog and its messages to the owner. + watch: watchStatus, + telegram: { enabled: telegram.enabled, sent: telegram.sent, lastSentAt: telegram.lastSentAt, lastError: telegram.lastError }, }); }); // Readiness for Docker and a proxy: 503 until the catalog, the week table and the index are @@ -309,7 +315,13 @@ export function start(port = Number(process.env.PORT || 3001), host = process.en console.log(`API и PWA: http://127.0.0.1:${port}`); // 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}`)); + getIndex() + .catch((e) => console.error(`Индекс: ${e.message}`)) + // Then the catalog is watched every few minutes, with messages in Telegram if set up. + .finally(() => { + watchOnce(); + startWatch(); + }); }); // A client must send its headers within 15 s and the whole request (a Word upload of up to // 10 MB on a slow phone) within two minutes, so slow connections cannot pile up. diff --git a/server/source.js b/server/source.js index 5464882..4334638 100644 --- a/server/source.js +++ b/server/source.js @@ -16,21 +16,25 @@ const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/"; const YEAR_FOLDER = /^(\d{4})-(\d{4})$/; const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false }); const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []); +// A check of an unchanged catalog costs two listings (see crawl), so it can run every 5 minutes. const DISCOVERY_TTL = 15 * 60 * 1000, - CATALOG_TTL = 15 * 60 * 1000, + CATALOG_TTL = 5 * 60 * 1000, CALENDAR_TTL = 6 * 60 * 60 * 1000; +// Even with unchanged ETags the whole catalog is listed again this often, in case the university's +// server ever fails to pass a change up to the parent folders. +const FULL_CRAWL = 6 * 60 * 60 * 1000; // A forced refresh (?refresh=1, the PWA's "Обновить") reaches the university at most this often; -// sooner, the copy just read is the answer. Anyone can send the request, and one catalog crawl +// sooner, the copy just read is the answer. Anyone can send the request, and a full catalog crawl // is up to 180 listings of the university's Nextcloud. -export const MIN_REFRESH = 2 * 60 * 1000; +export const MIN_REFRESH = process.env.PSU_MIN_REFRESH_MS ? Number(process.env.PSU_MIN_REFRESH_MS) : 2 * 60 * 1000; const CALENDAR_MIN_REFRESH = 10 * 60 * 1000; /** Milliseconds since an ISO time; Infinity for a missing or broken one. */ const ageOf = (iso) => { const age = Date.now() - new Date(iso).getTime(); return Number.isFinite(age) ? age : Infinity; }; -let catalogue, pending, calendar, calendarPending, sources, sourcesPending; -const status = { catalogError: null, calendarError: null }; +let catalogue, pending, calendar, calendarPending, sources, sourcesPending, tree; +const status = { catalogError: null, calendarError: null, catalogErrorSince: null, lastCrawl: null }; // When the university last failed to answer: background refreshes back off for MIN_REFRESH, // so a down source is not crawled again on every request. const failedAt = { catalog: 0, calendar: 0 }; @@ -88,6 +92,8 @@ export function decodeListing(xml, root = SHARE) { folder: props.resourcetype?.collection !== undefined, modified: props.getlastmodified || null, size: Number(props.getcontentlength || 0), + // Nextcloud changes a folder's ETag whenever anything below it changes. + etag: props.getetag ? String(props.getetag) : null, }; }) .filter((e) => typeof e.path === "string" && e.path.startsWith(root)); @@ -175,10 +181,12 @@ async function discover(refresh = false) { const yearEntries = decodeListing( (await request(yearRoot, "PROPFIND")).toString(), ).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase()); + const folder = root.find((e) => nameOf(e.path) === year.name); return (sources = { checkedAt: Date.now(), year, - yearRoot: root.find((e) => nameOf(e.path) === year.name)?.path || yearRoot, + yearRoot: folder?.path || yearRoot, + yearEntries, references: pickReferences(root, yearEntries), }); })().finally(() => { @@ -187,43 +195,104 @@ async function discover(refresh = false) { return sourcesPending; } -async function crawl() { - const source = await discover(true); - let queue = [source.yearRoot], - files = [], - count = 0; - while (queue.length) { - if (count + queue.length > 180) - throw apiError(502, "source_invalid", "Каталог университета слишком большой."); - const batch = queue.splice(0, 5); - count += batch.length; - const results = await Promise.all( - batch.map(async (path) => - decodeListing((await request(path, "PROPFIND")).toString()).filter( - (e) => e.path.toLowerCase() !== path.toLowerCase(), - ), - ), - ); - for (const entries of results) - for (const entry of entries) { - if (entry.folder) queue.push(entry.path); - else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path)) - files.push(fileMetadata(entry)); - } +const TREE = "catalog-tree.json"; +async function readTree() { + if (tree !== undefined) return; + try { + tree = JSON.parse(await readFile(DATA + TREE, "utf8")); + } catch { + tree = null; } +} + +/** A short fingerprint of the catalog's documents: it changes only when a file does. */ +export const catalogVersion = (files, year) => + createHash("sha1") + .update([year, ...files.map((f) => `${f.id}:${f.modified}`).sort()].join("|")) + .digest("hex") + .slice(0, 16); + +/** + * Lists the year folder and everything below it. Folder listings of the last crawl are kept with + * each folder's ETag: a folder whose ETag is unchanged is taken from there, subfolders included, + * without a request. A check of an unchanged catalog therefore costs only the two listings + * discover makes; a changed document costs the folders on the way to it. + */ +async function crawl() { + const started = Date.now(); + const source = await discover(true); + await readTree(); + const full = !tree || Date.now() - (tree.fullAt || 0) >= FULL_CRAWL; + const known = full ? {} : tree.folders || {}; + const folders = {}; + const files = []; + let queue = [], + requested = 0, + reused = 0; + const visit = (entries) => { + for (const entry of entries) { + if (entry.folder) queue.push(entry); + else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path)) files.push(fileMetadata(entry)); + } + }; + // discover has just listed the year folder itself. + folders[source.yearRoot] = { entries: source.yearEntries }; + visit(source.yearEntries); + while (queue.length) { + const fetch = []; + for (const entry of queue.splice(0)) { + const cached = known[entry.path]; + if (cached && entry.etag && cached.etag === entry.etag) { + folders[entry.path] = cached; + reused++; + visit(cached.entries); + } else fetch.push(entry); + } + for (let i = 0; i < fetch.length; i += 5) { + if (requested + fetch.length - i > 180) + throw apiError(502, "source_invalid", "Каталог университета слишком большой."); + const batch = fetch.slice(i, i + 5); + requested += batch.length; + const listed = await Promise.all( + batch.map(async ({ path }) => + decodeListing((await request(path, "PROPFIND")).toString()).filter( + (e) => e.path.toLowerCase() !== path.toLowerCase(), + ), + ), + ); + batch.forEach((entry, k) => { + folders[entry.path] = { etag: entry.etag, entries: listed[k] }; + visit(listed[k]); + }); + } + } + const docs = files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)); const result = { // Week tables and bell schedules are reference documents, not timetables. - files: files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)), + files: docs, otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length, + version: catalogVersion(docs, source.year.label), updatedAt: new Date().toISOString(), stale: false, year: source.year.label, currentYear: source.year.current, expectedYear: source.year.expected.replace("-", "–"), }; + tree = { fullAt: full ? Date.now() : tree.fullAt, folders }; await mkdir(DATA, { recursive: true }); + await writeFile(DATA + TREE, JSON.stringify(tree)); await writeFile(DATA + "catalog.json", JSON.stringify(result)); status.catalogError = null; + status.catalogErrorSince = null; + status.lastCrawl = { + at: result.updatedAt, + full, + // Two listings come from discover: the share's root and the year folder. + requests: requested + 2, + folders: Object.keys(folders).length, + reused, + ms: Date.now() - started, + }; return (catalogue = result); } async function readCatalogCache() { @@ -265,6 +334,7 @@ export async function getCatalog(refresh = false) { .catch((error) => { failedAt.catalog = Date.now(); status.catalogError = error.message; + status.catalogErrorSince ||= new Date().toISOString(); if (catalogue) return { ...catalogue, @@ -368,6 +438,10 @@ export async function getCalendar(refresh = false) { return calendarPending; } +/** Since when the university's catalog has been failing, with the error; null while it answers. */ +export const catalogFailure = () => + status.catalogError ? { error: status.catalogError, since: status.catalogErrorSince } : null; + /** What the server currently uses, for /api/health. Never touches the network. */ export async function sourceStatus(today = todayIso()) { await Promise.all([readCatalogCache(), readCalendarCache()]); @@ -399,6 +473,9 @@ export async function sourceStatus(today = todayIso()) { updatedAt: catalogue.updatedAt, ageMinutes: age(catalogue.updatedAt), stale: Boolean(catalogue.stale || status.catalogError), + // The last check: how many listings it cost and how many folders came from the cache. + lastCrawl: status.lastCrawl, + failingSince: status.catalogErrorSince, } : null, calendar: calendar diff --git a/server/watch.js b/server/watch.js new file mode 100644 index 0000000..9b9f90c --- /dev/null +++ b/server/watch.js @@ -0,0 +1,43 @@ +import { getCatalog, catalogFailure } from "./source.js"; +import { syncIndex } from "./directory.js"; +import { checkCatalog, checkSource } from "./alerts.js"; + +// The server checks the university's catalog by itself, not only when a request asks: a +// republished document is parsed within minutes, the clients' next refresh gets it, and the +// owner hears about problems (see alerts.js). An unchanged catalog costs two listings. + +const INTERVAL = Number(process.env.PSU_WATCH_INTERVAL_MS) || 5 * 60 * 1000; + +/** For /api/health. */ +export const watchStatus = { intervalMinutes: INTERVAL / 60000, lastAt: null, lastError: null }; + +let running = null; +/** One check: the catalog (refreshed), the index of a changed catalog, the messages. */ +export function watchOnce() { + // A slow university must not stack checks on top of each other. + running ||= check().finally(() => { + running = null; + }); + return running; +} + +async function check() { + try { + const catalog = await getCatalog(true); + // A failed crawl answers with the saved catalog: nothing new to read or report then. + if (!catalog.stale) await checkCatalog(await syncIndex(catalog), catalog); + watchStatus.lastError = catalog.stale ? catalog.error || null : null; + } catch (error) { + watchStatus.lastError = error.message; + } + await checkSource(catalogFailure()); + watchStatus.lastAt = new Date().toISOString(); +} + +let timer = null; +/** Checks every few minutes (PSU_WATCH_INTERVAL_MS) for as long as the process runs. */ +export function startWatch() { + if (timer) return; + timer = setInterval(watchOnce, INTERVAL); + timer.unref(); +} diff --git a/test/support/nextcloud.js b/test/support/nextcloud.js index 6fcf303..8ddc351 100644 --- a/test/support/nextcloud.js +++ b/test/support/nextcloud.js @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { createHash } from "node:crypto"; import { readFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +23,12 @@ const files = { [`${FOLDER}Медленный.docx`]: "slow", }; const folders = [SHARE, `${SHARE}${YEAR}/`, `${SHARE}${YEAR}/Финансово-экономический факультет/`, FOLDER]; +// Dates of files changed during a test; the others keep MODIFIED. +const dates = {}; +const dateOf = (path) => dates[path] || MODIFIED; +// As in Nextcloud, a folder's ETag changes whenever anything below it does. +const etagOf = (folder) => + `"${createHash("sha1").update(Object.keys(files).filter((f) => f.startsWith(folder)).sort().map((f) => f + dateOf(f)).join("|")).digest("hex").slice(0, 13)}"`; const encode = (path) => path.split("/").map(encodeURIComponent).join("/"); const listing = (folder) => { @@ -32,19 +39,23 @@ const listing = (folder) => { const size = Buffer.isBuffer(files[path]) ? files[path].length : 1000; return `${encode(path)}${ folder ? "" : `${size}` - }${MODIFIED}HTTP/1.1 200 OK`; + }${dateOf(path)}${folder ? etagOf(path) : `"${dateOf(path)}"`}HTTP/1.1 200 OK`; }; return `${[folder, ...children].map(entry).join("")}`; }; /** * Starts the stand-in and the API server; `env` adds settings such as lower limits. - * Returns the server's base URL, request statistics of the stand-in and `stop()`. + * Returns the server's base URL, request statistics of the stand-in, `catalog` to change the + * stand-in's documents during a test, and `stop()`. */ export async function startApi(env = {}) { - const stats = { requests: 0, connections: 0 }; + const stats = { requests: 0, connections: 0, listings: 0 }; + let down = false; const source = createServer((req, res) => { stats.requests++; + if (req.method === "PROPFIND") stats.listings++; + if (down) return res.writeHead(503).end(); const path = decodeURIComponent(req.url); if (req.method === "PROPFIND" && folders.includes(path)) { res.writeHead(207, { "Content-Type": "application/xml" }); @@ -67,9 +78,22 @@ export async function startApi(env = {}) { const { app } = await import("../../server/index.js"); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.on("listening", resolve)); + let tick = 0; return { base: `http://127.0.0.1:${server.address().port}`, stats, + catalog: { + /** Publishes a document in the faculty folder (or republishes it) with a new date. */ + put(name, body) { + const path = FOLDER + name; + files[path] = body; + dates[path] = new Date(Date.parse(MODIFIED) + ++tick * 60_000).toUTCString(); + }, + /** The university stops (true) or starts again (false) answering. */ + down(on) { + down = on; + }, + }, stop() { server.close(); server.closeAllConnections(); diff --git a/test/watch.test.js b/test/watch.test.js new file mode 100644 index 0000000..73cc37b --- /dev/null +++ b/test/watch.test.js @@ -0,0 +1,113 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { startApi, fixture } from "./support/nextcloud.js"; + +// The server's own checks of the catalog: listings only of folders whose ETag changed, the index +// rebuilt only for a changed document, and messages to the owner in Telegram (a stand-in here). +let api, telegram, watch; +const messages = []; +const health = async () => (await fetch(api.base + "/api/health")).json(); +const lastMessage = () => messages.at(-1)?.text || ""; + +before(async () => { + telegram = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + messages.push({ path: req.url, ...JSON.parse(body) }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: true })); + }); + }); + await new Promise((resolve) => telegram.listen(0, "127.0.0.1", resolve)); + api = await startApi({ + TELEGRAM_BOT_TOKEN: "test-token", + TELEGRAM_CHAT_ID: "42", + TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`, + PSU_PUBLIC_URL: "https://psu.example", + PSU_ALERT_SOURCE_DOWN_MS: "0", + // Every check reaches the stand-in, however soon after the last one. + PSU_MIN_REFRESH_MS: "0", + }); + watch = await import("../server/watch.js"); +}); +after(() => { + api?.stop(); + telegram?.close(); +}); + +test("Наблюдение: первая проверка обходит весь каталог и включает оповещения", async () => { + await watch.watchOnce(); + const { catalog, directory, telegram: tg, watch: status } = await health(); + assert.equal(catalog.lastCrawl.full, true); + assert.equal(directory.documents, 2); + assert.equal(tg.enabled, true); + assert.equal(status.lastError, null); + assert.equal(messages.length, 1); + assert.equal(messages[0].path, "/bottest-token/sendMessage"); + assert.equal(messages[0].chat_id, "42"); + assert.match(lastMessage(), /Оповещения расписания ПГУ включены/); + assert.match(lastMessage(), /2 документов, не читаются: 2/); +}); + +test("Наблюдение: неизменный каталог — два листинга, индекс и ETag расписаний те же", async () => { + const before = await health(); + const listings = api.stats.listings; + await watch.watchOnce(); + const after = await health(); + assert.equal(api.stats.listings - listings, 2); + assert.equal(after.catalog.lastCrawl.requests, 2); + assert.equal(after.catalog.lastCrawl.full, false); + assert.ok(after.catalog.lastCrawl.reused >= 1); + assert.equal(after.directory.builtAt, before.directory.builtAt); + assert.equal(messages.length, 1); +}); + +test("Наблюдение: новый документ — листинг только изменившихся папок и сообщение", async () => { + const listings = api.stats.listings; + api.catalog.put("26-АРХ.docx", fixture("isf-credits-26-arh.docx")); + await watch.watchOnce(); + const { catalog, directory } = await health(); + // The share's root, the year folder, the faculty folder and the day-time folder. + assert.equal(api.stats.listings - listings, 4); + assert.equal(catalog.lastCrawl.requests, 4); + assert.equal(directory.documents, 3); + assert.equal(messages.length, 2); + assert.match(lastMessage(), /Новый документ: 26-АРХ\.docx/); + assert.match(lastMessage(), /Группы: 26-АРХ/); + const groups = await (await fetch(api.base + "/api/groups?" + new URLSearchParams({ q: "26-АРХ" }))).json(); + assert.ok(groups.results.some((g) => g.group === "26-АРХ")); +}); + +test("Наблюдение: перезалитый документ с новыми подозрительными ячейками", async () => { + // 23-БА's "2,6-12-16н" lands in a document that had no such cell. + api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx")); + await watch.watchOnce(); + assert.equal(messages.length, 3); + assert.match(lastMessage(), /Перезалит с новыми подозрительными ячейками: 24-ИТ\.docx/); + assert.match(lastMessage(), /2,6-12-16н/); + assert.match(lastMessage(), /https:\/\/psu\.example\/api\/diagnostics\?doc=/); + // Republished again as it is: nothing new to say. + api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx")); + await watch.watchOnce(); + assert.equal(messages.length, 3); +}); + +test("Наблюдение: университет не отвечает и снова отвечает", async () => { + api.catalog.down(true); + await watch.watchOnce(); + const { watch: status, catalog } = await health(); + assert.ok(status.lastError); + assert.ok(catalog.failingSince); + assert.equal(messages.length, 4); + assert.match(lastMessage(), /Каталог университета не отвечает/); + // Still down: one message per outage. + await watch.watchOnce(); + assert.equal(messages.length, 4); + api.catalog.down(false); + await watch.watchOnce(); + assert.equal(messages.length, 5); + assert.match(lastMessage(), /снова отвечает/); + assert.equal((await health()).watch.lastError, null); +});