// =============================================================== // // "Web Graphics Controller" // (c) 2020- Sh Graphics // // No front-end frameworks. // Core functionality: NodeJS, Express, Handlebars -templating. // // Most serverside code is in /routes and /utils. // Most clientside code is in /static/js/spx_gc.js // // Basic usage: // node server.js [config.json] // // =============================================================== const express = require('express') const session = require('express-session') const bodyParser = require('body-parser') const cors = require('cors') const exphbs = require('express-handlebars') const app = express() const ip = require('ip') const fs = require('fs') const morgan = require('morgan') const path = require('path') const rfs = require('rotating-file-stream') var macaddress = require('macaddress'); console.log('\n WebCG Controller server is starting.\n Closing this window/process will stop the server.\n'); // EXIT HANDLER process.on('exit', function(code) { // usage: process.exit(1) let codestr="Unspecified exit code"; switch (code) { case 1: codestr = "Error while reading config.json" break; case 2: codestr = "Kill request received from WebCG client. If pm2 used, the server will restart automatically." break; case 3: codestr = "Port is already in use on this machine." break; default: break; } return console.log(`\n\nWebCG exit! (Errorcode ${code}: ${codestr})\n\n`); }); // global.config const cfg = require('./utils/spx_getconf.js'); cfg.readConfig() if (!configfileref) { process.exit(1) } const spx = require('./utils/spx_server_functions.js'); const logger = require('./utils/logger.js'); // STATICS app.use(express.static(path.join(__dirname,('static')))) app.use(express.static(path.resolve(spx.getStartUpFolder(),'ASSETS'))) const ipad = ip.address(); var pjson = require('./package.json'); var packageversion = pjson.version; const vers = process.env.npm_package_version || packageversion || 'X.X.X'; global.vers = vers; global.excel = {'readtime':0, 'filename':'', 'data':''}; // used as Excel cache // Added in 1.0.15. An memory persistent object to handle ------------- // rundown state to avoid unnecessary disk I/O and improve performance. global.rundownData = {} // -------------------------------------------------------------------- const port = config.general.port || 5656; global.CCGSockets = []; global.LastBrowsedTemplateFolder = ''; macaddress.one(function (err, mc) { let macaddress = String(mc); let pseudomac = macaddress.split(':').join('').substring(0,8); global.hwid = config.general.hostname || pseudomac; global.pmac = pseudomac; }); // Setup Morgan let LOGFOLDER = config.general.logfolder || spx.getStartUpFolder() + '/LOG'; var logDirectory = path.normalize(LOGFOLDER); fs.existsSync(logDirectory) || fs.mkdirSync(logDirectory) var accessLogStream = rfs.createStream('access.log', { interval: '1d', // rotate daily path: logDirectory }) app.use(morgan('combined', { stream: accessLogStream })) // Handlebars templating, updated in 1.3.3 app.engine('handlebars', exphbs.engine({ extname: '.handlebars', //defaultLayout: 'filelist', helpers: { max5: function (data) { if (parseInt(data)>5) { data = 5; } return String(data); }, // Convert JSON data and return as JSONstring. // (used to store arrays to hidden form fields) DataToJSONString: function (data) { let s = encodeURI(JSON.stringify(data)); return s; }, // conditional IF helper ifValue: function(a, b, options) { // console.log('a: ' + a + ', b: ' + b); if(String(a) === String(b) ) { return options.fn(this); } return options.inverse(this); }, // conditional IF helper return value A if "value" == "compareTo", B if not ifValueMatch: function(value, compareTo, A, B ) { if(value == compareTo) { return A; } else { return B; } }, // conditional IF helper itemCount: function(arr) { // console.log('a: ' + a + ', b: ' + b); return arr.length; }, // conditional IF helper ifGreater: function(a, b, options) { if(a > b) { // console.log('TRUE. A (' + a + ') was bigger.'); return options.fn(this); } // console.log('FALSE. B (' + b + ') was bigger.'); return options.inverse(this); }, // populate CasparCG options for show config templates generateDataFormatOptions(currentDataformat='json') { let html=""; let AvailableFormats=['json', 'xml']; AvailableFormats.forEach((Option,i) => { let curFormat = Option.toLowerCase(); let cfgFormat = currentDataformat.toLowerCase(); let sel = ""; if (curFormat==cfgFormat){ sel="selected" } html += ''; }); return html }, // populate CasparCG options for show config templates generateCasparCgPlayoutOptions(currentServer) { let html=""; let AvailableServers=['-']; if (config.casparcg){ config.casparcg.servers.forEach((element,i) => { AvailableServers.push(element.name); }); } AvailableServers.forEach((serverOption,i) => { let curSrv = serverOption.toUpperCase(); let cfgSrv = currentServer.toUpperCase(); let sel = ""; if (curSrv==cfgSrv){ sel="selected" } html += ''; }); return html }, // generate Open datafolder -commands in the UI menu for different operating systems GenerateOpenFolderCommands(){ let platform = process.platform; let html=""; switch (platform) { case 'win32': html += '\n'; html += '
  '+ spx.lang('button.exploredataroot') +'\n'; html += '
  '+ spx.lang('button.exploretemplates') +'\n'; break; default: html = "\n"; break; } return html; }, // draw all custom controls in the argument array GenerateCustomControls(arr){ if (!arr) {return}; // if no extras, do nuthin' let html = ""; html += '\n'; arr.forEach((control,index) => { html += '' html += '' html += '\n' }); html += '
' html += '' + control.description + '' html += '' switch (control.ftype){ case 'button': html += '

\n' return html; }, // generate a pretty string for each template to display playout configs GeneratePlayoutInfo(playserver='', playchannel='', playlayer='', webplayout='', out='' , itemid='') { webplayout = spx.max5(webplayout); let html = '
'; // html += ''; if (playserver !="-") { html += "" + playserver + "-"; html += "" + playchannel + "-"; html += "" + playlayer + ""; html += " "; // html += " "; }; if (webplayout !="-") { html += "" + webplayout + ""; // html += " "; }; if (out) { html += "" + out + ""; }; html += "ID"; // help button html += "?"; html += "
" return html }, // populate filelist for the controller add CSV file view (for instance) generateFileList(requestType) { let folder = '' // folder to start to scan let filter = '' // extensions to show ('csv' for example) // requestType = search command, such as "CSV" switch (requestType) { case 'CSV': folder = path.join(config.general.dataroot, currentShow, 'profile.json'); filter = 'csv'; break; default: logger.error('Unknown requestType ' + requestType + ', doing nothing'); return; // exit function break; } let html=""; // get show profile // iterate all templates let profileFile = path.join(config.general.dataroot, currentShow, 'profile.json'); showProfileData = spx.GetJsonData(profileFile); if(!showProfileData.templates) { // if no templates assigned to this project html += '' + spx.lang('warning.notemplates') + ''; return html } showProfileData.templates.forEach((template,i) => { html += '
\n'; html += '\n'; html += '' + spx.prettifyName(template.relpath) + '
\n'; html += '' + template.description + ' \n'; html += '
\n'; }); return html }, // populate templatelist for the controller add template view generateTemplateList(currentShow) { let html=""; // get show profile // iterate all templates let profileFile = path.join(config.general.dataroot, currentShow, 'profile.json'); showProfileData = spx.GetJsonData(profileFile); if(!showProfileData.templates) { // if no templates assigned to this project html += '' + spx.lang('warning.notemplates') + '

'; return html } showProfileData.templates.forEach((template,i) => { html += '
\n'; html += '\n'; html += '' + spx.prettifyName(template.relpath) + '
\n'; html += '' + template.description + ' \n'; html += '
\n'; }); return html }, // convert timestamp (epoch) to locale date string "31.12.2021 17:45" generateTimestamp(epoch) { let date = new Date(parseInt(epoch)); let fDate = date.toLocaleDateString(); let fTime = date.toLocaleTimeString(); return fDate + ' ' + fTime; }, // populate WebPlayout options for show config templates generateButtonColorClasses(currentValue) { let html=""; let AvailableOptions=[ { text:spx.lang('color.red'), value:'bg_red' }, { text:spx.lang('color.green'), value:'bg_green' }, { text:spx.lang('color.grey'), value:'bg_grey' }, { text:spx.lang('color.orange'), value:'bg_orange' }, { text:spx.lang('color.blue'), value:'bg_blue' }, { text:spx.lang('color.black'), value:'bg_black' }, { text:spx.lang('color.light'), value:'bg_grey15' } ]; AvailableOptions.forEach((OptionItem,i) => { let sel = ""; if (OptionItem.value==currentValue){ sel="selected" } html += ''; }); return html }, // return username CurrentUserName() { return global.user; }, // populate out options for show config templates generateOutOptions(currentOut) { logger.debug('Generating out options. This selection: ' + currentOut + '.'); let html=""; let AvailableOptions=[ {value:'manual', text:'Manually'}, {value:'none', text:'None (just play)'}, {value:'1000', text:'In 1 second'}, {value:'2000', text:'In 2 seconds'}, {value:'4000', text:'In 4 seconds'}, {value:'6000', text:'In 6 seconds'}, {value:'10000', text:'In 10 seconds'} ]; AvailableOptions.forEach((outOption,i) => { let sel = ""; let CurVal = currentOut.toLowerCase(); let CurOpt = outOption.value.toLowerCase(); if (CurVal==CurOpt){ sel="selected" } html += ''; }); return html }, // populate WebPlayout options for show config templates generateWebPlayoutOptions(currentLayer='-') { // console.log('generateWebPlayoutOptions got currentLayer: ' + currentLayer); currentLayer = spx.max5(currentLayer); logger.debug('Generating webplayout options. This selection: '+ currentLayer); let html=""; let AvailableLayers=['-',1,2,3,4,5]; AvailableLayers.forEach((layerOption,i) => { let curSrv = layerOption || '-'; let cfgSrv = currentLayer || '-'; let sel = ""; if (curSrv==cfgSrv){ sel="selected" } html += ''; }); return html }, // preview span for collapsed view of each rundown item generateCollapsedHeadline(DataFields) { return spx.generateCollapsedHeadline(DataFields); }, // generate radio buttons for show config templates generateColorAccents(selectedIndex, templateIndex) { let colors=['gra','red','ora','gre','blu','pin','vio','bla']; let html =""; let sel = ""; colors.forEach((color,loopindex) => { if (selectedIndex==loopindex) { sel = "checked" } else {sel=""}; html += ''; }); return html }, // Returns IP for socket server of this app's IP address getServerAddress: function () { var ip = require('ip'); var myIP = ip.address() + ':' + port; return myIP; }, // Returns resolution options for App Config > resolution // Added in 1.3.0 generateResolutionOptions: function (resolutionOption) { let html = ''; let options = [ { 'value': 'HD', 'text': 'HD (1080p)' }, { 'value': '4K', 'text': '4K (2160p)' }, { 'value': 'AUTO', 'text': 'AUTO (responsive)' }, ]; let selectedFound = false; options.forEach((opt,i) => { let sele = ''; if (opt.value.toUpperCase() == resolutionOption.toUpperCase()) { selectedFound = true; sele = 'selected' }; html += '\n'; }); return html; }, // See if cssSizeValue has 'vh' or 'vw' in it then return HD values // Added in 1.3.0 localPreviewSize: function (cssSizeValue) { if (cssSizeValue.includes('vw')) { return '1920'; } else if (cssSizeValue.includes('vh')) { return '1080'; } else { return cssSizeValue; } }, // Returns unique ID for this host machine getHostID: function () { return global.pmac; }, hookLoadControllerPlugins: function () { // Loads init.js files from all sub folders of ASSETS/plugins// let html = ''; try { let BrowseFolder = path.join(spx.getStartUpFolder(), 'ASSETS', 'plugins'); const list = spx.GetFilesAndFolders(BrowseFolder); list.foldArr.forEach(pluginName => { if ( pluginName.charAt(0) == '_' || pluginName.charAt(0) == '.' || pluginName === 'lib' ) { html += '\n' } else { logger.verbose('Loading plugin ' + pluginName ); html += '\n' } }) } catch (error) { html = ''; } return html }, // Convert JSON \\n to newline for textarea injectNewlines(jsonString) { let multiline = jsonString.replace('\\n', '\r\n') return multiline; }, // Language string helper lang(str) { return spx.lang(str); }, // Replace underscores with spaces (used by breadcrumbs) prettyfyBreadcrumb(str) { let fixed = str fixed = fixed.replace(/_/g, ' '); fixed = fixed.replace(/-/g, ' '); return fixed; }, // Return object as JSON string myStringify(obj) { return "// Stringified JSON\n\n" + JSON.stringify(obj, null, 4); }, // Get template name from filepath nameFromTemplatePath(filepath) { return spx.prettifyName(filepath); }, // Check config and return value allowOpeningFolder() { let disableButton = config.general.disableOpenFolderCommand || false; // console.log('allowOpeningFolder: ' + disableButton); if (disableButton==true || disableButton=="true") { return 'disabled'; } else { return ''; } }, // Make long strings nicer to look at shortifyUIstrings(str) { return spx.shortifyName(str); }, // generate checkbox to appconfig OpenChromeCheck() { let launch = config.general.launchBrowser || false; if (launch){ return ''; } else { return ''; } }, // Get a correct class for a play button playButtonClass(onair) { let value = onair || "false"; let ONAIR = String(value).toUpperCase(); if (ONAIR=="TRUE") { return "bg_red"; } else { return "bg_green"; } }, // Get a correct class for a play button playButtonText(onair) { let value = onair || "false"; let ONAIR = String(value).toUpperCase(); if (ONAIR=="TRUE") { return spx.lang('button.stop'); } else { return spx.lang('button.play'); } }, // Define a class for the playicon playIconClass(onair) { // console.log('Play icon [' + onair + ']'); let value = onair || "false"; let ONAIR = String(value).toUpperCase(); if (ONAIR=="TRUE") { return "playTrue"; } else { return "playFalse"; } }, // Show username / password in the login view ShowDemoLoginInfo() { let showuserpass = config.general.showusercommapass || ''; if (showuserpass==""){ return ""; } let u = showuserpass.split(',')[0] let p = showuserpass.split(',')[1] let html=""; html += ''; html += ''; html += ''; html += ''; html += 'Login to this instance with
'; html += 'user ' + u + ', '; html += 'pass ' + p + ''; html += '
'; html += ''; html += ''; return html; }, // Print out version number ShowVersion() { return vers; }, // Return available lang files as options with selected one DropdownOptionsLANG() { let html = ''; let sel = ''; let localesFolder = path.join(spx.getStartUpFolder(), 'locales'); let fileListData = spx.getJSONFileList(localesFolder); fileListData.files.forEach((element,i) => { sel = ''; if (element.name == config.general.langfile) { sel = 'selected'; } html += ''; }); return html }, // This is used to populate filelist dropdown control type in templates. // Note: these files are returned as http-assets from SPX server // Feature added in 1.0.3. and improved in 1.0.6, 1.0.9 // v1.0.15 adds relative assets (within template root folder). PopulateFilelistOptions(assetfolder, extension, value, relpath='') { let html = ''; let sel = ''; let fullFilePath = ''; let fullPath = ''; if ( assetfolder.startsWith('./') ) { // relative path support added in 1.0.15 let templateFile = path.join(spx.getStartUpFolder(), 'ASSETS', 'templates', relpath); let templateRootFolder = path.dirname(templateFile) fullPath = path.join(templateRootFolder, assetfolder) } else { // absolute path in ASSETS let assetPath = path.normalize(assetfolder || ''); fullPath = path.join(spx.getStartUpFolder(), 'ASSETS', assetPath); // fixed (again in 1.0.12) } let selectedValue = value; let fileList = spx.getFileList(fullPath, extension); // console.log('files', fileList); if (fileList) { fileList.forEach((fileRef,index) => { if (assetfolder.substr(assetfolder.length - 1)!="/") { // fixed a bug in 1.1.1 assetfolder = assetfolder + "/"; } fullFilePath = assetfolder + fileRef; sel = ""; if (fullFilePath == selectedValue) { sel = 'selected'; } html += ''; }); } return html }, // Servers status check request ServerStatus() { let html = ""; if (config.casparcg) { config.casparcg.servers.forEach((element,i) => { let disabledClass = '' if (element.disabled) { disabledClass = 'disabledServer'; } html += ''; }); } return html; // end serverstatus }, // See if there is custom content packages in the ASSETS folder DetectCustomContentPackages() { try { const fileref = path.join(spx.getStartUpFolder(), 'ASSETS', 'spx-content-repo.txt'); if (fs.existsSync(fileref)) { var contents = fs.readFileSync(fileref, 'utf8') || ''; if (contents == 'CONTENT_FULL') { logger.debug('DetectCustomContentPackages: CONTENT_FULL'); return ''; } else { logger.debug('DetectCustomContentPackages: returns ' + contents); return '
' + contents + '
'; } } else { // No file found, return empty string return ''; } } catch (error) { logger.error('DetectCustomContentPackages Error: ' + error); return ""; } // end DetectCustomContentPackages }, GenerateLocalRendererOptions(mode) { return '\n'; } // end LocalRendererOptions } // end helpers })); // end app.engine app.set('view engine', 'handlebars'); app.set('views', path.join(__dirname, 'views')); // Middleware app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(cors()); app.use(session({ secret: 'salainenSalaisuus#007', resave: false, saveUninitialized: false })); global.user = ""; // Router files const ROUTEfiles = require('./routes/routes-api.js'); app.use('/api/', ROUTEfiles); const ROUTEpubAPIv1 = require('./routes/routes-api-v1.js'); app.use('/api/v1/', ROUTEpubAPIv1); const ROUTEapp = require('./routes/routes-application.js'); app.use('/', ROUTEapp); const ROUTEccg = require('./routes/routes-casparcg.js'); app.use('/CCG', ROUTEccg); process.on('uncaughtException', function(err) { logger.error('Uncaught Exception: ' + err); if(err.errno === 'EADDRINUSE') { console.log('Process failed! Port is in use? [' + err + ']'); // most likely app running on this port already setTimeout(function(){ process.exit(3); }, 2000); } else if (err.errno === 'ENOENT') { console.log('File not found error detected! [' + err + ']'); } else { console.log('\n\nThis error in unhandled, consider SPX restart! ', err); // Added in 1.1.1 } }); var server = app.listen(port, (err) => { let splash = ' Copyright 2020- SPX Graphics\n\n' + ` SPX Solo version .......... ${global.vers}\n` + ' License ................... See LICENSE.txt\n' + ` Config file ............... ${configfileref}\n` + ` Cfg / locale .............. ${config.general.langfile}\n` + ` Cfg / host-id (name) ...... ${global.pmac} (${config.general.hostname})\n` + ` Cfg / loglevel ............ ${config.general.loglevel} (options: error | warn | info | verbose | debug )\n` + ` Cfg / dataroot ............ ${path.resolve(config.general.dataroot)}\n` + ` Cfg / logfolder ........... ${logDirectory}\n`; /* ` Cfg / lauchchrome ...... ${config.general.launchchrome}\n` */ if (config.general.apikey && config.general.apikey != '') { splash += ` Cfg / apikey ........... Set in config.json\n`; } if (config.osc?.enable) { splash += ` OSC enabled ............ port ${OSCport}\n`; } let TemplatesFromInfo; let tmplSourceAddress = spx.getTemplateSourcePath(); if ( tmplSourceAddress == "") { TemplatesFromInfo = "See caspar.config" } else { TemplatesFromInfo = tmplSourceAddress } splash += `\n Explore WebCG related resources:` + `\n` + `\n This is WebCG Solo. For professional features and support\n options see WebCG Production and WebCG Broadcast.`; console.log(splash); let prompt = 'Open WebCG Solo in a browser:'; let spxUrl = `http://${ipad}:${port}`; let spxLoc = `http://localhost:${port}`; let urLeng = spxUrl.length; let loLeng = spxLoc.length; let prLeng = prompt.length; let maxWid = Math.max(urLeng, loLeng, prLeng) let minWid = Math.min(urLeng, loLeng, prLeng) let line1s = '╭' + "─".repeat(maxWid+4) + "╮" let line2s = prompt + " ".repeat(maxWid-prLeng) let line3s = '╰' + "─".repeat(maxWid+4) + "╯" let spaCnt, spacer1, spacer2 spacer1 = " ".repeat(maxWid-urLeng) spacer2 = " ".repeat(maxWid-loLeng) console.log('\n ' + line1s); console.log(' │ ' + line2s + ' │'); // console.log(' │ ' + spxUrl + spacer1 + ' │'); console.log(' │ ' + spxLoc + spacer2 + ' │'); console.log(' ' + line3s); console.log(''); if ( config.general.launchBrowser || global.generatingDefaultConfig ) { try { (async () => { require("openurl").open(`http://${ipad}:${port}/`) })(); } catch (error) { console.warn('Could not open browser.') } } }); global.io = require('socket.io')(server); var clients = {} io.sockets.on('connection', function (socket) { logger.verbose('*** Socket connection (' + socket.id + ") Connections: " + io.engine.clientsCount); clients[socket.id] = socket; notifyMultipleControllers(); // on Connection socket.on('disconnect', async function () { let SPXClientName = clients[socket.id].SPXClientName || '** no name **'; logger.verbose('*** Socket disconnected (' + socket.id + ") Connections: " + io.engine.clientsCount); delete clients[socket.id]; // Notify controller of other clients lost (such as renderers closed with X) data = {}; data.spxcmd = 'clientLostNotification' data.source = 'windowClose' data.clientName = SPXClientName io.emit('SPXMessage2Client', data); notifyMultipleControllers(); // on Disconnect }); // end disconnect socket.on('SPXWebRendererMessage', function (data) { io.emit('SPXMessage2Client', data); }); socket.on('SPXMessage2Server', function (data) { logger.verbose('SPXMessage2Server received', data) switch (data.spxcmd) { case 'identifyClient': socket.SPXClientName = data.name; logger.verbose('Identified [' + socket.id + '] as [' + data.name + ']'); break; case 'saveToLog': // Added in 1.3.0 let level = data.level || 'info'; let ref = data.fileref || 'unknown'; logger[level]('TemplateLog [' + ref + '] --> ' + data.message); break; case 'command-name-here': // action here break; default: logger.warn('Unknown SPXMessage2Server command: ' + data.command); } }); // end message to server }); // end socket-io function notifyMultipleControllers() { if ( config.general?.disableSeveralControllersWarning==true ) { logger.debug('notifyMultipleControllers feature is disabled with a config flag.'); return; } setTimeout(function(){ let count = 0; for (const [key, value] of Object.entries(clients)) { if (value.SPXClientName == 'SPX_CONTROLLER') { count++; } } logger.debug('notifyMultipleControllers: ' + count + ' controllers connected.'); let data = {}; data.spxcmd = 'notifyMultipleControllers' data.count = count io.emit('SPXMessage2Client', data); }, 100); // small delay }