1 | const _ = require('lodash');
|
2 | const express = require('express');
|
3 | const cacheManager = require('cache-manager');
|
4 | const redisStore = require('cache-manager-redis-store');
|
5 | const Promise = require('bluebird');
|
6 | const URL = require('url-parse');
|
7 | const zlib = Promise.promisifyAll(require('zlib'));
|
8 | const Logger = require('le_node');
|
9 | const CircularJSON = require('circular-json-es6');
|
10 | const sizeof = require('object-sizeof');
|
11 | const deepFreeze = require('deep-freeze');
|
12 |
|
13 | const Api = require('ace-api');
|
14 |
|
15 | const defaultConfig = require('./config.default');
|
16 |
|
17 | process.on('unhandledRejection', result => console.error(result));
|
18 |
|
19 | function AceApiServer (app, customConfig = {}, customAuthMiddleware = null) {
|
20 | const config = deepFreeze(_.merge({}, Api.defaultConfig, defaultConfig, customConfig));
|
21 |
|
22 |
|
23 |
|
24 | const asyncMiddleware = fn => (req, res, next) => {
|
25 | Promise.resolve(fn(req, res, next))
|
26 | .catch(next);
|
27 | };
|
28 |
|
29 |
|
30 |
|
31 | const skipAuth = (req) => {
|
32 | if (config.environment !== 'development') {
|
33 | return false;
|
34 | }
|
35 | const allowedRoutes = [
|
36 | '/token',
|
37 | '/email/template',
|
38 | ];
|
39 | return allowedRoutes.indexOf(req.path) > -1;
|
40 | };
|
41 |
|
42 |
|
43 |
|
44 | const defaultAuthMiddleware = (req, res, next) => {
|
45 | if (skipAuth(req)) {
|
46 | next();
|
47 | return;
|
48 | }
|
49 |
|
50 | if (!req.session.userId) {
|
51 | res.status(401);
|
52 | res.send({
|
53 | code: 401,
|
54 | message: 'Not authorised',
|
55 | });
|
56 | return;
|
57 | }
|
58 |
|
59 | next();
|
60 | };
|
61 |
|
62 | const authMiddleware = customAuthMiddleware || defaultAuthMiddleware;
|
63 |
|
64 |
|
65 |
|
66 | const permissionMiddleware = (permission, req, res, next) => {
|
67 | if (!req.session.role) {
|
68 | res.status(401);
|
69 | res.send({
|
70 | permission,
|
71 | message: 'Error: role not defined in session.',
|
72 | });
|
73 | return;
|
74 | }
|
75 |
|
76 | if (req.session.role === 'super') {
|
77 | next();
|
78 | return;
|
79 | }
|
80 |
|
81 | const roles = Api.Roles();
|
82 |
|
83 | if (!roles.role(req.session.role) || roles.role(req.session.role).permissions[permission] !== true) {
|
84 | res.status(401);
|
85 | res.send({
|
86 | permission,
|
87 | message: 'Sorry, you\'re not authorised to do this.',
|
88 | });
|
89 | return;
|
90 | }
|
91 |
|
92 | next();
|
93 | };
|
94 |
|
95 |
|
96 |
|
97 | const omitUndefined = (obj) => {
|
98 | _.forIn(obj, (value, key, obj) => {
|
99 | if (_.isPlainObject(value)) {
|
100 | value = omitUndefined(value);
|
101 |
|
102 | if (_.keys(value).length === 0) {
|
103 | delete obj[key];
|
104 | }
|
105 | }
|
106 |
|
107 | if (_.isUndefined(value)) {
|
108 | delete obj[key];
|
109 | }
|
110 | });
|
111 |
|
112 | return obj;
|
113 | };
|
114 |
|
115 | const cloneConfig = config => _.mergeWith({}, JSON.parse(JSON.stringify(config)), omitUndefined(_.cloneDeep(config)));
|
116 |
|
117 | const getConfig = async (slug) => {
|
118 | const configClone = cloneConfig(config);
|
119 |
|
120 | configClone.slug = slug;
|
121 | configClone.db.name = slug;
|
122 |
|
123 | return configClone;
|
124 | };
|
125 |
|
126 |
|
127 |
|
128 | let cache;
|
129 |
|
130 | if (config.cache.enabled) {
|
131 | if (config.redis.url || config.redis.host) {
|
132 | const redisOptions = {
|
133 | ttl: config.cache.ttl,
|
134 | };
|
135 |
|
136 | if (config.redis.url) {
|
137 | redisOptions.url = config.redis.url;
|
138 | } else {
|
139 | redisOptions.host = config.redis.host;
|
140 | redisOptions.port = config.redis.port;
|
141 | redisOptions.password = config.redis.password;
|
142 | redisOptions.db = config.redis.db;
|
143 | }
|
144 |
|
145 | cache = cacheManager.caching(_.merge({ store: redisStore }, redisOptions));
|
146 |
|
147 | const redisClient = cache.store.getClient();
|
148 | redisClient.on('ready', () => {
|
149 | console.log('redis: ready');
|
150 | });
|
151 |
|
152 | } else {
|
153 | cache = cacheManager.caching({
|
154 | store: 'memory',
|
155 | ttl: config.cache.ttl,
|
156 | max: config.cache.memory.max,
|
157 | length: (item) => {
|
158 |
|
159 | const length = sizeof(item);
|
160 | return length;
|
161 | },
|
162 | });
|
163 | }
|
164 | }
|
165 |
|
166 |
|
167 |
|
168 | const cacheMiddleware = asyncMiddleware(async (req, res, next) => {
|
169 | const useCachedResponse = (
|
170 | config.cache.enabled
|
171 | && req.session.role === 'guest'
|
172 | && (req.query.__cache && JSON.parse(req.query.__cache)) !== false
|
173 | );
|
174 |
|
175 | if (useCachedResponse) {
|
176 | try {
|
177 | const key = `${req.session.slug}${req.url}`;
|
178 |
|
179 | let response = await cache.get(key);
|
180 |
|
181 | if (typeof response === 'string') {
|
182 | if (config.cache.compress) {
|
183 | response = (await zlib.gunzipAsync(Buffer.from(response, 'base64'))).toString();
|
184 | }
|
185 |
|
186 | try {
|
187 | response = JSON.parse(response);
|
188 | } catch (error) {
|
189 |
|
190 | }
|
191 |
|
192 | res.set('X-Cached-Response', true);
|
193 | res.status(200);
|
194 | res.send(response);
|
195 |
|
196 | return;
|
197 | }
|
198 | } catch (error) {
|
199 | console.error(error);
|
200 | }
|
201 | }
|
202 |
|
203 | res.set('X-Cached-Response', false);
|
204 | next();
|
205 | });
|
206 |
|
207 |
|
208 |
|
209 | const handleError = (req, res, error) => {
|
210 | if (_.isObject(error)) {
|
211 | error = JSON.parse(CircularJSON.stringify(error));
|
212 | }
|
213 |
|
214 | const statusCode = error.statusCode || error.code || 500;
|
215 | const errorMessage = error.stack || error.error || error.message || error.body || error.data || error;
|
216 |
|
217 | console.error(errorMessage);
|
218 |
|
219 | res.status(typeof statusCode === 'string' ? 500 : statusCode);
|
220 | res.send({
|
221 | code: statusCode,
|
222 | message: errorMessage,
|
223 | });
|
224 | };
|
225 |
|
226 | const handleResponse = async (req, res, response, cacheResponse = false) => {
|
227 | response = CircularJSON.stringify(response);
|
228 |
|
229 | res.status(200);
|
230 | res.send(JSON.parse(response));
|
231 |
|
232 | if (cacheResponse && config.cache.enabled && req.session.role === 'guest') {
|
233 | const key = `${req.session.slug}${req.url}`;
|
234 |
|
235 | if (config.cache.compress) {
|
236 | response = (await zlib.gzipAsync(Buffer.from(response))).toString('base64');
|
237 | }
|
238 |
|
239 | cache.set(key, response);
|
240 | }
|
241 | };
|
242 |
|
243 |
|
244 |
|
245 | const headerMiddleware = (req, res, next) => {
|
246 | const headers = {
|
247 | 'Access-Control-Allow-Origin': '*',
|
248 | 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE,OPTIONS',
|
249 | 'Access-Control-Expose-Headers': 'X-Slug, X-Role, X-User-Id',
|
250 | Vary: 'Accept-Encoding, X-Api-Token',
|
251 | };
|
252 |
|
253 | if (req.headers['access-control-request-headers']) {
|
254 | headers['Access-Control-Allow-Headers'] = req.headers['access-control-request-headers'];
|
255 | }
|
256 |
|
257 | res.set(headers);
|
258 |
|
259 | if (req.method === 'OPTIONS') {
|
260 | res.sendStatus(200);
|
261 | return;
|
262 | }
|
263 |
|
264 | next();
|
265 | };
|
266 |
|
267 |
|
268 |
|
269 | const jwt = Api.Jwt(config);
|
270 |
|
271 | const sessionMiddleware = (req, res, next) => {
|
272 | if (skipAuth(req)) {
|
273 | next();
|
274 | return;
|
275 | }
|
276 |
|
277 | const referrer = req.headers.referrer || req.headers.referer;
|
278 |
|
279 | if (referrer) {
|
280 | const referrerHostname = new URL(referrer)
|
281 | .hostname.split('.').slice(-2).join('.');
|
282 |
|
283 | if (config.api.blacklistReferrer.indexOf(referrerHostname) > -1) {
|
284 | res.status(401);
|
285 | res.send({
|
286 | code: 401,
|
287 | message: 'Not authorised, referrer blacklisted',
|
288 | });
|
289 | return;
|
290 | }
|
291 | }
|
292 |
|
293 | const token = req.headers['x-api-token'] || req.query.apiToken || req.session.apiToken;
|
294 |
|
295 | if (!token) {
|
296 | res.status(401);
|
297 | res.send({
|
298 | code: 401,
|
299 | message: 'Not authorised, missing token',
|
300 | });
|
301 | return;
|
302 | }
|
303 |
|
304 | if (config.api.blacklistToken.indexOf(token) > -1) {
|
305 | res.status(401);
|
306 | res.send({
|
307 | code: 401,
|
308 | message: 'Not authorised, token blacklisted',
|
309 | });
|
310 | return;
|
311 | }
|
312 |
|
313 | try {
|
314 | const payload = jwt.verifyToken(token);
|
315 |
|
316 | req.session.userId = payload.userId;
|
317 | req.session.slug = payload.slug;
|
318 | req.session.role = payload.role || 'guest';
|
319 |
|
320 | } catch (error) {
|
321 | res.status(401);
|
322 | res.send({
|
323 | code: 401,
|
324 | message: `Not authorised, token verification failed (${error.message})`,
|
325 | error,
|
326 | });
|
327 | return;
|
328 | }
|
329 |
|
330 | if (!req.session.slug) {
|
331 | res.status(401);
|
332 | res.send({
|
333 | code: 401,
|
334 | message: 'Not authorised, missing slug',
|
335 | });
|
336 | return;
|
337 | }
|
338 |
|
339 | if (!req.session.role) {
|
340 | req.session.role = 'guest';
|
341 | }
|
342 |
|
343 | if (req.session.userId) {
|
344 | res.set('X-User-Id', req.session.userId);
|
345 | }
|
346 |
|
347 | res.set('X-Environment', config.environment);
|
348 | res.set('X-Slug', req.session.slug);
|
349 | res.set('X-Role', req.session.role);
|
350 |
|
351 | next();
|
352 | };
|
353 |
|
354 |
|
355 |
|
356 | const router = express.Router();
|
357 |
|
358 | const forceHttps = (req, res, next) => {
|
359 | if (
|
360 | (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'] !== 'https') &&
|
361 | (req.headers['cf-visitor'] && JSON.parse(req.headers['cf-visitor']).scheme !== 'https')
|
362 | ) {
|
363 | res.redirect(301, `https://${req.headers.host}${req.path}`);
|
364 | return;
|
365 | }
|
366 | next();
|
367 | };
|
368 |
|
369 | if (config.environment === 'production' && config.forceHttps === true) {
|
370 | if (app.enable) {
|
371 | app.enable('trust proxy');
|
372 | }
|
373 | app.use(forceHttps);
|
374 | }
|
375 |
|
376 | app.use(`/${config.api.prefix}`, headerMiddleware, sessionMiddleware, router);
|
377 |
|
378 | app.get(`/${config.api.prefix}`, (req, res) => {
|
379 | res.send('<pre> ______\n|A |\n| /\\ |\n| / \\ |\n|( )|\n| )( |\n|______|</pre>');
|
380 | });
|
381 |
|
382 |
|
383 |
|
384 | const context = {
|
385 | app,
|
386 | router,
|
387 | cache,
|
388 | authMiddleware,
|
389 | permissionMiddleware,
|
390 | cacheMiddleware,
|
391 | asyncMiddleware,
|
392 | getConfig,
|
393 | handleResponse,
|
394 | handleError,
|
395 | };
|
396 |
|
397 |
|
398 |
|
399 | Object.keys(Api).forEach((key) => {
|
400 | context[key] = Api[key];
|
401 | });
|
402 |
|
403 | if (config.logentriesToken) {
|
404 | context.log = new Logger({
|
405 | token: config.logentriesToken,
|
406 | });
|
407 | }
|
408 |
|
409 | const afterResponse = (req, res) => {
|
410 | res.removeListener('finish', afterResponse);
|
411 | res.removeListener('close', afterResponse);
|
412 | };
|
413 |
|
414 | if (config.environment !== 'production') {
|
415 | app.use((req, res, next) => {
|
416 | res.on('finish', afterResponse.bind(null, req, res));
|
417 | res.on('close', afterResponse.bind(null, req, res));
|
418 | next();
|
419 | });
|
420 | }
|
421 |
|
422 |
|
423 |
|
424 | require('./routes/analytics')(context, config);
|
425 | require('./routes/auth')(context, config);
|
426 | require('./routes/cache')(context, config);
|
427 | require('./routes/config')(context, config);
|
428 | require('./routes/debug')(context, config);
|
429 | require('./routes/ecommerce')(context, config);
|
430 | require('./routes/email')(context, config);
|
431 | require('./routes/embedly')(context, config);
|
432 | require('./routes/entity')(context, config);
|
433 | require('./routes/file')(context, config);
|
434 | require('./routes/metadata')(context, config);
|
435 | require('./routes/pdf')(context, config);
|
436 | require('./routes/schema')(context, config);
|
437 | require('./routes/settings')(context, config);
|
438 | require('./routes/shippo')(context, config);
|
439 | require('./routes/social')(context, config);
|
440 | require('./routes/stripe')(context, config);
|
441 | require('./routes/taxonomy')(context, config);
|
442 | require('./routes/token')(context, config);
|
443 | require('./routes/tools')(context, config);
|
444 |
|
445 | require('./routes/upload')(context, config);
|
446 | require('./routes/user')(context, config);
|
447 | require('./routes/zencode')(context, config);
|
448 |
|
449 | return app;
|
450 | }
|
451 |
|
452 | module.exports = AceApiServer;
|