CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s
Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné. - pole skupiny bolt* přejmenována na tracking* + nové trackingProvider - endpoint /groups/setBoltTracking → /groups/setTracking - Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order - DEV simulace zůstává jen pro Bolt Food
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
import './instrument'; // Sentry — musí být první import (auto-instrumentace)
|
||
import * as Sentry from '@sentry/node';
|
||
import express from "express";
|
||
import bodyParser from "body-parser";
|
||
import cors from 'cors';
|
||
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service";
|
||
import { MealSlot } from "../../types/gen/types.gen";
|
||
import dotenv from 'dotenv';
|
||
import path from 'path';
|
||
import { getQr } from "./qr";
|
||
import { generateToken, getLogin, verify } from "./auth";
|
||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||
import { getPendingQrs } from "./pizza";
|
||
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||
import { startOrderTrackingScheduler, stopOrderTrackingScheduler, releaseOrderTrackingLease } from "./orderTracking";
|
||
import { storageReady } from "./storage";
|
||
import getStorage from "./storage";
|
||
import { shutdownRedisStorage } from "./storage/redis";
|
||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||
import suggestionRoutes from "./routes/suggestionRoutes";
|
||
import easterEggRoutes from "./routes/easterEggRoutes";
|
||
import statsRoutes from "./routes/statsRoutes";
|
||
import notificationRoutes from "./routes/notificationRoutes";
|
||
import qrRoutes from "./routes/qrRoutes";
|
||
import devRoutes from "./routes/devRoutes";
|
||
import changelogRoutes from "./routes/changelogRoutes";
|
||
import groupRoutes from "./routes/groupRoutes";
|
||
import storeRoutes from "./routes/storeRoutes";
|
||
import butterflyRoutes from "./routes/butterflyRoutes";
|
||
|
||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||
|
||
if (!process.env.JWT_SECRET) {
|
||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||
}
|
||
|
||
const app = express();
|
||
const server = require("http").createServer(app);
|
||
|
||
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
|
||
// headersTimeout must be strictly greater than keepAliveTimeout.
|
||
server.keepAliveTimeout = 65_000;
|
||
server.headersTimeout = 66_000;
|
||
server.requestTimeout = 30_000;
|
||
|
||
initWebsocket(server);
|
||
|
||
app.use(bodyParser.json());
|
||
app.use(cors({ origin: '*' }));
|
||
|
||
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
||
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||
if (HTTP_REMOTE_USER_ENABLED) {
|
||
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
|
||
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
||
}
|
||
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
||
console.log('Zapnutý login přes hlavičky z proxy.');
|
||
}
|
||
|
||
// ─── Shutdown state ──────────────────────────────────────────────────────────
|
||
|
||
let shuttingDown = false;
|
||
|
||
async function shutdown(signal: string) {
|
||
if (shuttingDown) return;
|
||
shuttingDown = true;
|
||
console.log(`${signal} received — initiating graceful shutdown`);
|
||
|
||
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
|
||
setTimeout(() => {
|
||
console.error('Graceful shutdown timed out, forcing exit');
|
||
process.exit(1);
|
||
}, 25_000).unref();
|
||
|
||
// Disconnect WebSocket clients so they reconnect to another pod
|
||
const io = getWebsocket();
|
||
io?.disconnectSockets(true);
|
||
|
||
// Stop accepting new HTTP connections and drain in-flight requests
|
||
(server as any).closeIdleConnections?.();
|
||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||
|
||
// Stop reminder scheduler and release leader lease
|
||
stopReminderScheduler();
|
||
await releaseReminderLease();
|
||
|
||
// Stop order tracking scheduler and release leader lease
|
||
stopOrderTrackingScheduler();
|
||
await releaseOrderTrackingLease();
|
||
|
||
// Shut down Redis pub/sub clients (Socket.io adapter)
|
||
await shutdownWebsocketClients();
|
||
|
||
// Shut down main Redis storage client
|
||
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||
await shutdownRedisStorage();
|
||
}
|
||
|
||
// Flush zbývajících Sentry eventů/logů (no-op, když Sentry není inicializované)
|
||
await Sentry.close(2000);
|
||
|
||
console.log('Graceful shutdown complete');
|
||
process.exit(0);
|
||
}
|
||
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||
|
||
// ─── Routes — no auth required ───────────────────────────────────────────────
|
||
|
||
/** Liveness probe — cheap, no external deps. */
|
||
app.get("/api/health", (_req, res) => {
|
||
res.status(200).json({ ok: true });
|
||
});
|
||
|
||
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
|
||
app.get("/api/health/ready", async (_req, res) => {
|
||
if (shuttingDown) {
|
||
return res.status(503).json({ ok: false, reason: 'shutting down' });
|
||
}
|
||
const healthy = await getStorage().healthCheck?.() ?? true;
|
||
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
|
||
res.status(200).json({ ok: true });
|
||
});
|
||
|
||
/** Veřejná runtime konfigurace pro klienta (Sentry DSN apod.). */
|
||
app.get("/api/config", (_req, res) => {
|
||
res.status(200).json({
|
||
sentry: {
|
||
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
||
environment: ENVIRONMENT,
|
||
},
|
||
});
|
||
});
|
||
|
||
app.get("/api/whoami", (req, res) => {
|
||
if (!HTTP_REMOTE_USER_ENABLED) {
|
||
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
||
}
|
||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||
delete req.headers["cookie"]
|
||
console.log(req.headers)
|
||
}
|
||
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
|
||
})
|
||
|
||
app.post("/api/login", (req, res) => {
|
||
if (HTTP_REMOTE_USER_ENABLED) {
|
||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||
if (remoteUser && remoteUser.length > 0) {
|
||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
||
} else {
|
||
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
||
}
|
||
} else {
|
||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||
throw new Error("Nebyl předán login");
|
||
}
|
||
res.status(200).json(generateToken(req.body.login, false));
|
||
}
|
||
});
|
||
|
||
// QR se zobrazuje přes <img>, nemáme sem jak dostat token
|
||
app.get("/api/qr", async (req, res) => {
|
||
if (!req.query?.login) {
|
||
return res.status(400).json({ error: "Nebyl předán login" });
|
||
}
|
||
if (!req.query?.id) {
|
||
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||
}
|
||
const img = await getQr(req.query.login as string, req.query.id as string);
|
||
res.writeHead(200, {
|
||
'Content-Type': 'image/png',
|
||
'Content-Length': img.length
|
||
});
|
||
res.end(img);
|
||
});
|
||
|
||
// ─── Semi-public routes ───────────────────────────────────────────────────────
|
||
|
||
app.use("/api/food/refresh", refreshMetoda);
|
||
|
||
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||
try {
|
||
const { login, token } = req.body ?? {};
|
||
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
|
||
return res.status(400).json({ error: 'Chybí login nebo token' });
|
||
}
|
||
if (!verifyQuickChoiceToken(login, token)) {
|
||
return res.status(403).json({ error: 'Neplatný token' });
|
||
}
|
||
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||
getWebsocket().emit("message", updatedData);
|
||
res.status(200).json({});
|
||
} catch (e: any) { next(e); }
|
||
});
|
||
|
||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||
|
||
app.use("/api/", (req, res, next) => {
|
||
if (HTTP_REMOTE_USER_ENABLED) {
|
||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||
delete req.headers["cookie"]
|
||
console.log(req.headers)
|
||
}
|
||
if (remoteUser && remoteUser.length > 0) {
|
||
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
|
||
if (ENVIRONMENT !== "production") {
|
||
console.log("Tvuj username: %s.", remoteName);
|
||
}
|
||
}
|
||
}
|
||
if (!req.headers.authorization) {
|
||
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
|
||
}
|
||
const token = req.headers.authorization.split(' ')[1];
|
||
if (!verify(token)) {
|
||
return res.status(403).json({ error: 'Neplatný autentizační token' });
|
||
}
|
||
next();
|
||
});
|
||
|
||
// ─── Authenticated routes ─────────────────────────────────────────────────────
|
||
|
||
app.get("/api/data", async (req, res) => {
|
||
let date = undefined;
|
||
if (req.query.date != null && typeof req.query.date === 'string') {
|
||
// Konkrétní datum (YYYY-MM-DD) – umožňuje načtení historie i mimo aktuální týden
|
||
const parsed = new Date(`${req.query.date}T00:00:00`);
|
||
if (isNaN(parsed.getTime())) {
|
||
return res.status(400).json({ error: 'Neplatné datum' });
|
||
}
|
||
// Budoucnost ořízneme na dnešek – do budoucna historii nedává smysl zobrazovat
|
||
date = parsed.getTime() > getToday().getTime() ? getToday() : parsed;
|
||
} else if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
||
const index = parseInt(req.query.dayIndex);
|
||
if (!isNaN(index)) {
|
||
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
||
}
|
||
} else if (getIsWeekend(getToday())) {
|
||
date = getDateForWeekIndex(4);
|
||
}
|
||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) {
|
||
return res.status(400).json({ error: 'Neplatný slot' });
|
||
}
|
||
const data = await getData(date, slotParam);
|
||
try {
|
||
const login = getLogin(parseToken(req));
|
||
const pendingQrs = await getPendingQrs(login);
|
||
if (pendingQrs.length > 0) {
|
||
data.pendingQrs = pendingQrs;
|
||
}
|
||
} catch {
|
||
// Token nemusí být validní, ignorujeme
|
||
}
|
||
res.status(200).json(data);
|
||
});
|
||
|
||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||
app.use("/api/food", foodRoutes);
|
||
app.use("/api/suggestions", suggestionRoutes);
|
||
app.use("/api/easterEggs", easterEggRoutes);
|
||
app.use("/api/stats", statsRoutes);
|
||
app.use("/api/notifications", notificationRoutes);
|
||
app.use("/api/qr", qrRoutes);
|
||
app.use("/api/dev", devRoutes);
|
||
app.use("/api/changelogs", changelogRoutes);
|
||
app.use("/api/groups", groupRoutes);
|
||
app.use("/api/stores", storeRoutes);
|
||
app.use("/api/butterflies", butterflyRoutes);
|
||
|
||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||
app.get('*splat', (_req, res) => {
|
||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||
});
|
||
|
||
// Sentry error handler — hlásí jen 5xx (výchozí shouldHandleError), očekávané
|
||
// chyby (403/409) do Sentry nepadají. Musí být před vlastním error middlewarem.
|
||
Sentry.setupExpressErrorHandler(app);
|
||
|
||
// Error handling middleware
|
||
app.use((err: any, req: any, res: any, next: any) => {
|
||
if (err instanceof InsufficientPermissions) {
|
||
res.status(403).send({ error: err.message })
|
||
} else if (err instanceof PizzaDayConflictError) {
|
||
res.status(409).send({ error: err.message })
|
||
} else {
|
||
res.status(500).send({ error: err.message })
|
||
}
|
||
next();
|
||
});
|
||
|
||
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||
|
||
const PORT = process.env.PORT ?? 3001;
|
||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||
|
||
storageReady.then(async () => {
|
||
// Init Redis adapter after storage is connected (only in Redis mode)
|
||
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||
await initRedisAdapter();
|
||
}
|
||
server.listen(PORT, () => {
|
||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||
startReminderScheduler();
|
||
startOrderTrackingScheduler();
|
||
});
|
||
});
|