mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-07-27 07:04:44 +02:00
391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
/* eslint-disable no-param-reassign, no-console, strict, global-require, no-unused-vars, func-names */
|
|
|
|
'use strict';
|
|
|
|
/* ********* Polyfills (for node) ********* */
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
require('@babel/register')({
|
|
// This will override `node_modules` ignoring - you can alternatively pass
|
|
// an array of strings to be explicitly matched or a regex / glob
|
|
ignore: [
|
|
/node_modules\/(?!react-leaflet|@babel\/runtime\/helpers\/esm|@digitransit-util)/,
|
|
],
|
|
});
|
|
|
|
global.fetch = require('node-fetch');
|
|
const proxy = require('express-http-proxy');
|
|
|
|
global.self = { fetch: global.fetch };
|
|
|
|
const devhost = '';
|
|
|
|
process.on('unhandledRejection', (reason, p) => {
|
|
console.log('Unhandled Rejection at:', p, 'reason:', reason);
|
|
});
|
|
|
|
/* ********* Server ********* */
|
|
const express = require('express');
|
|
const expressStaticGzip = require('express-static-gzip');
|
|
const cookieParser = require('cookie-parser');
|
|
const bodyParser = require('body-parser');
|
|
const logger = require('morgan');
|
|
const { CosmosClient } = require('@azure/cosmos');
|
|
const { getJson } = require('../app/util/xhrPromise');
|
|
const { retryFetch } = require('../app/util/fetchUtils');
|
|
const configTools = require('../app/config');
|
|
|
|
const config = configTools.getConfiguration();
|
|
|
|
const appRoot = `${process.cwd()}/`;
|
|
const configsDir = path.join(appRoot, 'app', 'configurations');
|
|
const configFiles = fs
|
|
.readdirSync(configsDir)
|
|
.filter(file => file.startsWith('config'));
|
|
let allZones;
|
|
|
|
/* ********* Global ********* */
|
|
const port = config.PORT || 8080;
|
|
const app = express();
|
|
const { indexPath, hostnames } = config;
|
|
|
|
/* Setup functions */
|
|
function setUpOpenId() {
|
|
const setUpOIDC = require('./passport-openid-connect/openidConnect').default;
|
|
if (process.env.DEBUGLOGGING) {
|
|
app.use(logger('dev'));
|
|
}
|
|
app.use(bodyParser.json());
|
|
app.use(bodyParser.urlencoded({ extended: false }));
|
|
app.use(cookieParser());
|
|
app.use(
|
|
require('helmet')({
|
|
contentSecurityPolicy: false,
|
|
referrerPolicy: false,
|
|
expectCt: false,
|
|
}),
|
|
);
|
|
setUpOIDC(app, port, indexPath, hostnames);
|
|
}
|
|
|
|
function setUpStaticFolders() {
|
|
// First set up a specific path for sw.js
|
|
if (process.env.ASSET_URL) {
|
|
const swText = fs.readFileSync(
|
|
path.join(process.cwd(), '_static', 'sw.js'),
|
|
{ encoding: 'utf8' },
|
|
);
|
|
const injectionPoint = swText.indexOf(';') + 2;
|
|
const swPreText = swText.substring(0, injectionPoint);
|
|
const swPostText = swText.substring(injectionPoint);
|
|
const swInjectionText = fs
|
|
.readFileSync(path.join(process.cwd(), 'server', 'swInjection.js'), {
|
|
encoding: 'utf8',
|
|
})
|
|
.replace(/ASSET_URL/g, process.env.ASSET_URL);
|
|
const swTextInjected = swPreText + swInjectionText + swPostText;
|
|
|
|
app.get(`${config.APP_PATH}/sw.js`, (req, res) => {
|
|
res.setHeader('Cache-Control', 'public, max-age=0');
|
|
res.setHeader('Content-type', 'application/javascript; charset=UTF-8');
|
|
res.send(swTextInjected);
|
|
});
|
|
}
|
|
|
|
const staticFolder = path.join(process.cwd(), '_static');
|
|
// Sert cache for 1 week
|
|
const oneDay = 86400000;
|
|
app.use(
|
|
config.APP_PATH,
|
|
expressStaticGzip(staticFolder, {
|
|
enableBrotli: true,
|
|
index: false,
|
|
maxAge: 14 * oneDay,
|
|
setHeaders(res, reqPath) {
|
|
if (
|
|
reqPath.toLowerCase().includes('sw.js') ||
|
|
reqPath.toLowerCase().includes('appcache')
|
|
) {
|
|
res.setHeader('Cache-Control', 'public, max-age=0');
|
|
}
|
|
// Always set cors header
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setUpMiddleware() {
|
|
app.use(cookieParser());
|
|
app.use(bodyParser.raw());
|
|
if (process.env.NODE_ENV === 'development') {
|
|
const hotloadPort = process.env.HOT_LOAD_PORT || 9000;
|
|
// proxy for dev-bundle
|
|
app.use('/proxy/', proxy(`http://localhost:${hotloadPort}/`));
|
|
}
|
|
}
|
|
|
|
function onError(err, req, res) {
|
|
res.statusCode = 500;
|
|
res.end(err.message + err.stack);
|
|
}
|
|
|
|
function setUpErrorHandling() {
|
|
app.use(onError);
|
|
}
|
|
|
|
function setUpRoutes() {
|
|
app.use(
|
|
['/', '/fi/', '/en/', '/sv/', '/ru/', '/slangi/'],
|
|
require('./reittiopasParameterMiddleware').default,
|
|
);
|
|
app.use(require('../app/server').default);
|
|
|
|
// Make sure req has the correct hostname extracted from the proxy info
|
|
app.enable('trust proxy');
|
|
}
|
|
|
|
function processTicketTypeResult(result) {
|
|
const resultData = result.data;
|
|
if (config.availableTickets) {
|
|
if (resultData && Array.isArray(resultData.ticketTypes)) {
|
|
resultData.ticketTypes.forEach(ticket => {
|
|
const ticketFeed = ticket.fareId.split(':')[0];
|
|
if (config.availableTickets[ticketFeed] === undefined) {
|
|
config.availableTickets[ticketFeed] = {};
|
|
}
|
|
config.availableTickets[ticketFeed][ticket.fareId] = {
|
|
price: ticket.price,
|
|
zones: ticket.zones,
|
|
};
|
|
});
|
|
console.log('availableTickets loaded');
|
|
} else {
|
|
console.log('could not load availableTickets, result was invalid');
|
|
}
|
|
} else {
|
|
console.log(
|
|
'availableTickets not loaded, missing availableTickets object from config-file',
|
|
);
|
|
}
|
|
}
|
|
|
|
function setUpAvailableTickets() {
|
|
return new Promise(resolve => {
|
|
const options = {
|
|
method: 'POST',
|
|
body: '{ ticketTypes { price fareId zones } }',
|
|
headers: { 'Content-Type': 'application/graphql' },
|
|
};
|
|
const queryParameters = config.hasAPISubscriptionQueryParameter
|
|
? `?${config.API_SUBSCRIPTION_QUERY_PARAMETER_NAME}=${config.API_SUBSCRIPTION_TOKEN}`
|
|
: '';
|
|
// try to fetch available ticketTypes every four seconds with 4 retries
|
|
retryFetch(`${config.URL.OTP}gtfs/v1${queryParameters}`, 4, 4000, options)
|
|
.then(res => res.json())
|
|
.then(
|
|
result => {
|
|
processTicketTypeResult(result);
|
|
resolve();
|
|
},
|
|
err => {
|
|
console.log(err);
|
|
if (process.env.BASE_CONFIG) {
|
|
// Patching of availableTickets into cached configs would not work with BASE_CONFIG
|
|
// if availableTickets are fetched after launch
|
|
console.log('failed to load availableTickets at launch, exiting');
|
|
process.exit(1);
|
|
} else {
|
|
// If after 5 tries no available ticketTypes are found, start server anyway
|
|
resolve();
|
|
console.log('failed to load availableTickets at launch, retrying');
|
|
// Continue attempts to fetch available ticketTypes in the background for one day once every minute
|
|
retryFetch(
|
|
`${config.URL.OTP}gtfs/v1${queryParameters}`,
|
|
1440,
|
|
60000,
|
|
options,
|
|
)
|
|
.then(res => res.json())
|
|
.then(
|
|
result => {
|
|
processTicketTypeResult(result);
|
|
},
|
|
error => {
|
|
console.log(error);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function getZoneUrl(json) {
|
|
const zoneLayer =
|
|
!json.noZoneSharing &&
|
|
json.layers.find(
|
|
layer => layer.name.fi === 'Vyöhykkeet' || layer.name.en === 'Zones',
|
|
);
|
|
if (zoneLayer && !allZones) {
|
|
// use a geoJson source to initialize combined zone data
|
|
allZones = zoneLayer;
|
|
}
|
|
return zoneLayer?.url;
|
|
}
|
|
|
|
async function fetchGeoJsonConfig(url) {
|
|
const response = await getJson(url);
|
|
return response.geoJson || response.geojson;
|
|
}
|
|
|
|
function collectGeoJsonZones() {
|
|
if (!process.env.ASSEMBLE_GEOJSON) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise(mainResolve => {
|
|
const promises = [];
|
|
configFiles.forEach(file => {
|
|
// eslint-disable-next-line import/no-dynamic-require
|
|
const conf = require(`${configsDir}/${file}`);
|
|
const { geoJson } = conf.default;
|
|
if (geoJson) {
|
|
if (geoJson.layerConfigUrl) {
|
|
promises.push(
|
|
new Promise(resolve => {
|
|
fetchGeoJsonConfig(geoJson.layerConfigUrl).then(data => {
|
|
resolve(getZoneUrl(data));
|
|
});
|
|
}),
|
|
);
|
|
} else {
|
|
promises.push(
|
|
new Promise(resolve => {
|
|
resolve(getZoneUrl(geoJson));
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
Promise.all(promises).then(urls => {
|
|
if (allZones) {
|
|
// valid zone data was found
|
|
allZones.url = urls.filter(url => !!url); // drop invalid
|
|
console.log(`Assembled ${allZones.url.length} geoJson zones`);
|
|
configTools.setAssembledZones(allZones);
|
|
}
|
|
mainResolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function startServer() {
|
|
const server = app.listen(port, () =>
|
|
console.log('Digitransit-ui available on port %d', server.address().port),
|
|
);
|
|
}
|
|
|
|
async function fetchCitybikeSeasons() {
|
|
const client = new CosmosClient(process.env.CITYBIKE_DB_CONN_STRING);
|
|
const database = client.database(process.env.CITYBIKE_DATABASE);
|
|
const container = database.container('schedules');
|
|
const query = {
|
|
query: 'SELECT * FROM c',
|
|
};
|
|
|
|
const { resources } = await container.items.query(query).fetchAll();
|
|
console.log('citybike season configurations fetched from the database');
|
|
return resources;
|
|
}
|
|
|
|
function buildCitybikeConfig(seasonDef, configName) {
|
|
const inSeason = seasonDef.inSeason.split('-');
|
|
return {
|
|
configName: seasonDef.configName,
|
|
networkName: seasonDef.networkName,
|
|
enabled: seasonDef.enabled,
|
|
season: {
|
|
preSeasonStart: seasonDef.preSeason,
|
|
start: inSeason[0],
|
|
end: inSeason[1],
|
|
},
|
|
};
|
|
}
|
|
|
|
function handleCitybikeSeasonConfigurations(schedules, configName) {
|
|
const seasonDefinitions = schedules.filter(
|
|
seasonDef => seasonDef.configName === configName,
|
|
);
|
|
const configurations = [];
|
|
seasonDefinitions.forEach(def =>
|
|
configurations.push(buildCitybikeConfig(def, configName)),
|
|
);
|
|
return configurations;
|
|
}
|
|
function fetchCitybikeConfigurations() {
|
|
if (!process.env.CITYBIKE_DB_CONN_STRING || !process.env.CITYBIKE_DATABASE) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise(mainResolve => {
|
|
const promises = [];
|
|
|
|
fetchCitybikeSeasons()
|
|
.then(r => {
|
|
const schedules = [];
|
|
r.forEach(seasonDef => schedules.push(...seasonDef.schedules));
|
|
configFiles.forEach(file => {
|
|
// eslint-disable-next-line import/no-dynamic-require
|
|
const conf = require(`${configsDir}/${file}`);
|
|
const configName = conf.default.CONFIG;
|
|
const { vehicleRental } = conf.default;
|
|
if (vehicleRental && Object.keys(vehicleRental).length > 0) {
|
|
promises.push(
|
|
new Promise(resolve => {
|
|
resolve(
|
|
handleCitybikeSeasonConfigurations(schedules, configName),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
Promise.all(promises).then(definitions => {
|
|
// filter empty objects and duplicates
|
|
const seasonDefinitions = definitions
|
|
.filter(seasonDef => Object.keys(seasonDef).length > 0)
|
|
.flat()
|
|
.filter(
|
|
(v, i, a) =>
|
|
a.findIndex(v2 => v2.networkName === v.networkName) === i,
|
|
);
|
|
console.log(
|
|
`fetched: ${seasonDefinitions.length} citybike season configuration`,
|
|
);
|
|
console.log(seasonDefinitions);
|
|
configTools.setAvailableCitybikeConfigurations(seasonDefinitions);
|
|
mainResolve();
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.log('error fetching citybike season configurations', err);
|
|
mainResolve();
|
|
});
|
|
});
|
|
}
|
|
/* ********* Init ********* */
|
|
|
|
if (process.env.OIDC_CLIENT_ID) {
|
|
setUpOpenId();
|
|
}
|
|
setUpStaticFolders();
|
|
setUpMiddleware();
|
|
setUpRoutes();
|
|
setUpErrorHandling();
|
|
Promise.all([
|
|
setUpAvailableTickets(),
|
|
collectGeoJsonZones(),
|
|
fetchCitybikeConfigurations(),
|
|
]).then(startServer);
|
|
|
|
module.exports.app = app;
|