All files / services ExpressServer.js

68.96% Statements 140/203
100% Branches 9/9
66.66% Functions 6/9
68.96% Lines 140/203

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 2031x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           1x 1x 2x 2x 2x 1x 1x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                                                 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                     1x 1x
import path from 'node:path';
import express from 'express';
import i18n from 'i18n';
 
import createError from 'http-errors';
import {
    cacheGetProjectBugsUrl,
    cacheGetProjectHomepage,
    cacheGetProjectMetadata,
    cacheGetVersion
} from "../lib/MemoryCache.js";
import {unauthorized} from "../lib/CommonApi.js";
import {generateErrorId} from "../lib/Common.js";
import {StatusCodes} from "http-status-codes";
import ApplicationConfig from "../config/ApplicationConfig.js";
 
const __dirname = path.resolve();
const wwwPath = path.join(__dirname, './src/www');
 
const BES_ISSUES = cacheGetProjectBugsUrl();
 
const HEALTH_ENDPOINT = '/health';
const UNAUTHORIZED_FRIENDLY = "Le milieu autorisĂ© c'est un truc, vous y ĂȘtes pas vous hein !";// (c) Coluche
export default class ExpressServer {
    constructor(services) {
        const {
            config, loggerService, blueskyService,
            botService, newsService,
            auditLogsService, summaryService, inactivityDetector
        } = services;
        this.config = config;
        this.blueskyService = blueskyService;
        this.botService = botService;
        this.newsService = newsService;
        this.auditLogsService = auditLogsService;
        this.summaryService = summaryService;
        this.inactivityDetector = inactivityDetector;
 
        this.logger = loggerService.getLogger().child({label: 'ExpressServer'});
 
        this.port = config.port;
        this.tokenSimulation = config.bot.tokenSimulation;
        this.tokenAction = config.bot.tokenAction;
        this.version = cacheGetVersion();
        this.logger.debug("build", this.version);
        this.sendAutditLogs = auditLogsService.notifyLogs.bind(auditLogsService);
    }
 
    async init() {
        const expressServer = this;
        expressServer.logger.debug("init()");
 
        // doc: https://github.com/mashpie/i18n-node
        // as singleton // https://github.com/mashpie/i18n-node?tab=readme-ov-file#as-singleton
        i18n.configure({
            locales: ['fr', 'en'],
            directory: path.join(__dirname, 'src','locales'),
            defaultLocale: 'en',
            queryParameter: 'lang',// query parameter to switch locale (ie. /home?lang=ch) - defaults to NULL
            cookie: 'lang'
        });
 
        expressServer.app = express();
 
        expressServer.app.use(express.static(path.join(wwwPath, './public')));
        expressServer.app.use(expressServer.handleActivityTic.bind(this));
        expressServer.app.use(expressServer.i18n.bind(this));
        expressServer.app.set('views', path.join(wwwPath, './views'));
        expressServer.app.set('view engine', 'ejs');
        expressServer.app.get('/api/about', expressServer.aboutResponse.bind(this));
        expressServer.app.get('/api/hook', expressServer.hookResponse.bind(this));
        expressServer.app.get(HEALTH_ENDPOINT, (req, res) => res.status(200));
        expressServer.app.get('/*', expressServer.webPagesResponse.bind(this));// default
        expressServer.app.use((req, res, next) => next(createError(404)));// catch 404 and forward to error handler
        expressServer.app.use(expressServer.errorHandlerMiddleware.bind(this));// error handler
 
        // build initial cache
        await this.summaryService.cacheGetWeekSummary({})
 
        // register inactivity listener
        this.inactivityDetector.registerOnInactivityListener(
            async () => {
                await ApplicationConfig.sendAuditLogs();
            }
        )
 
        expressServer.listeningServer = await expressServer.app.listen(expressServer.port);
        expressServer.logger.info(`Bot ${expressServer.version} listening on ${expressServer.port} with health on ${HEALTH_ENDPOINT}`);
        return expressServer.listeningServer;
    }
 
    getRemoteAddress(request) {
        return request.headers['x-forwarded-for'] ?
            request.headers['x-forwarded-for']
            : request.connection?.remoteAddress
            || "???";
    }
 
    handleActivityTic(req, res, next) {
        this.inactivityDetector.activityTic();
        next();
    }
 
    i18n(req, res, next) {
        i18n.init(req, res);
        return next();
    }
 
    aboutResponse(req, res) {
        const {version} = this;
        res.json({version});
    }
 
    /**
     * trigger bot action
     * @param req
     * @param res cron job minimal response payload
     * @returns {Promise<void>}
     */
    async hookResponse(req, res) {
        const expressServer = this;
        try {
            const currentLocale = i18n.getLocale();
            const remoteAddress = expressServer.getRemoteAddress(req);
            const apiToken = req.get('API-TOKEN');
            const pluginName = req.get('PLUGIN-NAME');
            const doSimulate = expressServer.tokenSimulation && apiToken === expressServer.tokenSimulation;
            expressServer.context = {currentLocale, remoteAddress, pluginName, doSimulate};
            let doAction = !doSimulate && expressServer.tokenAction && apiToken === expressServer.tokenAction;
            if ("simulateError" === pluginName) {
                throw new Error("simulateError");
            }
            if (doSimulate || doAction) {
                await expressServer.botService.process(remoteAddress, doSimulate, pluginName);
                res.status(200).json({success: true});
            } else {
                this.logger.debug(StatusCodes.UNAUTHORIZED, JSON.stringify({code: 401, doSimulate, doAction}));
                unauthorized(res, UNAUTHORIZED_FRIENDLY);
            }
        } catch (error) {
            if (error.status && error.message) {
                const {status, message} = error;
                res.status(status).json({success: false, message});
                return;
            }
            let errId = generateErrorId();
            // internal
            let errorInternalDetails = `Error id:${errId} msg:${error.message} stack:${error.stack}`;
            this.logger.error(errorInternalDetails);
            this.auditLogsService.createAuditLog(errorInternalDetails);
            // user
            const unexpectedError = res.__('server.error.unexpected');
            const pleaseReportIssue = res.__('server.pleaseReportIssue');
            const issueLink = res.__('server.issueLinkLabel');
            let userErrorTxt =  `${unexpectedError}, ${pleaseReportIssue} ${BES_ISSUES} - ${errId}`;
            let userErrorHtml = `${unexpectedError},${pleaseReportIssue} <a href="${BES_ISSUES}">${issueLink}</a> - ${errId}`;
            this.newsService.add(userErrorHtml);
            res.status(500).json({success: false, message: userErrorTxt});
        }
    }
 
    async webPagesResponse(req, res) {
        const {version, newsService, config, summaryService} = this;
        const projectHomepage = cacheGetProjectHomepage();
        const projectIssues = cacheGetProjectBugsUrl();
        const projectDiscussions = cacheGetProjectMetadata("projectDiscussions");
        const blueskyAccount = cacheGetProjectMetadata("blueskyAccount");
        const blueskyDisplayName = cacheGetProjectMetadata("blueskyDisplayName");
        const summary = await summaryService.cacheGetWeekSummary({});
        newsService.getNews()
            .then(news => {
                res.render('pages/index', {// page data
                    __: res.__,
                    // locale: res.currentLocale,
                    news, "tz": config.tz,
                    version, projectHomepage, projectIssues, projectDiscussions,
                    blueskyAccount, blueskyDisplayName,
                    summary
                });
            });
    }
 
    async errorHandlerMiddleware(err, req, res) {
        const {logger, context} = this;
        const url = req.url;
        const status = err.status || 500;
        try {
            const src_ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
            const method = req.method;
            if (status === 404) {
                logger.info(`SEC ${err} - ip:${src_ip} - ${method} ${url} - ${status}`,
                    {...context, status, url, "security": true}
                );
            } else {
                logger.error(`${err} - ip:${src_ip} - ${method} ${url} - ${status}`, {...context, status, url});
            }
        } catch (e) {
            logger.error(`errorHandlerMiddleware unexpected error: ${e.message}`, {...context, status, url});
        }
        res.status(status).send();
    }
 
}