Files
Luncher/server/src/index.ts
T
Ondřej Anděl 2a54a1d41f
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
feat: HG mode - změna obrázků, animace v modálech
2026-10-12 14:34:55 +02:00

352 lines
14 KiB
TypeScript
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 './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";
import { getConfettiCharacters, getConfettiImage, getHellGirlImages } from "./confetti";
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
import { getGroupLimits } from "./limits";
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';
let trustedProxies = parseTrustedIps('');
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);
// Hlavička s identitou se přijímá jen od těchto adres (kontrola skutečné adresy spojení)
trustedProxies = parseTrustedIps(process.env.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,
},
limits: {
group: getGroupLimits(),
},
auth: {
trustedHeaders: HTTP_REMOTE_USER_ENABLED,
},
features: {
storeAdmin: !!process.env.ADMIN_PASSWORD,
},
});
});
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(getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies) ?? '');
})
app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(remoteUser, true));
} else {
// Přihlašuje proxy (trusted headers) — bez hlavičky nejde o chybu serveru, ale o nepřihlášeného uživatele
res.status(401).json({ error: 'Přihlášení probíhá přes firemní přihlášení (proxy), ale nepřišla identita uživatele. Zkuste stránku obnovit nebo se přihlásit znovu.' });
}
} 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); }
});
// Konfety kola štěstí — veřejné (načítá je <img>), obrázky jen z pevného seznamu, cachované ve storage
app.get("/api/confetti/umamusume", (_req, res) => {
// Seznam je malý — vždy revalidovat (ETag → 304), aby se změny projevily hned po nasazení
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
});
app.get("/api/confetti/hellgirl", (_req, res) => {
res.set('Cache-Control', 'no-cache').json(getHellGirlImages());
});
app.get("/api/confetti/image/:id", async (req, res, next) => {
try {
const id = req.params.id;
const image = /^[0-9a-f]{16}$/.test(id) ? await getConfettiImage(id) : undefined;
if (!image) {
return res.status(404).json({ error: 'Obrázek není dostupný' });
}
res.set('Content-Type', image.contentType);
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.end(image.data);
} catch (e: any) { next(e); }
});
// ─── Auth middleware ──────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
const remoteName = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
console.log(req.headers)
}
if (remoteName && 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();
});
});