-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathserver.js
More file actions
220 lines (186 loc) · 6.7 KB
/
server.js
File metadata and controls
220 lines (186 loc) · 6.7 KB
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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
require("dotenv").config({ quiet: true });
const fs = require("fs");
const bodyParser = require("body-parser");
const bodyParserErrorHandler = require("express-body-parser-error-handler");
const cors = require("cors");
const yaml = require("js-yaml");
const helmet = require("helmet");
const { Elm } = require("./server-app");
const jsonUtils = require("./lib/json");
const rateLimit = require("express-rate-limit");
const { createCSPDirectives, extractTokenFromHeaders } = require("./lib/http");
// monitoring
const { setupSentry } = require("./lib/sentry"); // MUST be required BEFORE express
const { createMatomoTracker } = require("./lib/matomo");
const { createPlausibleTracker } = require("./lib/plausible");
const { getProcessesAsString, filterLegacyFood1Paths } = require("./lib");
const express = require("express");
const expressHost = "0.0.0.0";
const expressPort = 8001;
// Env vars
const {
ENABLE_FOOD_SECTION,
ENABLE_FOOD1_API_DOCS,
NODE_ENV,
RATELIMIT_MAX_RPM,
RATELIMIT_WHITELIST,
} = process.env;
const INTERNAL_BACKEND_URL = "http://localhost:8002";
const app = express(); // web app
const api = express(); // api app
// Rate-limiting
const rateLimitWhitelist = RATELIMIT_WHITELIST?.split(",").filter(Boolean) ?? [];
const rateLimitMaxRPM = parseInt(RATELIMIT_MAX_RPM, 10) || 5000;
// Make rate-limiting working with X-Forwarded-For headers
app.set("trust proxy", 1);
app.use(
rateLimit({
windowMs: 60 * 1000, // 1 minute
max: rateLimitMaxRPM,
message: { error: `This server is rate-limited to ${rateLimitMaxRPM}rpm, please slow down.` },
skip: ({ ip }) => NODE_ENV !== "production" || rateLimitWhitelist.includes(ip),
}),
);
// Sentry monitoring
setupSentry(app);
// Plausible API tracker
const plausibleTracker = createPlausibleTracker(process.env);
// Matomo
const matomoTracker = createMatomoTracker(process.env);
// Middleware
const jsonErrorHandler = bodyParserErrorHandler({
onError: (err, req, res, next) => {
res.status(400).send({
error: { decoding: `Format JSON invalide : ${err.message}` },
documentation: "https://ecobalyse.beta.gouv.fr/#/api",
});
},
});
// Web
// Note: helmet middlewares have to be called *after* the Sentry middleware
// but *before* other middlewares to be applied effectively
app.use(
helmet({
crossOriginEmbedderPolicy: false,
hsts: false,
xssFilter: false,
contentSecurityPolicy: {
useDefaults: true,
directives: createCSPDirectives(process.env),
},
}),
);
app.use(
express.static("dist", {
setHeaders: (res) => {
// Note: helmet sets this header to `0` by default and doesn't allow overriding
// this value
res.set("X-XSS-Protection", "1; mode=block");
},
}),
);
// Redirects: Web
app.get("/accessibilite", (_, res) => res.redirect("/#/pages/accessibilité"));
app.get("/mentions-legales", (_, res) => res.redirect("/#/pages/mentions-légales"));
app.get("/stats", (_, res) => res.redirect("/#/stats"));
// API
const openApiContents = processOpenApi(
yaml.load(fs.readFileSync("openapi.yaml")),
// @FIXME: we should have the correct version number specified in the package.json file
require("./package.json").version,
);
function processOpenApi(contents, versionNumber) {
// Add app version info to openapi docs
contents.version = versionNumber;
// Remove food1 api docs if disabled from env
if (ENABLE_FOOD_SECTION !== "True" || ENABLE_FOOD1_API_DOCS !== "True") {
contents.paths = filterLegacyFood1Paths(contents.paths);
}
return contents;
}
// Processes
//
// Merge generic and legacy format for the time being. To do so we need to parse the JSON
// concat the arrays and the stringify again the whole thing
// Elm decoders should handle the differences between the two formats
const processesImpacts = getProcessesAsString((detailed = true));
const processes = getProcessesAsString((detailed = false));
const getProcesses = async (headers) => {
let isValidToken = false;
const token = extractTokenFromHeaders(headers);
if (NODE_ENV !== "test" && token) {
try {
const tokenRes = await fetch(`${INTERNAL_BACKEND_URL}/api/tokens/validate`, {
method: "POST",
body: JSON.stringify({ token }),
});
isValidToken = tokenRes.status == 201;
} catch (error) {
console.error("Error validating token from the auth backend", error);
isValidToken = false;
}
}
if (NODE_ENV === "test" || isValidToken) {
return processesImpacts;
} else {
return processes;
}
};
app.get("/processes/processes.json", async (req, res) => {
// Note: JSON parsing is done in Elm land
return res
.status(200)
.contentType("text/plain")
.send(JSON.stringify(await getProcesses(req.headers)));
});
const elmApp = Elm.Server.init();
elmApp.ports.output.subscribe(({ status, body, jsResponseHandler }) => {
return jsResponseHandler({ status, body });
});
api.get("/", async (req, res) => {
matomoTracker.track(200, req);
await plausibleTracker.captureEvent(200, req);
res.status(200).send(openApiContents);
});
// Redirects: API
api.get(/^\/countries$/, (_, res) => res.redirect("textile/countries"));
api.get(/^\/materials$/, (_, res) => res.redirect("textile/materials"));
api.get(/^\/products$/, (_, res) => res.redirect("textile/products"));
const cleanRedirect = (url) => (url.startsWith("/") ? url : "");
api.get(/^\/simulator(.*)$/, ({ url }, res) => res.redirect(`/api/textile${cleanRedirect(url)}`));
const respondWithFormattedJSON = (res, status, body) => {
res.status(status);
res.setHeader("Content-Type", "application/json");
res.send(jsonUtils.serialize(body));
};
// Note: Text/JSON request body parser (JSON is decoded in Elm)
api.all(/(.*)/, bodyParser.json(), jsonErrorHandler, async (req, res) => {
const token = extractTokenFromHeaders(req.headers);
if (!token) {
return res.status(401).send({
error: { authorization: "Un token est requis pour utiliser l’API" },
documentation: "https://ecobalyse.beta.gouv.fr/#/api",
});
}
const processes = await getProcesses(req.headers);
elmApp.ports.input.send({
method: req.method,
protocol: req.protocol,
host: req.get("host"),
url: req.url,
version: null, // note: no way to infer a version number from the request containing none
body: req.body,
processes,
jsResponseHandler: async ({ status, body }) => {
matomoTracker.track(status, req);
await plausibleTracker.captureEvent(status, req);
respondWithFormattedJSON(res, status, body);
},
});
});
api.use(cors()); // Enable CORS for all API requests
app.use("/api", api);
const server = app.listen(expressPort, expressHost, () => {
console.log(`Server listening at http://${expressHost}:${expressPort} (NODE_ENV=${NODE_ENV})`);
});
module.exports = server;