Files
Qris-Soundbox/src/app.ts
2026-06-06 20:58:04 +07:00

204 lines
6.3 KiB
TypeScript

import express from "express";
import helmet from "helmet";
import { env } from "./config/env";
import { requestContext } from "./shared/middleware/requestContext";
import { requestLogging } from "./shared/middleware/requestLogging";
import { rateLimit } from "./shared/middleware/rateLimit";
import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware";
import { NextFunction, Request, Response } from "express";
import adminRoutes from "./routes/admin";
import integrationRoutes from "./routes/integrations";
import deviceRoutes from "./routes/device";
import merchantRoutes from "./routes/merchant";
import speakerRoutes from "./routes/speaker";
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
import { startExportJobWorker } from "./shared/services/exportJobWorker";
import { startMqttSubscriber } from "./shared/services/mqttSubscriber";
import { getDatabaseHealth } from "./shared/services/health";
import path from "node:path";
import fs from "node:fs";
const app = express();
if (env.TRUST_PROXY === "true") {
app.set("trust proxy", 1);
}
const allowedPreviewOrigins = new Set([
"http://127.0.0.1:4173",
"http://localhost:4173"
]);
app.use((req, res, next) => {
const origin = req.header("origin");
if (origin && allowedPreviewOrigins.has(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Vary", "Origin");
res.header("Access-Control-Allow-Headers", "authorization, content-type, x-request-id, idempotency-key");
res.header("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
return next();
});
startNotificationOrchestrator();
startDynamicQrExpiryScheduler();
startExportJobWorker();
startMqttSubscriber();
app.use(
helmet({
hidePoweredBy: true,
referrerPolicy: {
policy: "no-referrer"
},
hsts:
process.env.NODE_ENV === "production"
? {
maxAge: 15552000,
includeSubDomains: true
}
: false,
crossOriginResourcePolicy: {
policy: "cross-origin"
},
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.tailwindcss.com"],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
imgSrc: ["'self'", "data:", "https://lh3.googleusercontent.com", "https://*.googleusercontent.com"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"]
}
}
})
);
app.use(express.json({ limit: env.JSON_BODY_LIMIT }));
app.use(requestContext);
app.use(requestLogging);
const loginLimiter = rateLimit({
name: "login",
windowMs: env.RATE_LIMIT_LOGIN_WINDOW_MS,
max: env.RATE_LIMIT_LOGIN_MAX
});
const deviceLimiter = rateLimit({
name: "device",
windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS,
max: env.RATE_LIMIT_DEVICE_MAX,
key: (req) => `${req.ip}:${req.header("x-device-id") || "legacy"}`
});
const adminWriteLimiter = rateLimit({
name: "admin_write",
windowMs: env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS,
max: env.RATE_LIMIT_ADMIN_WRITE_MAX,
key: (req) => `${req.ip}:${req.header("authorization") || "anonymous"}`
});
app.get("/", (_req, res) => {
res.redirect(302, "/ui/soundbox-ops");
});
app.get("/favicon.ico", (_req, res) => {
res
.type("image/svg+xml")
.send(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#004ac6"/><path fill="#fff" d="M16 17h12v12H16V17Zm4 4v4h4v-4h-4Zm16-4h12v12H36V17Zm4 4v4h4v-4h-4ZM16 35h12v12H16V35Zm4 4v4h4v-4h-4Zm18-4h4v4h-4v-4Zm6 0h4v10h-4V35Zm-10 6h8v4h-8v-4Zm0 8h4v4h-4v-4Zm8 0h10v4H42v-4Z"/></svg>`
);
});
function resolveUiPageFile(slug: string) {
const workspaceRoot = process.cwd();
const candidates = [
path.resolve(workspaceRoot, "ui", slug, "index.html"),
path.resolve(workspaceRoot, "ui", slug.replace(/_/g, "-"), "index.html"),
path.resolve(workspaceRoot, "ui", slug.replace(/-/g, "_"), "index.html")
];
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
}
app.get("/ui", (_req, res) => {
res.redirect(302, "/ui/soundbox-ops");
});
app.get("/ui/hub", (_req, res) => {
const filePath = path.resolve(process.cwd(), "ui/hub.html");
res.sendFile(filePath);
});
app.use("/ui/shared", express.static(path.resolve(process.cwd(), "ui", "shared")));
app.get("/ui/:page", (req, res, next) => {
const filePath = resolveUiPageFile(req.params.page);
if (!filePath) {
return next();
}
res.sendFile(filePath);
});
app.use("/admin/login", loginLimiter);
app.use("/merchant/login", loginLimiter);
app.use("/admin", (req, res, next) => {
if (req.path === "/login") {
return next();
}
if (["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) {
return adminWriteLimiter(req, res, next);
}
return next();
});
app.use("/device", deviceLimiter);
app.use("/speaker", deviceLimiter);
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
app.use("/admin", adminRoutes);
app.use("/merchant", merchantRoutes);
app.use("/integrations", integrationRoutes);
app.use("/device", deviceRoutes);
app.use("/speaker", speakerRoutes);
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
handleErrors(err, _req, res, next);
});
app.get("/health", (req, res) => {
res.status(200).json(
successResponse(req, {
status: "healthy",
time: new Date().toISOString()
})
);
});
app.get("/health/deep", async (req, res) => {
const database = await getDatabaseHealth();
const healthy = database.status === "ok";
res.status(healthy ? 200 : 503).json(
successResponse(req, {
status: healthy ? "healthy" : "degraded",
time: new Date().toISOString(),
checks: {
database
}
})
);
});
app.use((req, res) => {
res.status(404).json({
code: "NOT_FOUND",
message: `Route ${req.path} not found`,
request_id: req.requestId,
timestamp: new Date().toISOString()
});
});
export default app;