Source: server.js

'use strict';
/**
 * @module lib/server
 * @summary Whiteflag API server module
 * @description Module with the server and endpoint configuration and handlers
 * @tutorial installation
 * @tutorial configuration
 * @tutorial modules
 * @tutorial openapi
 */
module.exports = {
    // Server functions
    start: startServer,
    stop: stopServer,
    createEndpoints,
    sendSocket,
    monitorSocket,
    // Server functions for testing
    test: {
        getEndpoints,
        getOpenApiEndpoints
    }
};

// Node.js core and external modules //
const fs = require('fs');
const http = require('http');
const https = require('https');
const cors = require('cors');
const express = require('express');
const auth = require('express-basic-auth');
const socket = require('socket.io');

// Whiteflag common functions and classes //
const log = require('./common/logger');
const { ignore } = require('./common/processing');
const { ProcessingError } = require('./common/errors');
const response = require('./common/httpres');

// Whiteflag modules //
const wfApiConfig = require('./config');

// Whiteflag modules with endpoint operations //
const wfApiMessagesHandler = require('./operations/messages');
const wfApiBlockchainsHandler = require('./operations/blockchains');
const wfApiOriginatorsHandler = require('./operations/originators');
const wfApiSignaturesHandler = require('./operations/signatures');
const wfApiTokensHandler = require('./operations/tokens');
const wfApiQueueHandler = require('./operations/queue');

// Module constants //
const MODULELOG = 'server';
const SOCKETPATH = '/socket';
const OPENAPIFILE = '../static/openapi.json';

// Module objects //
let _wfApiConfig;
let _wfApi;
let _wfServer;
let _wfSocket;

// MAIN MODULE FUNCTIONS //
/**
 * Starts the API http server
 * @function startServer
 * @alias module:lib/server.start
 * @param {function(Error, url)} callback function called after starting the server
 * @typedef {string} url the server url
 */
function startServer(callback) {
    // Get configuration
    _wfApiConfig = wfApiConfig.getConfig();
    const protocol = _wfApiConfig.server.protocol || 'http';
    const hostname = _wfApiConfig.server.hostname || '';
    const port = process.env.WFPORT || _wfApiConfig.server.port || '5746';
    const url = `${protocol}://${hostname}:${port}`;

    // Check for valid port
    if (port < 0 || port > 65536) {
        return callback(new Error(`Invalid port number: ${port}`));
    }
    // Create server
    _wfApi = express();
    switch (protocol) {
        case 'http': {
            log.trace(MODULELOG, `Starting server on ${url}`);
            try {
                _wfServer = http.createServer(_wfApi);
            } catch(err) {
                return callback(err, url);
            }
            break;
        }
        case 'https': {
            log.trace(MODULELOG, `Starting server with SSL on ${url}`);
            if (!_wfApiConfig.ssl.keyFile) return callback(new Error('No private key file configured for SSL'));
            if (!_wfApiConfig.ssl.certificateFile) return callback(new Error('No certificate file configured for SSL'));
            try {
                let key = fs.readFileSync(_wfApiConfig.ssl.keyFile);
                let cert = fs.readFileSync(_wfApiConfig.ssl.certificateFile);
                _wfServer = https.createServer({
                    key: key,
                    cert: cert
                }, _wfApi);
            } catch(err) {
                return callback(err, url);
            }
            break;
        }
        default: {
            return callback(new Error(`Unsupported protocol: ${protocol}`));
        }
    }
    // Create socket
    if (_wfApiConfig.socket.enable) {
        // Socket configuration
        let socketConfig = {
            path: SOCKETPATH
        };
        if (_wfApiConfig.http.enableCors) {
            socketConfig.cors = {
                origin: hostname,
                methods: ['GET', 'POST']
            };
        }
        // Open socket
        try {
            _wfSocket = socket(_wfServer, socketConfig);
        } catch(err) {
            return callback(err, url + SOCKETPATH);
        }
        log.info(MODULELOG, `Initialised socket for clients to listen for incoming messages on ${url + SOCKETPATH}`);
    }

    // Static content
    _wfApi.use('/', express.static('./static'));
    _wfApi.use('/docs', express.static('./docs/jsdoc'));

    // Http settings
    if (_wfApiConfig.http.trustProxy) {
        _wfApi.enable('trust proxy');
        log.info(MODULELOG, 'Configured to run behind a trusted reverse proxy');
    }
    _wfApi.disable('x-powered-by');

    // Basic http authorization
    let authConfig = {};
    if (_wfApiConfig.authorization.username && _wfApiConfig.authorization.password) {
        log.info(MODULELOG, 'Enabling basic http authorization');
        try {
            authConfig[_wfApiConfig.authorization.username] = _wfApiConfig.authorization.password;
            _wfApi.use(auth({
                users: authConfig,
                unauthorizedResponse: unauthorizedResponse
            }));
        } catch(err) {
            return callback(err, url);
        }
    } else {
        log.warn(MODULELOG, 'No authentication enabled because no basic http authorization configured');
    }

    // Middleware
    if (_wfApiConfig.http.enableCors) {
        _wfApi.use(cors());
        log.info(MODULELOG, 'Enabled HTTP Cross-Origin Resource Sharing (CORS)');
    }
    _wfApi.use(express.json());
    _wfApi.use(errorHandler);

    // Start listener on the port
    _wfServer.listen(port, hostname, err => {
        if (err) return callback(err, url);
        _wfServer.url = url;
        return callback(err, _wfServer.url);
    });
}

/**
 * Stops the API http server
 * @function stopServer
 * @alias module:lib/server.stop
 * @param {function(Error)} callback function called after stopping the server
 */
function stopServer(callback) {
    // Try to shutdown socket
    if (_wfSocket) {
        log.trace(MODULELOG, 'Removing all listeners on socket');
        _wfSocket.removeAllListeners();
    }
    // Shutdown server
    if (_wfServer) {
        let timer = setTimeout(timeoutCb, 2000);
        log.trace(MODULELOG, `Closing server on ${_wfServer.url}`);
        _wfServer.close(err => {
            clearTimeout(timer);
            return callback(err);
        });
    } else {
        return callback();
    }

    /**
     * Returns callback with timeout error
     * @callback timeout
     */
    function timeoutCb() {
        return callback(new Error('Timeout while closing server'));
    }
}

/**
 * Creates routes to the API endpoints
 * @function createEndpoints
 * @alias module:lib/server.createEndpoints
 * @param {logEndpointEventCb} endpointCb function passed to the endpoint handler to be invoked when done
 * @param {function(Error)} callback function to be called upon completion
 */
function createEndpoints(endpointCb, callback) {
    // Endpoints array index
    const PATH = 0;
    const METHOD = 1;
    const OPERATIONID = 2;
    const HANDLER = 3;

    // Get array with endpoints
    const endpoints = getEndpoints();

    // Check if server is ready
    if (!_wfApi) {
        return callback(new Error('Could not bind endpoint routes to handlers: Server not started'));
    }
    try {
        // Message controller endpoints
        endpoints.forEach(function endpointsCb(endpoint) {
            if (!Object.prototype.hasOwnProperty.call(_wfApiConfig.endpoints, endpoint[OPERATIONID])
                || _wfApiConfig.endpoints[endpoint[OPERATIONID]]) {
                log.debug(MODULELOG, `Creating endpoint: ${endpoint[OPERATIONID]}: ${endpoint[METHOD]} ${endpoint[PATH]}`);
                createEndpoint(endpoint[PATH], endpoint[METHOD], endpoint[OPERATIONID], endpoint[HANDLER], endpointCb);
            } else {
                log.info(MODULELOG, `Disabled endpoint: ${endpoint[OPERATIONID]}: ${endpoint[METHOD]} ${endpoint[PATH]}`);
                createEndpoint(endpoint[PATH], endpoint[METHOD], endpoint[OPERATIONID], forbiddenHandler, endpointCb);
            }
        });
        // Catch-all endpoint; MUST be last one to call
        log.trace(MODULELOG, 'Creating endpoint for all undefined routes and methods');
        createEndpoint('*', '_all', 'undefined', undefinedHandler, endpointCb);
    } catch(err) {
        return callback(err);
    }
    return callback(null);
}

/**
 * Sends out data on socket
 * @function sendSocket
 * @alias module:lib/server.sendSocket
 * @param {string} data
 */
function sendSocket(data) {
    if (!_wfSocket) {
        return log.debug(MODULELOG, 'Could not send data: Socket not initialised');
    }
    _wfSocket.sockets.emit('message', data);
}

/**
 * Monitors socket client connections
 * @function monitorSocket
 * @alias module:lib/server.monitorSocket
 * @param {logEndpointEventCb} callback
 */
function monitorSocket(callback) {
    // Check if socket is ready
    if (!_wfSocket) {
        return callback(new Error('Could not start monitor: Socket not initialised'));
    }
    // Pass event and socket to callback function when client connects
    _wfSocket.on('connection', socket => {
        // Get client ip address
        let clientIp;
        if (_wfApiConfig.http.trustProxy) {
            clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address;
        } else {
            clientIp = socket.handshake.address;
        }
        // Handle socket errors here, no need to further pass to callback
        socket.on('error', err => {
            return callback(null, clientIp, 'error', err.message);
        });
        // Pass socket disconnect details to callback when client disconnects
        socket.on('disconnect', reason => {
            return callback(null, clientIp, 'disconnected', reason);
        });
        // Pass socket connection details to callback
        return callback(null, clientIp, 'connected', 'waiting for data');
    });
}

// PRIVATE MODULE FUNCTIONS //
/**
 * Creates an endpoint
 * @private
 * @param {string} route the path in the URL of the endpoint
 * @param {string} method the HTTP method of the endpoint (GET, POST, etc.)
 * @param {string} operationId the operation id as defined in the openapi definition
 * @param {function} handler the function to handle a client request
 * @param {logEndpointEventCb} endpointCb callback function passed to handler
 */
function createEndpoint(route, method, operationId, handler, endpointCb) {
    switch (method) {
        case 'GET': {
            _wfApi.get(route, function serverEndpointGetCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        case 'POST': {
            _wfApi.post(route, function serverEndpointPostCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        case 'PUT': {
            _wfApi.put(route, function serverEndpointPutCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        case 'PATCH': {
            _wfApi.patch(route, function serverEndpointPutCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        case 'DELETE': {
            _wfApi.delete(route, function serverEndpointDeleteCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        case '_all': {
            _wfApi.all(route, function serverEndpointAllCb(req, res) {
                return handler(req, res, operationId, endpointCb);
            });
            break;
        }
        default: {
            throw new Error(`Internal Coding Error: Wrong HTTP method provided for endpoint ${route}: ${method}`);
        }
    }
}

/**
 * Handles requests w/ undefined endpoints or methods
 * @private
 * @param {Object} req the http request
 * @param {Object} res the http response
 * @param {string} operationId the operation id as defined in the openapi definition
 * @param {logEndpointEventCb} callback function to be called upon completion
 */
function undefinedHandler(req, res, operationId, callback) {
    let resBody = response.createBody(req, operationId);
    let err = new ProcessingError('Undefined endpoint or illegal method', null, 'WF_API_NO_RESOURCE');
    return response.sendImperative(res, err, resBody, null, callback);
}

/**
 * Handles requests w/ disabled endpoints
 * @private
 * @param {Object} req the http request
 * @param {Object} res the http response
 * @param {string} operationId the operation id as defined in the openapi definition
 * @param {logEndpointEventCb} callback
 */
function forbiddenHandler(req, res, operationId, callback) {
    let resBody = response.createBody(req, operationId);
    let err = new ProcessingError(`Configuration does not allow operation: ${operationId}`, null, 'WF_API_NOT_ALLOWED');
    return response.sendImperative(res, err, resBody, null, callback);
}

/**
 * Handles middleware errors
 * @private
 * @param {Error} err the error
 * @param {Object} req the http request
 * @param {Object} res the http response
 * @param {function} next middleware function to be called next
 */
function errorHandler(err, req, res, next) {
    const statusCode = err.status || 500;
    let resBody = response.createBody(req, null);

    // Compose error message
    let errorMessage = err.message || 'Unspecified error';
    errorMessage = `WF_API_MIDDLEWARE_ERROR: ${err.type}: ` + errorMessage;

    // Send error response
    resBody.errors = [ errorMessage ];
    res.status(statusCode).send(resBody);
    ignore(next);

    // Log the error
    log.warn(MODULELOG, `Could not process request: ${JSON.stringify(resBody)}`);
}

/**
 * Creates repsonse body for unauthorised access
 * @private
 * @param {Object} req the http request
 * @returns {Object} the response body
 */
function unauthorizedResponse(req) {
    let resBody = response.createBody(req, null);
    resBody.errors = [ 'Unauthorized to access this endpoint or resource' ];
    log.warn(MODULELOG, `Unauthorised request: ${JSON.stringify(resBody.meta.request)}`);
    return resBody;
}

/**
 * Returns endpoints to be created
 * @private
 * @returns {Array} endpoints
 */
function getEndpoints() {
    return [
        // Message resource endpoints
        [ '/messages', 'GET', 'getMessages', wfApiMessagesHandler.getMessages ],
        [ '/messages/references', 'GET', 'getMessageReferences', wfApiMessagesHandler.getReferences ],
        [ '/messages/sequence', 'GET', 'getMessageSequence', wfApiMessagesHandler.getSequence ],
        [ '/messages/send', 'POST', 'sendMessage', wfApiMessagesHandler.send ],
        [ '/messages/receive', 'POST', 'receiveMessage', wfApiMessagesHandler.receive ],

        // Message validation endpoints
        [ '/messages/validate', 'POST', 'validateMessage', wfApiMessagesHandler.validate ],
        [ '/messages/encode', 'POST', 'encodeMessage', wfApiMessagesHandler.encode ],
        [ '/messages/decode', 'POST', 'decodeMessage', wfApiMessagesHandler.decode ],

        // Blockchain endpoints
        [ '/blockchains', 'GET', 'getAllBlockchains', wfApiBlockchainsHandler.getBlockchains ],
        [ '/blockchains/:blockchain', 'GET', 'getBlockchainState', wfApiBlockchainsHandler.getBlockchain ],

        // Blockchain accounts endpoints
        [ '/blockchains/:blockchain/accounts', 'GET', 'getAccounts', wfApiBlockchainsHandler.getAccounts ],
        [ '/blockchains/:blockchain/accounts/:account', 'GET', 'getAccount', wfApiBlockchainsHandler.getAccount ],
        [ '/blockchains/:blockchain/accounts', 'POST', 'createAccount', wfApiBlockchainsHandler.createAccount ],
        [ '/blockchains/:blockchain/accounts/:account', 'PATCH', 'updateAccount', wfApiBlockchainsHandler.updateAccount ],
        [ '/blockchains/:blockchain/accounts/:account', 'DELETE', 'deleteAccount', wfApiBlockchainsHandler.deleteAccount ],

        // Blockchain account signature and transfer value endpoints
        [ '/blockchains/:blockchain/accounts/:account/sign', 'POST', 'createSignature', wfApiBlockchainsHandler.createSignature ],
        [ '/blockchains/:blockchain/accounts/:account/transfer', 'POST', 'transferFunds', wfApiBlockchainsHandler.transferFunds ],
        [ '/originators', 'GET', 'getAllOriginators', wfApiOriginatorsHandler.getOriginators ],
        [ '/originators/:address', 'GET', 'getOriginator', wfApiOriginatorsHandler.getOriginator ],
        [ '/originators/:address', 'PATCH', 'updateOriginator', wfApiOriginatorsHandler.updateOriginator ],
        [ '/originators/:address', 'DELETE', 'deleteOriginator', wfApiOriginatorsHandler.deleteOriginator ],

        // Originator pre-shared keys
        [ '/originators/:address/psk/:account', 'GET', 'getPreSharedKey', wfApiOriginatorsHandler.getPreSharedKey ],
        [ '/originators/:address/psk/:account', 'PUT', 'storePreSharedKey', wfApiOriginatorsHandler.storePreSharedKey ],
        [ '/originators/:address/psk/:account', 'DELETE', 'deletePreSharedKey', wfApiOriginatorsHandler.deletePreSharedKey ],

        // Originator authentication tokens
        [ '/originators/tokens/:authTokenId', 'GET', 'getAuthToken', wfApiOriginatorsHandler.getAuthToken ],
        [ '/originators/tokens', 'POST', 'storeAuthToken', wfApiOriginatorsHandler.storeAuthToken ],
        [ '/originators/tokens/:authTokenId', 'DELETE', 'deleteAuthToken', wfApiOriginatorsHandler.deleteAuthToken ],
        [ '/signature/decode', 'POST', 'decodeSignature', wfApiSignaturesHandler.decode ],
        [ '/signature/verify', 'POST', 'verifySignature', wfApiSignaturesHandler.verify ],
        [ '/token/create', 'POST', 'createToken', wfApiTokensHandler.create ],

        // Queue
        [ '/queues/:queue', 'GET', 'getQueue', wfApiQueueHandler.getQueue ]
    ];
}

/**
 * Gets openapi endpoint definitions
 * @private
 * @returns {Array} endpoints
 */
function getOpenApiEndpoints() {
    const openapi = JSON.parse(fs.readFileSync(OPENAPIFILE)).paths;
    let endpoints = [];
    Object.keys(openapi).forEach(path => {
        let endpoint = path.replace(/{/g, ':').replace(/}/g, '');
        Object.keys(openapi[path]).forEach(method => {
            let operationId = openapi[path][method].operationId;
            endpoints.push([ endpoint, method.toUpperCase(), operationId ]);
        });
    });
    return endpoints;
}