File size: 4,442 Bytes
6e13570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6b1e8
 
6e13570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6b1e8
 
 
 
 
 
 
 
6e13570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6b1e8
6e13570
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
const express = require("express");
const helmet = require("helmet");
const cors = require("cors");
const compression = require("compression");
const morgan = require("morgan");
const mongoSanitize = require("express-mongo-sanitize");
const hpp = require("hpp");

const { env } = require("./config/env");
const { apiLimiter } = require("./middleware/rateLimiters");
const { errorHandler, notFound } = require("./middleware/errorHandler");
const logger = require("./utils/logger");

const authRoutes = require("./routes/auth.routes");
const courseRoutes = require("./routes/course.routes");
const orderRoutes = require("./routes/order.routes");
const adminRoutes = require("./routes/admin.routes");
const voucherRoutes = require("./routes/voucher.routes");
const webhookRoutes = require("./routes/webhook.routes");

const app = express();

// Trust the first proxy hop (Render/Heroku/Nginx/etc.) so req.ip and the
// "secure" cookie/HSTS logic reflect the real client over HTTPS.
app.set("trust proxy", 1);

// --- Security headers -------------------------------------------------
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
        objectSrc: ["'none'"],
        frameAncestors: ["'none'"],
      },
    },
    crossOriginResourcePolicy: { policy: "same-site" },
    hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
  })
);

// --- CORS: only the configured frontend origins may call the API.
// `credentials: false` is deliberate — this API is designed to run
// cross-site from its frontend (e.g. a Hugging Face Space backend +
// a separate Vercel-hosted frontend), and some hosting proxies in that
// kind of setup intercept CORS preflight requests before they reach
// this app and strip the Access-Control-Allow-Credentials header,
// which silently breaks anything relying on `credentials: include`.
// Sessions are carried via a bearer access token (Authorization
// header) plus a refresh token returned in the JSON response body
// instead of a cookie, so no cross-site credentialed request is ever
// needed — see controllers/auth.controller.js for the reasoning and
// trade-offs of that approach in more detail. ---
app.use(
  cors({
    origin(origin, callback) {
      if (!origin || env.clientOrigins.includes(origin)) return callback(null, true);
      // Logged (not just thrown) so a mismatched frontend domain shows up
      // immediately in the server logs with the exact rejected value —
      // compare this directly against the CORS allow-list logged at
      // startup to spot typos, protocol mismatches, or a missing entry.
      logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
      return callback(new Error("Not allowed by CORS"));
    },
    credentials: false,
  })
);

app.use(compression());

// Payoneer's webhook signature is computed over the exact raw request
// body, so this route MUST be registered with a raw-body parser
// BEFORE the global express.json() below — once express.json() has
// run, the original bytes are gone and any signature check would
// fail. See utils/payoneerCheckout.js -> verifyAndParseWebhook.
app.use("/api/webhooks", express.raw({ type: "*/*", limit: "200kb" }), webhookRoutes);

app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
app.use(express.urlencoded({ extended: true, limit: "100kb" }));

// Strip any keys starting with "$" or containing "." from user input to
// block MongoDB operator-injection attacks.
app.use(mongoSanitize());
// Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
app.use(hpp());

app.use(
  morgan("combined", {
    stream: { write: (message) => logger.info(message.trim()) },
    skip: () => env.nodeEnv === "test",
  })
);

app.use("/api/", apiLimiter);

app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));

app.use("/api/auth", authRoutes);
app.use("/api/courses", courseRoutes);
app.use("/api/orders", orderRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/vouchers", voucherRoutes);

app.use(notFound);
app.use(errorHandler);

module.exports = app;