fcsc_ipi_backend/server.js

406 lines
9.7 KiB
JavaScript

const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const swaggerUi = require("swagger-ui-express");
const swaggerJsdoc = require("swagger-jsdoc");
const routes = require("./app/routes/routes");
const db = require("./app/models");
const path = require("path");
const cookieParser = require("cookie-parser");
const sanitizeInput = require("./app/utils/sanitizeInput");
const verifySignature = require("./app/middleware/app.middleware");
const authController = require("./app/controllers/auth.controller");
const establishmentController = require("./app/controllers/establishment.controller");
const AutomatedSchedulerService = require('./app/services/scheduler.service');
const ipiDemoController = require("./app/controllers/ipiDemo.controller");
require("dotenv").config();
const csrf = require("csurf");
const isProd = process.env.NODE_ENV === "production";
const isLocal = process.env.NODE_ENV === "development";
/**
* =========================
* CSRF CONFIG (CORRECT)
* =========================
*/
const csrfProtection = csrf({
cookie: {
key: "_csrf",
httpOnly: true,
secure: isProd, // HTTPS in UAT/PROD
sameSite: isProd ? "none" : "lax",
},
});
const app = express();
/**
* =========================
* VIEW ENGINE SETUP (EJS)
* =========================
*/
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'app/views'));
/**
* REQUIRED for HSTS when behind proxy
*/
app.set("trust proxy", 1);
/**
* =========================
* GLOBAL MIDDLEWARE
* =========================
*/
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(
"/assets",
express.static(path.join(__dirname, "app/assets"))
);
/**
* =========================
* HELMET + SECURITY HEADERS
* =========================
*/
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
connectSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
fontSrc: ["'self'", "data:"],
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'self'"],
},
},
})
);
// Clickjacking protection
app.use(helmet.frameguard({ action: "deny" }));
// HSTS for UAT + PROD
if (!isLocal) {
app.use(
helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: true,
})
);
}
app.use(morgan("dev"));
app.use(sanitizeInput);
/**
* =========================
* CORS
* =========================
*/
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",").map(o => o.trim())
: [];
app.use(
cors({
origin: allowedOrigins,
credentials: true,
})
);
/**
* Manual headers (kept as-is, but fixed OPTIONS flow)
*/
app.use((req, res, next) => {
if (allowedOrigins.includes(req.headers.origin)) {
res.header("Access-Control-Allow-Origin", req.headers.origin);
}
res.header("Access-Control-Allow-Credentials", "true");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, APP_SIGNATURE, x-app-signature, X-CSRF-Token"
);
res.header(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
// ⚠️ IMPORTANT: DO NOT TERMINATE REQUEST HERE
if (req.method === "OPTIONS") {
return next();
}
next();
});
/**
* =========================
* SWAGGER
* =========================
*/
const swaggerOptions = {
definition: {
openapi: "3.0.0",
info: {
title: "FCSC IPI Survey",
version: "1.0.0",
description:
"Federal Competitiveness and Statistics Centre (FCSC) - Industrial Production Index (IPI)",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
appSignature: {
type: "apiKey",
in: "header",
name: "x-app-signature",
},
csrfToken: {
type: "apiKey",
in: "header",
name: "X-CSRF-Token",
},
},
},
},
apis: ["./app/routes/*.js"],
};
const swaggerDocs = swaggerJsdoc(swaggerOptions);
app.use(
"/api-docs",
swaggerUi.serve,
swaggerUi.setup(swaggerDocs, {
swaggerOptions: {
withCredentials: true,
},
})
);
/**
* =========================
* AUTH (NO CSRF)
* =========================
*/
const logRoutes = require('./app/routes/logRoutes');
app.use('/logs', logRoutes);
app.post("/api/auth/login", [verifySignature], authController.login);
app.post(
"/api/forgot-password/request-otp",
[verifySignature],
establishmentController.forgotPasswordRequestOTP
);
app.post(
"/api/forgot-password/verify-otp",
[verifySignature],
establishmentController.forgotPasswordVerifyOTP
);
/**
* =========================
* CSRF TOKEN ENDPOINT
* =========================
*/
app.get("/api/csrf-token", csrfProtection, (req, res) => {
res.status(200).json({
csrfToken: req.csrfToken(),
});
});
/**
* =========================
* ENFORCE CSRF FOR STATE-CHANGING REQUESTS
* =========================
*/
app.use((req, res, next) => {
// Allow safe methods
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
return next();
}
// Only protect API routes
if (!req.path.startsWith("/api")) {
return next();
}
// Skip auth & public endpoints
const csrfExcludedPaths = [
"/api/auth/login",
"/api/forgot-password/request-otp",
"/api/forgot-password/verify-otp",
"/api/csrf-token",
"/api/auth/request-otp",
"/api/auth/verify-otp",
"/api/products/uploadCSV",
"/api/establishments/uploadCSV",
"/api/unit_master/uploadCSV",
];
if (csrfExcludedPaths.includes(req.path)) {
return next();
}
// 🔒 Enforce CSRF
return csrfProtection(req, res, next);
});
/**
* =========================
* PROTECTED ROUTES
* =========================
*/
app.use("/api", routes);
/**
* =========================
* CSRF ERROR HANDLER
* =========================
*/
app.use((err, req, res, next) => {
if (err.code === "EBADCSRFTOKEN") {
return res.status(403).json({
status: "failed",
message: "Invalid or missing CSRF token",
});
}
if (err.name === "MulterError") {
return res.status(400).json({
status: "failed",
message: err.message,
hint: 'Upload the CSV using form field "file".',
});
}
next(err);
});
/**
* =========================
* TEST ROUTE
* =========================
*/
app.get("/api/test", (req, res) => {
res.json({ status: "success", message: "Test API working fine 🚀" });
});
/**
* =========================
* DEPLOYMENT ROUTE
* =========================
*/
const deploymentController = require("./app/controllers/deployment.controller");
app.post("/deploy", deploymentController.deployment);
/**
* =========================
* IPI DEMO UI
* =========================
*/
const requireIpiDemoAuth = (req, res, next) => {
const demoKey = process.env.IPI_DEMO_KEY;
if (!demoKey) return next();
if (req.cookies.ipi_demo_auth === "1") return next();
if (req.path.startsWith("/ipi-demo") && req.method === "GET" && req.headers.accept?.includes("text/html")) {
return res.redirect("/ipi-demo-auth");
}
return res.status(401).json({
success: false,
message: "Unauthorized demo access",
});
};
app.get("/ipi-demo-auth", (req, res) => {
if (!process.env.IPI_DEMO_KEY) return res.redirect("/ipi-demo");
if (req.cookies.ipi_demo_auth === "1") return res.redirect("/ipi-demo");
return res.render("ipi-demo-auth", { title: "IPI Demo Access", error: null });
});
app.post("/ipi-demo-auth", (req, res) => {
const expectedKey = process.env.IPI_DEMO_KEY;
if (!expectedKey) return res.redirect("/ipi-demo");
const providedKey = String(req.body.key || "").trim();
if (providedKey !== expectedKey) {
return res.status(401).render("ipi-demo-auth", {
title: "IPI Demo Access",
error: "Invalid demo key",
});
}
res.cookie("ipi_demo_auth", "1", {
httpOnly: true,
sameSite: "lax",
secure: isProd,
maxAge: 12 * 60 * 60 * 1000,
});
return res.redirect("/ipi-demo");
});
app.post("/ipi-demo-logout", (req, res) => {
res.clearCookie("ipi_demo_auth");
return res.redirect("/ipi-demo-auth");
});
app.get("/ipi-demo", requireIpiDemoAuth, ipiDemoController.renderPage);
app.get("/ipi-demo/data", requireIpiDemoAuth, ipiDemoController.getData);
app.get("/ipi-demo/table-data", requireIpiDemoAuth, ipiDemoController.getTableData);
app.get("/ipi-demo/run-status", requireIpiDemoAuth, ipiDemoController.getRunStatus);
app.post("/ipi-demo/run-month", requireIpiDemoAuth, ipiDemoController.runMonthCalculation);
/**
* =========================
* Auto Submission and iip calculation Automation
* =========================
*/
const scheduler = new AutomatedSchedulerService();
scheduler.start();
scheduler.startRemainderEmail();
// manual trigger endpoint
app.post('/api/admin/trigger-scheduler', async (req, res) => {
try {
await scheduler.manualTrigger();
res.json({ success: true, message: 'Scheduler triggered successfully' });
} catch (error) {
res.status(500).json({
success: false,
message: 'Error triggering scheduler',
error: error.message
});
}
});
/**
* =========================
* START SERVER
* =========================
*/
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`Automated scheduler (Index Calculation) is active and will run daily at 2:00 AM`);
console.log(`Automated scheduler (Reminder Emails) is active and will run daily at 6:00 AM`);
});