something

This commit is contained in:
2026-04-07 10:07:24 +02:00
parent 887cf6331e
commit 94e108205d
9 changed files with 164 additions and 331 deletions
+2 -4
View File
@@ -209,8 +209,7 @@ function handleData(wss, receivedData, rdsWss) {
}
// Get the received TX info
fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps)
.then((currentTx) => {
fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps).then((currentTx) => {
if (currentTx && currentTx.station !== undefined && parseInt(currentTx.distance) < 4000) {
dataToSend.txInfo = {
tx: currentTx.station,
@@ -227,8 +226,7 @@ function handleData(wss, receivedData, rdsWss) {
score: currentTx.score,
};
}
})
.catch((error) => console.log("Error fetching Tx info:", error));
}).catch((error) => console.log("Error fetching Tx info:", error));
// Send the updated data to the client
const dataToSendJSON = JSON.stringify(dataToSend);
+2 -4
View File
@@ -42,9 +42,7 @@ function connectToSerial() {
serialport.open((err) => {
if (err) {
logError('Error opening port: ' + err.message);
setTimeout(() => {
connectToSerial();
}, 5000);
setTimeout(connectToSerial, 5000);
return;
}
@@ -78,7 +76,7 @@ function connectToSerial() {
serverConfig.audio.startupVolume
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
: serialport.write('Y100\n');
}, 6000);
}, 2000);
serialport.on('data', helpers.resolveDataBuffer);
serialport.on('error', (error) => logError(error.message));
+13 -48
View File
@@ -36,8 +36,7 @@ router.get('/', (req, res) => {
if (configExists() === false) {
let serialPorts;
SerialPort.list()
.then((deviceList) => {
SerialPort.list().then((deviceList) => {
serialPorts = deviceList.map(port => ({
path: port.path,
friendlyName: port.friendlyName,
@@ -49,7 +48,6 @@ router.get('/', (req, res) => {
videoDevices: result.audioDevices,
audioDevices: result.videoDevices,
serialPorts: serialPorts,
serialPorts: serialPorts,
tunerProfiles: tunerProfiles.map((profile) => ({
id: profile.id,
label: profile.label,
@@ -90,9 +88,7 @@ router.get('/403', (req, res) => {
res.render('403', { reason });
})
router.get('/audioonly', (req, res) => {
res.render('audioonly');
})
router.get('/audioonly', (req, res) => res.render('audioonly'))
router.get('/wizard', (req, res) => {
let serialPorts;
@@ -102,8 +98,7 @@ router.get('/wizard', (req, res) => {
return;
}
SerialPort.list()
.then((deviceList) => {
SerialPort.list().then((deviceList) => {
serialPorts = deviceList.map(port => ({
path: port.path,
friendlyName: port.friendlyName,
@@ -176,15 +171,6 @@ router.get('/setup', (req, res) => {
})
});
router.get('/rds', (req, res) => {
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
});
router.get('/rdsspy', (req, res) => {
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
});
router.get('/api', (req, res) => {
const { ps_errors, rt0_errors, rt1_errors, ims, eq, ant, st_forced, previousFreq, txInfo, rdsMode, ...dataToSend } = dataHandler.dataToSend;
res.json({
@@ -196,9 +182,8 @@ router.get('/api', (req, res) => {
});
});
const loginAttempts = {}; // Format: { 'ip': { count: 1, lastAttempt: 1234567890 } }
const MAX_ATTEMPTS = 10;
const MAX_ATTEMPTS = 8;
const WINDOW_MS = 15 * 60 * 1000;
const authenticate = (req, res, next) => {
@@ -242,9 +227,7 @@ router.post('/login', authenticate, (req, res) => {
router.get('/logout', (req, res) => {
// Clear the session and redirect to the main page
req.session.destroy(() => {
res.status(200).json({ message: 'Logged out successfully, refreshing the page...' });
});
req.session.destroy(() => res.status(200).json({ message: 'Logged out successfully, refreshing the page...' }));
});
router.get('/kick', (req, res) => {
@@ -254,9 +237,7 @@ router.get('/kick', (req, res) => {
res.status(403);
return;
}
setTimeout(() => {
res.redirect('/setup');
}, 500);
setTimeout(res.redirect, 500, "/setup");
});
router.get('/addToBanlist', (req, res) => {
@@ -300,7 +281,6 @@ router.get('/removeFromBanlist', (req, res) => {
res.json({ success: true, message: 'IP address removed from banlist.' });
});
router.post('/saveData', (req, res) => {
const data = req.body;
let firstSetup;
@@ -331,11 +311,8 @@ router.get('/getData', (req, res) => {
});
router.get('/getDevices', (req, res) => {
if (req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) {
parseAudioDevice((result) => {
res.json(result);
});
} else res.status(403).json({ error: 'Unauthorized' });
if (req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) parseAudioDevice(res.json);
else res.status(403).json({ error: 'Unauthorized' });
});
/* Static data are being sent through here on connection - these don't change when the server is running */
@@ -360,22 +337,18 @@ router.get('/server_time', (req, res) => {
res.json({ serverTime: serverTimeUTC });
});
router.get('/ping', (req, res) => {
res.send('pong');
});
router.get('/ping', (req, res) => res.send('pong'));
const logHistory = {};
// Function to check if the ID has been logged within the last 60 minutes
function canLog(id) {
const now = Date.now();
const sixtyMinutes = 60 * 60 * 1000; // 60 minutes in milliseconds
const sixtyMinutes = 60 * 60 * 1000;
// Remove expired entries
for (const [entryId, timestamp] of Object.entries(logHistory)) {
if ((now - timestamp) >= sixtyMinutes) {
delete logHistory[entryId];
}
if ((now - timestamp) >= sixtyMinutes) delete logHistory[entryId];
}
if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) return false; // Deny logging if less than 60 minutes have passed
@@ -440,16 +413,8 @@ router.get('/log_fmlist', (req, res) => {
const request = https.request(options, (response) => {
let data = '';
// Collect response chunks
response.on('data', (chunk) => {
data += chunk;
});
// Response ended
response.on('end', () => {
res.status(200).send(data);
});
response.on('data', (chunk) => data += chunk);
response.on('end', () => res.status(200).send(data));
});
// Handle errors in the request
+2 -4
View File
@@ -57,8 +57,7 @@ function authenticateWithXdrd(client, salt, password) {
sha1.update(saltBuffer);
sha1.update(passwordBuffer);
const hashedPassword = sha1.digest('hex');
client.write(hashedPassword + '\n');
client.write(sha1.digest('hex') + '\n');
client.write('x\n');
}
@@ -120,8 +119,7 @@ function handleConnect(clientIp, currentUsers, ws, callback) {
}
if (ipInfoInFlight.has(clientIp)) {
ipInfoInFlight
.get(clientIp)
ipInfoInFlight.get(clientIp)
.then((info) => processConnection(clientIp, info, currentUsers, ws, callback))
.catch(() => processConnection(clientIp, { country: undefined }, currentUsers, ws, callback));
return;
+3 -3
View File
@@ -11,15 +11,15 @@ tunnel.download();
require("./device");
require('./stream/index');
const startServer = require("./web");
{
const helpers = require('./helpers');
const plugins = helpers.findServerFiles(serverConfig.plugins);
if (plugins.length > 0) setTimeout(helpers.startPluginsWithDelay, 3000, plugins, 3000);
}
require('./stream/index');
const startServer = require("./web");
startServer(serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp);
tunnel.connect();
+118 -241
View File
@@ -222,271 +222,148 @@ var countries = [
]
var iso = [
"AL",
"EE",
"DZ",
"ET",
"AD",
"AO",
"FI",
"AM",
"FR",
"SH",
"GA",
"AT",
"GM",
"AZ",
"GE",
"DE",
"BH",
"GH",
"BY",
"GI",
"BE",
"GR",
"BJ",
"GN",
"BA",
"GW",
"BW",
"HU",
"BG",
"IS",
"BF",
"IQ",
"BI",
"IE",
"--",
"IL",
"CM",
"IT",
"JO",
"CV",
"KZ",
"CF",
"KE",
"TD",
"XK",
"KM",
"KW",
"CD",
"KG",
"CG",
"LV",
"CI",
"LB",
"HR",
"LS",
"CY",
"LR",
"CZ",
"LY",
"DK",
"LI",
"DJ",
"LT",
"EG",
"LU",
"GQ",
"MK",
"ER",
"MG",
"SC",
"MW",
"SL",
"ML",
"SK",
"MT",
"SI",
"MR",
"SO",
"MU",
"ZA",
"MD",
"SS",
"MC",
"ES",
"MN",
"SD",
"ME",
"SZ",
"MA",
"SE",
"MZ",
"CH",
"NA",
"SY",
"NL",
"TJ",
"NE",
"TZ",
"NG",
"TG",
"NO",
"TN",
"OM",
"TR",
"PS",
"TM",
"PL",
"UG",
"PT",
"UA",
"QA",
"AE",
"RO",
"GB",
"RU",
"UZ",
"RW",
"VA",
"SM",
"EH",
"ST",
"YE",
"SA",
"ZM",
"SN",
"ZW",
"RS",
"AI",
"GY",
"AG",
"HT",
"AR",
"HN",
"AW",
"JM",
"BS",
"MQ",
"BB",
"MX",
"BZ",
"MS",
"--",
"--",
"BO",
"NI",
"BR",
"PA",
"CA",
"PY",
"KY",
"PE",
"CL",
"--",
"CO",
"KN",
"CR",
"LC",
"CU",
"PM",
"DM",
"VC",
"DO",
"SR",
"SN",
"TT",
"TB",
"FK",
"GL",
"UY",
"GD",
"VE",
"GP",
"VG",
"GT",
"AF",
"KR",
"LA",
"AU",
"MO",
"AU",
"MY",
"AU",
"MV",
"AU",
"MH",
"AU",
"FM",
"AU",
"MM",
"AU",
"NR",
"AU",
"NP",
"BD",
"NZ",
"BT",
"PK",
"BN",
"PG",
"KH",
"PH",
"CN",
"WS",
"SG",
"SB",
"FJ",
"LK",
"HK",
"TW",
"IN",
"TH",
"ID",
"TO",
"IR",
"VU",
"JP",
"VN",
"KI",
"KP",
"--"
"AL", "EE",
"DZ", "ET",
"AD", "AO",
"FI", "AM",
"FR", "SH",
"GA", "AT",
"GM", "AZ",
"GE", "DE",
"BH", "GH",
"BY", "GI",
"BE", "GR",
"BJ", "GN",
"BA", "GW",
"BW", "HU",
"BG", "IS",
"BF", "IQ",
"BI", "IE",
"--", "IL",
"CM", "IT",
"JO", "CV",
"KZ", "CF",
"KE", "TD",
"XK", "KM",
"KW", "CD",
"KG", "CG",
"LV", "CI",
"LB", "HR",
"LS", "CY",
"LR", "CZ",
"LY", "DK",
"LI", "DJ",
"LT", "EG",
"LU", "GQ",
"MK", "ER",
"MG", "SC",
"MW", "SL",
"ML", "SK",
"MT", "SI",
"MR", "SO",
"MU", "ZA",
"MD", "SS",
"MC", "ES",
"MN", "SD",
"ME", "SZ",
"MA", "SE",
"MZ", "CH",
"NA", "SY",
"NL", "TJ",
"NE", "TZ",
"NG", "TG",
"NO", "TN",
"OM", "TR",
"PS", "TM",
"PL", "UG",
"PT", "UA",
"QA", "AE",
"RO", "GB",
"RU", "UZ",
"RW", "VA",
"SM", "EH",
"ST", "YE",
"SA", "ZM",
"SN", "ZW",
"RS", "AI",
"GY", "AG",
"HT", "AR",
"HN", "AW",
"JM", "BS",
"MQ", "BB",
"MX", "BZ",
"MS", "--",
"--", "BO",
"NI", "BR",
"PA", "CA",
"PY", "KY",
"PE", "CL",
"--", "CO",
"KN", "CR",
"LC", "CU",
"PM", "DM",
"VC", "DO",
"SR", "SN",
"TT", "TB",
"FK", "GL",
"UY", "GD",
"VE", "GP",
"VG", "GT",
"AF", "KR",
"LA", "AU",
"MO", "AU",
"MY", "AU",
"MV", "AU",
"MH", "AU",
"FM", "AU",
"MM", "AU",
"NR", "AU",
"NP", "BD",
"NZ", "BT",
"PK", "BN",
"PG", "KH",
"PH", "CN",
"WS", "SG",
"SB", "FJ",
"LK", "HK",
"TW", "IN",
"TH", "ID",
"TO", "IR",
"VU", "JP",
"VN", "KI",
"KP", "--"
]
const rdsEccA0A6Lut = [
// A0
[
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
[ "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", "", "USA/VI/PR", "USA/VI/PR", ""
],
// A1
[
"", "", "", "", "",
"", "", "", "", "",
[ "", "", "", "", "", "", "", "", "", "",
"Canada", "Canada", "Canada", "Canada", "Greenland"
],
// A2
[
"Anguilla", "Antigua and Barbuda", "Brazil/Equador", "Falkland Islands", "Barbados",
[ "Anguilla", "Antigua and Barbuda", "Brazil/Equador", "Falkland Islands", "Barbados",
"Belize", "Cayman Islands", "Costa Rica", "Cuba", "Argentina",
"Brazil", "Brazil/Bermuda", "Brazil/AN", "Guadeloupe", "Bahamas"
],
// A3
[
"Bolivia", "Colombia", "Jamaica", "Martinique", "",
[ "Bolivia", "Colombia", "Jamaica", "Martinique", "",
"Paraguay", "Nicaragua", "", "Panama", "Dominica",
"Dominican Republic", "Chile", "Grenada", "Turks and Caicos islands", "Guyana"
],
// A4
[
"Guatemala", "Honduras", "Aruba", "", "Montserrat",
[ "Guatemala", "Honduras", "Aruba", "", "Montserrat",
"Trinidad and Tobago", "Peru", "Suriname", "Uruguay", "St. Kitts",
"St. Lucia", "El Salvador", "Haiti", "Venezuela", "Virgin Islands"
],
// A5
[
"", "", "", "", "",
"", "", "", "", "",
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"
],
[ "", "", "", "", "", "", "", "", "", "",
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"],
// A6
[
"", "", "", "", "",
"", "", "", "", "",
"", "", "", "", "St. Pierre and Miquelon"
]
["", "", "", "", "", "", "", "", "", "", "", "", "", "", "St. Pierre and Miquelon"]
];
const rdsEccD0D4Lut = [
+22 -25
View File
@@ -61,15 +61,12 @@ let currentUsers = 0;
let timeoutAntenna;
wss.on('connection', (ws, request) => {
const output = storage.ctl_output;
let clientIp = helpers.getIpAddress(request);
const userCommandHistory = {};
// Per-IP limit connection open
if (clientIp) {
const isLocalIp = (
clientIp === '127.0.0.1' || clientIp === '::1' ||
clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.'));
const isLocalIp = (clientIp === '127.0.0.1' || clientIp === '::1' || clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.'));
if (!isLocalIp) {
if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0);
const currentCount = ipConnectionCounts.get(clientIp);
@@ -117,7 +114,7 @@ wss.on('connection', (ws, request) => {
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024);
if (!clientIp.includes("127.0.0.1")) {
if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) ||
if (!request.session.isAdminAuthenticated && (command.startsWith('X') || command.startsWith('Y') || command.startsWith("D") || command.startsWith("")) ||
((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) {
logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`);
return;
@@ -160,7 +157,7 @@ wss.on('connection', (ws, request) => {
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return;
}
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) output.write(`${command}\n`);
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) storage.ctl_output.write(`${command}\n`);
});
ws.on('close', (code) => {
@@ -191,35 +188,34 @@ wss.on('connection', (ws, request) => {
if (currentUsers === 0) {
storage.connectedUsers = [];
if (serverConfig.bwAutoNoUsers === "1") output.write("W0\n"); // Auto BW 'Enabled'
if (serverConfig.bwAutoNoUsers === "1") storage.ctl_output.write("W0\n"); // Auto BW 'Enabled'
// cEQ and iMS combinations
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") output.write("G00\n"); // Both Disabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") output.write(`G0${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") output.write(`G${dataHandler.dataToSend.eq}0\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") output.write(`G1${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") output.write(`G${dataHandler.dataToSend.eq}1\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") output.write("G10\n"); // Only cEQ enabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") output.write("G01\n"); // Only iMS enabled
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") output.write("G11\n"); // Both Enabled
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") storage.ctl_output.write("G00\n"); // Both Disabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") storage.ctl_output.write(`G0${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") storage.ctl_output.write(`G${dataHandler.dataToSend.eq}0\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") storage.ctl_output.write(`G1${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") storage.ctl_output.write(`G${dataHandler.dataToSend.eq}1\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") storage.ctl_output.write("G10\n"); // Only cEQ enabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") storage.ctl_output.write("G01\n"); // Only iMS enabled
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") storage.ctl_output.write("G11\n"); // Both Enabled
// Handle stereo mode
if (serverConfig.stereoNoUsers === "1") output.write("B0\n");
else if (serverConfig.stereoNoUsers === "2") output.write("B1\n");
if (serverConfig.stereoNoUsers === "1") storage.ctl_output.write("B0\n");
else if (serverConfig.stereoNoUsers === "2") storage.ctl_output.write("B1\n");
// Handle Antenna selection
if (timeoutAntenna) clearTimeout(timeoutAntenna);
timeoutAntenna = setTimeout(() => {
if (serverConfig.antennaNoUsers === "1") output.write("Z0\n");
else if (serverConfig.antennaNoUsers === "2") output.write("Z1\n");
else if (serverConfig.antennaNoUsers === "3") output.write("Z2\n");
else if (serverConfig.antennaNoUsers === "4") output.write("Z3\n");
if (serverConfig.antennaNoUsers === "1") storage.ctl_output.write("Z0\n");
else if (serverConfig.antennaNoUsers === "2") storage.ctl_output.write("Z1\n");
else if (serverConfig.antennaNoUsers === "3") storage.ctl_output.write("Z2\n");
else if (serverConfig.antennaNoUsers === "4") storage.ctl_output.write("Z3\n");
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
}
if (tunerLockTracker.has(ws)) {
logInfo(`User who locked the tuner left. Unlocking the tuner.`);
output.write('wT0\n') // Why are we telling the tuner that?
tunerLockTracker.delete(ws);
serverConfig.publicTuner = true;
}
@@ -228,7 +224,7 @@ wss.on('connection', (ws, request) => {
serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
setTimeout(function() {
if (currentUsers === 0) {
output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
storage.ctl_output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
dataHandler.resetToDefault(dataHandler.dataToSend);
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
@@ -254,10 +250,11 @@ pluginsWss.on('connection', (ws, request) => {
ws.on('message', message => {
// Anti-spam
helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins');
const msg2 = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins');
if(!msg2) return;
try {
let messageData = JSON.parse(message); // Attempt to parse the JSON. This code will attemp to parse json using the JSON.parse which coincidentally actually parses the json
let messageData = JSON.parse(msg2); // Attempt to parse the JSON. This code will attemp to parse json using the JSON.parse which coincidentally actually parses the json
if (messageData.type === "GPS" && messageData.value) {
const gpsData = messageData.value;
+1 -1
View File
@@ -21,6 +21,6 @@
</head>
<body>
<button class="playbutton">Start / Stop</button>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="1">
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="1" style="width: 0.5vw;">
</body>
</html>
+1 -1
View File
@@ -18,7 +18,7 @@
<meta property="og:title" content="FM-DX WebServer [<%= tunerName %>]">
<meta property="og:type" content="website">
<meta property="og:image" content="https://fmdx.org/img/webserver_icon.png">
<meta property="og:image" content="https://flerken.zapto.org:5000/img/webserver_icon.png">
<meta property="og:description" content="Server description: <%= tunerDescMeta %>.">
<script src="js/3las/util/3las.helpers.js"></script>