const express = require('express');
const path = require('path');
const fs = require('fs-extra');
const os = require('os');
const Utils = require('../Utils.js');
const http = require('http');
const https = require('https');
const { spawn } = require('child_process');
const selfsigned = require('selfsigned');
const exporters = require('../exporters/index.js');
const otreeStatic = require('../dialects/otree/static.js');
const AdminAuth = require('./AdminAuth.js');
/** Opens a URL in the default browser. */
function openUrl(url) {
var command = { darwin: 'open', win32: 'cmd' }[process.platform] || 'xdg-open';
var args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
var child = spawn(command, args, { detached: true, stdio: 'ignore' });
child.on('error', function(err) {
console.log('jtree: could not open a browser (' + err.message + '), open ' + url);
});
child.unref();
}
/**
* Rewrites root-absolute URLs (src="/shared/...", href='/admin/...', action="/admin") in HTML
* responses to sit under basePath, so pages written for the root work wherever jtree is served.
* Applies to every HTML page: jtree's own, and experiment apps' stages.
*/
function prefixHtmlUrls(basePath) {
if (basePath === '') {
return function(req, res, next) { next(); };
}
var escaped = basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var rootUrl = new RegExp('\\b(src|href|action)(\\s*=\\s*)(["\'])\\/(?!\\/|' + escaped.substring(1) + '\\/)', 'gi');
return function(req, res, next) {
var write = res.write;
var end = res.end;
var chunks = [];
var isHtml = function() {
return /^text\/html/.test(res.getHeader('Content-Type') || '');
};
res.write = function(chunk, encoding) {
if (!isHtml()) {
return write.apply(res, arguments);
}
chunks.push(Buffer.from(chunk, encoding));
return true;
};
res.end = function(chunk, encoding) {
if (!isHtml() || req.method === 'HEAD') {
return end.apply(res, arguments);
}
if (chunk != null && typeof chunk !== 'function') {
chunks.push(Buffer.from(chunk, encoding));
}
var html = Buffer.concat(chunks).toString('utf8').replace(rootUrl, '$1$2$3' + basePath + '/');
if (!res.headersSent) {
res.setHeader('Content-Length', Buffer.byteLength(html));
}
return end.call(res, html);
};
next();
};
}
/**
* The first non-internal IPv4 address, so links given to participants work from other machines
* on the network. Loopback aliases (e.g. 10.x addresses on lo0) count as internal.
*/
function lanAddress() {
var interfaces = os.networkInterfaces();
for (var name in interfaces) {
var addresses = interfaces[name] || [];
for (var i = 0; i < addresses.length; i++) {
if (addresses[i].family === 'IPv4' && !addresses[i].internal) {
return addresses[i].address;
}
}
}
return 'localhost';
}
/** Server for static files */
class StaticServer {
constructor(jt) {
this.jt = jt;
var expApp = express();
expApp.use(express.urlencoded({extended : true}));
/** Who may use the admin interface, see {@link AdminAuth}. */
this.auth = new AdminAuth.new(jt);
expApp.use(this.auth.sessionMiddleware);
// After the session, whose own res.end wrapper sends the headers early: this one must run first.
expApp.use(prefixHtmlUrls(jt.basePath));
var self = this;
this.expApp = expApp;
//////////////////////////////
// ADMIN LOGIN
expApp.get('/admin/login', (req, res) => this.auth.sendLoginPage(req, res));
// Older login forms post to /admin.
expApp.post(['/admin/login', '/admin'], (req, res) => this.auth.login(req, res));
expApp.use(['/admin', '/api', '/session-download', '/source'], this.auth.requireAdmin());
//////////////////////////////
//////////////////////////////
// FILES TO SERVE
expApp.use(express.static(path.join(this.jt.path, jt.settings.participantUI)));
expApp.use('/help', express.static(path.join(this.jt.path, jt.settings.helpPath)));
expApp.use('/source', express.static(path.join(this.jt.path, jt.settings.adminUIsSharedPath)));
// Admin interfaces may be TypeScript and Vue files that the browser compiles (admin/v2),
// which would otherwise go out as video/mp2t and application/octet-stream.
var adminStatic = (dir) => express.static(dir, {
setHeaders: (res, filePath) => {
if (/\.(ts|vue)$/.test(filePath)) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}
},
});
expApp.use('/admin', adminStatic(this.defaultAdminUIPath()));
var adminUIs = this.adminUIs();
for (var i in adminUIs) {
expApp.use('/admin/' + adminUIs[i], adminStatic(path.join(this.adminUIsPath(), adminUIs[i])));
}
expApp.use('/participant', express.static(path.join(this.jt.path, jt.settings.participantUI)));
console.log('serving shared', path.join(this.jt.path, jt.settings.sharedUI))
// shared.js is made when jtree starts (generateSharedJS), where clientJSFile says: serve
// that one, which may be outside the shared folder.
expApp.get('/shared/shared.js', (req, res) => res.sendFile(path.resolve(this.jt.path, jt.settings.clientJSFile)));
expApp.use('/shared', express.static(path.join(this.jt.path, jt.settings.sharedUI)));
for (let i in jt.data.queues) {
let queue = jt.data.queues[i];
jt.log('serving files from ' + queue.parentFolderFullName() + ' as /' + queue.parentFolderName());
expApp.use('/' + queue.parentFolderName(), express.static(queue.parentFolderFullName()));
}
// END FILE SERVING
//////////////////////////////
//////////////////////////////
// REQUESTS
expApp.get('/', function(req, res) {
let pId = null;
if (req.query.id != null) {
pId = req.query.id;
}
self.sendParticipantPage(req, res, pId, undefined);
});
expApp.get('/api/sessions', function(req, res) {
let sessions = self.jt.data.loadSessions();
let out = [];
for (let i=0; i<sessions.length; i++) {
out.push(sessions[i].shell());
}
res.send(out);
});
// The admin interfaces' app editors (admins only, as all of /api).
expApp.use('/api/editor', require('../editors/index.js').router(jt));
expApp.get('/api/apps', function(req, res) {
res.send(Object.values(self.jt.data.appsMetaData));
});
expApp.get('/api/clients', function(req, res) {
let clients = self.jt.data.getClients(req.params.sessionId);
// let out = [];
// for (let i=0; i<clients.length; i++) {
// out.push(clients[i].shell());
// }
res.send(clients);
});
// The default admin interface, when its folder has no index.html for express.static to find.
expApp.get(['/admin', '/admin/'], (req, res) => {
res.sendFile(path.join(this.defaultAdminUIPath(), 'admin.html'));
});
expApp.get('/:pId', this.handleRequest.bind(this));
expApp.post('/:pId', this.handleRequest.bind(this));
expApp.get('/room/:rId', function(req, res) {
var room = self.jt.data.room(req.params.rId);
if (room == null) {
return self.sendNoRoom(res, req.params.rId);
}
res.cookie('roomId', req.params.rId);
res.cookie('roomDN', room.displayName);
res.cookie('hasSecret', room.useSecureURLs);
res.sendFile(path.join(self.jt.path, self.jt.settings.clientUI, '/room.html'));
});
// oTree apps' static files (see dialects/otree/static.js).
expApp.get('/otree-static/:pkg/*file', function(req, res) {
var rel = [].concat(req.params.file).join('/');
var full = otreeStatic.file(req.params.pkg, rel);
if (full == null) {
return res.status(404).type('text').send('No such file.');
}
res.sendFile(full);
});
// A session's data, in one of the formats of server/source/exporters (jtree's by default).
expApp.get(['/session-download/:sId', '/session-download/:sId/:format'], function(req, res) {
var session = self.jt.data.session(req.params.sId);
if (session == null) {
return res.status(404).type('text').send('There is no session "' + req.params.sId + '".');
}
var format = exporters.exporter(session, req.params.format || 'jtree');
if (format == null) {
return res.status(404).type('text').send('Session "' + req.params.sId + '" has no data as "' + req.params.format + '".');
}
res.setHeader('Content-disposition', 'attachment; filename=' + format.filename(session));
res.set('Content-Type', format.contentType);
res.status(200).send(format.write(session));
});
expApp.get('/room/:rId/:pId', function(req, res) {
var room = self.jt.data.room(req.params.rId);
if (room == null) {
return self.sendNoRoom(res, req.params.rId);
}
if (room.isValidPId(req.params.pId, req.params.hash)) {
res.sendFile(path.join(self.jt.path, self.jt.settings.participantUI + '/readyClient.html'));
} else {
res.cookie('roomId', req.params.rId);
res.cookie('participantId', req.params.pId);
res.cookie('roomDN', room.displayName);
res.cookie('hasSecret', room.useSecureURLs);
res.sendFile(path.join(self.jt.path, self.jt.settings.clientUI, '/room.html'));
}
});
// GET /logout
expApp.get('/users/logout', function(req, res, next) {
if (req.session) {
for (var i in self.jt.data.users) {
var user = self.jt.data.users[i];
if (user.id === req.session.userId) {
var ind = user.sessionIds.indexOf(req.session.id);
user.sessionIds.splice(ind, 1);
}
}
// delete session object
req.session.destroy(function(err) {
if(err) {
return next(err);
} else {
return res.redirect(jt.basePath + '/admin');
}
});
}
});
// expApp.get('/', function(req, res) {
// self.sendParticipantPage(req, res, req.query.id, undefined);
// });
//
expApp.get('/session/:sId/:pId', function(req, res) {
self.sendParticipantPage(req, res, req.params.pId, req.params.sId);
});
// Admin interfaces without an index.html, e.g. /admin/multiuser.
for (let i in adminUIs) {
let adminHtml = path.join(this.adminUIsPath(), adminUIs[i], 'admin.html');
expApp.get('/admin/' + adminUIs[i], function(req, res, next) {
if (!fs.existsSync(adminHtml)) {
return next();
}
res.sendFile(adminHtml);
});
}
// END REQUESTS
//////////////////////////////
//////////////////////////////
// START SERVER
// Hosted by another program (see jtree.js), attach to its server; otherwise listen on our own.
if (jt.httpServer != null) {
this.attach(jt.httpServer);
} else {
this.listen();
}
//////////////////////////////
}
/**
* Serve from a server owned by a hosting program (e.g. JAS), which mounts {@link StaticServer#expApp}
* and decides the port.
*/
attach(httpServer) {
var jt = this.jt;
this.server = httpServer;
this.ip = lanAddress();
if (jt.settings.useHTTPS) {
console.log('jtree: useHTTPS is ignored, the hosting server decides the protocol');
}
this.server.on('listening', () => {
this.port = this.server.address().port;
jt.settings.port = this.port;
jt.settings.server.ip = this.ip;
jt.settings.server.port = this.port;
let protocol = this.server instanceof https.Server ? 'https://' : 'http://';
let adminUrl = protocol + 'localhost:' + this.port + jt.basePath + '/admin/';
console.log('jtree ' + jt.version);
console.log(' admin: ' + adminUrl);
console.log(' participants: ' + protocol + this.ip + ':' + this.port + jt.basePath + '/');
this.generateSharedJS(jt.settings.clientJSTemplateFile, jt.settings.clientJSFile);
if (jt.settings.openAdminOnStart) {
openUrl(adminUrl);
}
});
}
listen() {
var jt = this.jt;
var self = this;
var expApp = this.expApp;
// Serve under jt.basePath, and send visitors to the root there.
if (jt.basePath !== '') {
expApp = express();
expApp.use(jt.basePath, this.expApp);
expApp.get('/', function(req, res) {
res.redirect(jt.basePath + '/');
});
}
this.port = jt.settings.port;
this.ip = lanAddress();
let protocol = jt.settings.useHTTPS ? 'https://' : 'http://';
// HTTPS gets a self-signed certificate, made before listening; the server is
// made now so that socket.io can attach to it.
let ready = Promise.resolve();
if (jt.settings.useHTTPS) {
this.server = https.createServer(expApp);
ready = selfsigned.generate([{ name: 'commonName', value: this.ip }], { days: 365 }).then((pems) => {
this.server.setSecureContext({ key: pems.private, cert: pems.cert });
});
} else {
this.server = http.createServer(expApp);
}
this.server.on('listening', () => {
jt.settings.server.ip = self.ip;
jt.settings.server.port = self.port;
// Admin opens from this computer without a password (see AdminAuth), so point it at localhost.
let adminUrl = protocol + 'localhost:' + this.port + jt.basePath + '/admin/';
console.log('###############################################');
console.log('jtree ' + jt.version);
console.log(' admin: ' + adminUrl);
console.log(' participants: ' + protocol + this.ip + ':' + this.port + jt.basePath + '/');
// After listening: an occupied port moves this.port.
this.generateSharedJS(jt.settings.clientJSTemplateFile, jt.settings.clientJSFile);
if (jt.settings.openAdminOnStart) {
openUrl(adminUrl);
}
});
let portsTried = [];
this.server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
portsTried.push(jt.settings.port);
this.server.close();
let nextPort = 80;
// On many OSses, ports below 1024 require admin access.
for (let i=1025; i<9999; i++) {
if (!portsTried.includes(i)) {
nextPort = i;
break;
}
}
console.log('Something is already running on port ' + jt.settings.port + '. Retrying on port ' + nextPort + '...');
jt.settings.port = nextPort;
this.port = jt.settings.port;
this.server.listen(this.port);
}
});
ready.then(() => this.server.listen(this.port), (err) => {
console.error('jtree: could not make an HTTPS certificate: ' + err);
process.exit(1);
});
}
// generateClientModels() {
// // var file = babel.transformFileSync(path.join(this.jt.path, "../server/source/App.js"), {});
// // fs.writeFileSync(path.join(this.jt.path, 'internal/clients/admin/shared/models.js'), file.code);
// }
handleRequest(req, res) {
if (req.params.pId === 'favicon.ico') {
return res.status(404).end();
}
this.sendParticipantPage(req, res, req.params.pId, undefined);
}
sendNoRoom(res, rId) {
res.status(404).type('text').send('There is no room "' + rId + '".');
}
/** Names of the admin interfaces: the folders in {@link Settings#adminUIsPath}. */
adminUIs() {
return fs.readdirSync(this.adminUIsPath()).filter((name) => {
return fs.statSync(path.join(this.adminUIsPath(), name)).isDirectory();
});
}
/** The admin interface served at /admin: {@link Settings#defaultAdminUI}, or multiuser if that one is missing. */
defaultAdminUIPath() {
var ui = path.join(this.adminUIsPath(), this.jt.settings.defaultAdminUI);
if (!fs.existsSync(ui)) {
if (!this.warnedNoDefaultAdminUI) {
this.warnedNoDefaultAdminUI = true;
console.log('jtree: there is no admin interface "' + this.jt.settings.defaultAdminUI + '" in ' + this.adminUIsPath() + ', serving multiuser at /admin');
}
return path.join(this.adminUIsPath(), 'multiuser');
}
return ui;
}
adminUIsPath() {
return path.join(this.jt.path, this.jt.settings.adminUIsPath);
}
/**
* Folder containing content common to all admin UIs.
*/
adminUIsSharedPath() {
return path.join(this.adminUIsPath(), this.jt.settings.adminUIsSharedPath);
}
/**
* Generates "shared.js", to be used by all clients to connect to server.
* Copies 'sharedTemplate.js', filling in this machine's IP, the port and the route.
*/
generateSharedJS(inFile, outFile) {
var fn = path.join(this.jt.path, inFile) // file with marker
var newFN = path.join(this.jt.path, outFile) // actual file to be sent to clients and admins
try {
var text = fs.readFileSync(fn, 'utf8')
.replaceAll('{{{SERVER_IP}}}', this.ip)
.replaceAll('{{{SERVER_PORT}}}', String(this.port))
.replaceAll('{{{BASE_PATH}}}', this.jt.basePath)
.replaceAll('{{{ASK_BEFORE_LEAVING}}}', String(this.jt.settings.askBeforeLeaving !== false));
fs.writeFileSync(newFN, text);
} catch (err) {
console.error(err);
}
}
sendParticipantPage(req, res, pId, sessionId) {
var session = null;
if (sessionId == null || sessionId == undefined) {
session = this.jt.data.getMostRecentActiveSession();
} else {
session = Utils.findByIdWOJQ(this.jt.data.sessions, sessionId);
}
// If asked for a particular session, and that session:
// - does not exists, send invalid session page.
// - does exist, send participant page for that session.
// If did not ask for particular session,
// - send default start page.
if (sessionId != null && session === null) {
res.sendFile(path.resolve(this.jt.path, './' + this.jt.settings.participantUI + '/invalidSession.html'));
} else {
if (session != null) {
session.sendParticipantPage(req, res, pId);
} else {
res.sendFile(path.join(this.jt.path, this.jt.settings.participantUI + '/readyClient.html'));
}
}
}
}
var exports = module.exports = {};
exports.new = StaticServer;