From a597445e80c11a78841dfb479f9242ce05761eab Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:05:11 +0500 Subject: [PATCH 01/10] fix(server): make CORS origin matching exact --- server/src/app.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index cd097ff..e197ecd 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,14 +11,24 @@ import { logger } from "./utils/logger.utils"; const app = express(); const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(",") - : ["https://almaquake-production.up.railway.app", "exp://", "http://localhost"]; + ? process.env.ALLOWED_ORIGINS.split(",").map((origin) => origin.trim()).filter(Boolean) + : ["https://almaquake-production.up.railway.app"]; + +const isAllowedOrigin = (origin?: string): boolean => { + // Native/mobile clients may omit the Origin header entirely. + if (!origin) return true; + + // Expo development clients use exp:// URLs. Keep this explicit instead of + // accepting arbitrary strings that merely share a prefix with an allowlist entry. + if (origin.startsWith("exp://")) return true; + + return ALLOWED_ORIGINS.includes(origin); +}; app.use( cors({ origin: (origin, cb) => { - // allow mobile apps (no origin) and listed origins - if (!origin || ALLOWED_ORIGINS.some((o) => origin.startsWith(o))) { + if (isAllowedOrigin(origin)) { cb(null, true); } else { cb(null, false); From be125d7853b583878b64159d6cdbe055b95ca858 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:11:49 +0500 Subject: [PATCH 02/10] refactor(server): validate runtime configuration --- server/src/config/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 31d942a..58c56fd 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -1,6 +1,17 @@ +const parsePositiveInt = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + export const config = { almaty: { lat: 43.2565, lng: 76.9286 }, - quake: { radiusKm: 300, minMagnitude: 2.5, limit: 100 }, - poll: { intervalMs: 60_000 }, - port: Number(process.env.PORT) || 3000, + quake: { + radiusKm: parsePositiveInt(process.env.QUAKE_RADIUS_KM, 300), + minMagnitude: Number(process.env.QUAKE_MIN_MAGNITUDE) || 2.5, + limit: parsePositiveInt(process.env.QUAKE_LIMIT, 100), + }, + poll: { + intervalMs: parsePositiveInt(process.env.POLL_INTERVAL_MS, 60_000), + }, + port: parsePositiveInt(process.env.PORT, 3000), } as const; From ce36c525bf80409dd387f1d7dbfb2f43e2a7860c Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:11:59 +0500 Subject: [PATCH 03/10] docs(server): document runtime tuning variables --- server/.env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/.env.example b/server/.env.example index 43a1c73..9f1beb4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -3,5 +3,10 @@ ADMIN_SECRET=replace-with-a-long-random-value TELEGRAM_BOT_TOKEN= FIREBASE_CONFIG= ALLOWED_ORIGINS=https://almaquake-production.up.railway.app +# Optional tuning; safe defaults are used when omitted. +QUAKE_RADIUS_KM=300 +QUAKE_MIN_MAGNITUDE=2.5 +QUAKE_LIMIT=100 +POLL_INTERVAL_MS=60000 # Railway: attach a Volume mounted at /data, then set this variable. DATA_DIR=/data From a8b419d17313429cd2aa7539661ff2cf9108cd51 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:30:56 +0500 Subject: [PATCH 04/10] feat(server): add graceful HTTP shutdown --- server/src/utils/shutdown.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 server/src/utils/shutdown.ts diff --git a/server/src/utils/shutdown.ts b/server/src/utils/shutdown.ts new file mode 100644 index 0000000..1806965 --- /dev/null +++ b/server/src/utils/shutdown.ts @@ -0,0 +1,27 @@ +import type { Server } from "node:http"; +import { logger } from "./logger.utils"; + +export const registerGracefulShutdown = (server: Server): void => { + let shuttingDown = false; + + const shutdown = (signal: string): void => { + if (shuttingDown) return; + shuttingDown = true; + + logger.info(`Received ${signal}; shutting down gracefully`); + + server.close((error) => { + if (error) { + logger.error("HTTP server shutdown failed", error); + process.exitCode = 1; + return; + } + + logger.info("HTTP server closed"); + process.exitCode = 0; + }); + }; + + process.once("SIGTERM", () => shutdown("SIGTERM")); + process.once("SIGINT", () => shutdown("SIGINT")); +}; From ecf8b14268fc693e9e5b6d169a4e1115063448f3 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:34:01 +0500 Subject: [PATCH 05/10] feat(server): enable graceful HTTP shutdown --- server/src/app.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index e197ecd..8affb52 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -7,6 +7,7 @@ import { startTelegramBot } from "./services/telegram.service"; import { config } from "./config"; import { logStorageConfiguration } from "./services/storage.service"; import { logger } from "./utils/logger.utils"; +import { registerGracefulShutdown } from "./utils/shutdown"; const app = express(); @@ -15,41 +16,31 @@ const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS : ["https://almaquake-production.up.railway.app"]; const isAllowedOrigin = (origin?: string): boolean => { - // Native/mobile clients may omit the Origin header entirely. if (!origin) return true; - - // Expo development clients use exp:// URLs. Keep this explicit instead of - // accepting arbitrary strings that merely share a prefix with an allowlist entry. if (origin.startsWith("exp://")) return true; - return ALLOWED_ORIGINS.includes(origin); }; app.use( cors({ origin: (origin, cb) => { - if (isAllowedOrigin(origin)) { - cb(null, true); - } else { - cb(null, false); - } + if (isAllowedOrigin(origin)) cb(null, true); + else cb(null, false); }, }) ); app.use(express.json({ limit: "10kb" })); - -// Mount central API router app.use("/api", apiRouter); - -// Centralized Error Handling Middleware app.use(errorHandlerMiddleware); -app.listen(config.port, () => { +const server = app.listen(config.port, () => { logStorageConfiguration(); logger.info(`🟢 Server started on port ${config.port}`); startMonitorJob(); startTelegramBot(); }); +registerGracefulShutdown(server); + export default app; From 7e81663a8239a91a2ff2910425257c35bdf7c74b Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:37:39 +0500 Subject: [PATCH 06/10] fix(server): make earthquake monitor scheduling bounded --- server/src/jobs/monitor.job.ts | 61 +++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/server/src/jobs/monitor.job.ts b/server/src/jobs/monitor.job.ts index aacca6b..366138a 100644 --- a/server/src/jobs/monitor.job.ts +++ b/server/src/jobs/monitor.job.ts @@ -1,44 +1,67 @@ -import { fetchQuakes } from "../services/usgs.service"; -import { sendQuakeAlert } from "../services/telegram.service"; -import { config } from "../config"; +import { fetchQuakes } from "../services/usgs.service"; +import { sendQuakeAlert } from "../services/telegram.service"; +import { config } from "../config"; import { dataFile, readJson, writeJson } from "../services/storage.service"; +import { logger } from "../utils/logger.utils"; const STATE_FILE = dataFile("monitor-state.json"); -let seenIds = new Set(readJson(STATE_FILE, [])); +const MAX_SEEN_IDS = 200; + +let seenIds = new Set(readJson(STATE_FILE, []).slice(0, MAX_SEEN_IDS)); let running = false; +let timer: ReturnType | undefined; +let stopped = false; + +function rememberLatest(quakeIds: string[]): void { + seenIds = new Set(quakeIds.slice(0, MAX_SEEN_IDS)); + writeJson(STATE_FILE, [...seenIds]); +} export async function runMonitor(): Promise { - if (running) return; + if (running || stopped) return; running = true; try { const quakes = await fetchQuakes(); - if (quakes.length === 0) { - return; - } + if (quakes.length === 0) return; if (seenIds.size === 0) { - seenIds = new Set(quakes.map((quake) => quake.id)); - writeJson(STATE_FILE, [...seenIds]); - console.log(`📌 Монитор запущен. Последнее событие: ${quakes[0].id} (M${quakes[0].magnitude})`); + rememberLatest(quakes.map((quake) => quake.id)); + logger.info(`Monitor initialized with latest event ${quakes[0].id} (M${quakes[0].magnitude})`); return; } const newQuakes = quakes.filter((quake) => !seenIds.has(quake.id)).reverse(); for (const quake of newQuakes) { - console.log(`🌍 ОБНАРУЖЕНО НОВОЕ СОБЫТИЕ: M${quake.magnitude} — ${quake.place}`); + logger.info(`New earthquake detected: M${quake.magnitude} — ${quake.place}`); await sendQuakeAlert(quake); } - seenIds = new Set(quakes.map((quake) => quake.id)); - writeJson(STATE_FILE, [...seenIds]); - } catch (error: any) { - console.error("⚠️ Сбой мониторинга (повтор через 60с):", error.message || error); + + rememberLatest(quakes.map((quake) => quake.id)); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Monitoring failure; retrying after ${config.poll.intervalMs}ms: ${message}`); } finally { running = false; } } +function scheduleNextRun(): void { + if (stopped) return; + timer = setTimeout(async () => { + await runMonitor(); + scheduleNextRun(); + }, config.poll.intervalMs); +} + export function startMonitorJob(): void { - // Запуск первой проверки сразу - runMonitor(); - setInterval(runMonitor, config.poll.intervalMs); + stopped = false; + void runMonitor().finally(scheduleNextRun); +} + +export function stopMonitorJob(): void { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } } From a77e4affc57ad622b34083759951ed46670bcf10 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:37:44 +0500 Subject: [PATCH 07/10] feat(server): allow shutdown cleanup hooks --- server/src/utils/shutdown.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/utils/shutdown.ts b/server/src/utils/shutdown.ts index 1806965..093d602 100644 --- a/server/src/utils/shutdown.ts +++ b/server/src/utils/shutdown.ts @@ -1,12 +1,16 @@ import type { Server } from "node:http"; import { logger } from "./logger.utils"; -export const registerGracefulShutdown = (server: Server): void => { +export const registerGracefulShutdown = ( + server: Server, + cleanup: () => void = () => undefined +): void => { let shuttingDown = false; const shutdown = (signal: string): void => { if (shuttingDown) return; shuttingDown = true; + cleanup(); logger.info(`Received ${signal}; shutting down gracefully`); From 4f0600c99ffa80b1839e641785c5a57e994c97d1 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:37:50 +0500 Subject: [PATCH 08/10] fix(server): stop monitor scheduler during shutdown --- server/src/app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 8affb52..0c09300 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,7 +2,7 @@ import express from "express"; import cors from "cors"; import { apiRouter } from "./routes"; import { errorHandlerMiddleware } from "./middlewares/errorHandler.middleware"; -import { startMonitorJob } from "./jobs/monitor.job"; +import { startMonitorJob, stopMonitorJob } from "./jobs/monitor.job"; import { startTelegramBot } from "./services/telegram.service"; import { config } from "./config"; import { logStorageConfiguration } from "./services/storage.service"; @@ -41,6 +41,6 @@ const server = app.listen(config.port, () => { startTelegramBot(); }); -registerGracefulShutdown(server); +registerGracefulShutdown(server, stopMonitorJob); export default app; From 24588866b0c581a1091067a56aab8c4c0f04c029 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:39:19 +0500 Subject: [PATCH 09/10] fix(server): validate earthquake magnitude configuration --- server/src/config/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 58c56fd..170f553 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -3,11 +3,16 @@ const parsePositiveInt = (value: string | undefined, fallback: number): number = return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }; +const parseNonNegativeNumber = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +}; + export const config = { almaty: { lat: 43.2565, lng: 76.9286 }, quake: { radiusKm: parsePositiveInt(process.env.QUAKE_RADIUS_KM, 300), - minMagnitude: Number(process.env.QUAKE_MIN_MAGNITUDE) || 2.5, + minMagnitude: parseNonNegativeNumber(process.env.QUAKE_MIN_MAGNITUDE, 2.5), limit: parsePositiveInt(process.env.QUAKE_LIMIT, 100), }, poll: { From b5d2d2f4849859fbddc4e8494a1db778cfa08c18 Mon Sep 17 00:00:00 2001 From: Serik <104305931+serik-k@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:39:26 +0500 Subject: [PATCH 10/10] fix(server): disable Expo wildcard origin in production --- server/src/app.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index 0c09300..d1260fe 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import { logger } from "./utils/logger.utils"; import { registerGracefulShutdown } from "./utils/shutdown"; const app = express(); +const isProduction = process.env.NODE_ENV === "production"; const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",").map((origin) => origin.trim()).filter(Boolean) @@ -17,7 +18,7 @@ const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS const isAllowedOrigin = (origin?: string): boolean => { if (!origin) return true; - if (origin.startsWith("exp://")) return true; + if (!isProduction && origin.startsWith("exp://")) return true; return ALLOWED_ORIGINS.includes(origin); };