204 lines
6.3 KiB
TypeScript
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;
|