1
0
mirror of https://github.com/KubaPro010/fm-dx-webserver.git synced 2026-02-26 22:13:53 +01:00

ejs + webadmin + bugfixes

This commit is contained in:
NoobishSVK
2024-02-04 16:56:35 +01:00
parent d6b128c0bd
commit c848bef002
27 changed files with 1329 additions and 161 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules/
/example.js
/userconfig.json
/userconfig_backup.js
/config.json

View File

@@ -45,32 +45,11 @@ Version >=21.6.0 is currently not working correctly.
npm install
```
4. Update your config in userconfig.js:
```js
const webServerHost = '0.0.0.0'; // IP of the web server
const webServerPort = 8080; // web server port
const webServerName = "Noobish's Server"; // web server name (will be displayed in title, bookmarks...)
const audioDeviceName = "Microphone (High Definition Audio Device)"; // Audio device name in your OS
const audioPort = 8081; // Port for the audio stream
const xdrdServerHost = '127.0.0.1'; // xdrd server IP (if it's running on the same machine, use 127.0.0.1)
const xdrdServerPort = 7373; // xdrd server port
const xdrdPassword = 'password'; // xdrd password (optional)
const qthLatitude = '50.123456'; // your latitude, useful for maps.fmdx.pl integration
const qthLongitude = '15.123456'; // your longitude, useful for maps.fmdx.pl integration
const verboseMode = false; // if true, console will display extra messages
```
4. Start the server:
```bash
node .
npm run webserver
```
4. Open your web browser and navigate to `http://web-server-ip:web-server-port` to access the web interface.

View File

@@ -1,4 +1,4 @@
const { verboseMode } = require('./userconfig');
const verboseMode = process.argv.includes('--debug');
const getCurrentTime = () => {
const currentTime = new Date();

View File

@@ -7,7 +7,6 @@ const os = require('os');
const win32 = (os.platform() == "win32");
const unicode_type = (win32 ? 'int16_t' : 'int32_t');
const lib = koffi.load(path.join(__dirname, "librdsparser." + (win32 ? "dll" : "so")));
const config = require('./userconfig');
koffi.proto('void callback_pi(void *rds, void *user_data)');
koffi.proto('void callback_pty(void *rds, void *user_data)');

349
index.js
View File

@@ -1,27 +1,77 @@
/* Libraries / Imports */
/**
* LIBRARIES AND IMPORTS
*/
// Web handling
const express = require('express');
const app = express();
const session = require('express-session');
const bodyParser = require('body-parser');
const http = require('http');
const https = require('https');
const app = express();
const httpServer = http.createServer(app);
const ejs = require('ejs');
// Websocket handling
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true });
const path = require('path');
const net = require('net');
const client = new net.Socket();
// Other files and libraries
const crypto = require('crypto');
const fs = require('fs');
const commandExists = require('command-exists-promise');
const dataHandler = require('./datahandler');
const consoleCmd = require('./console');
const config = require('./userconfig');
const audioStream = require('./stream/index.js');
const { parseAudioDevice } = require('./stream/parser.js');
const configPath = path.join(__dirname, 'config.json');
const { webServerHost, webServerPort, webServerName, audioPort, xdrdServerHost, xdrdServerPort, xdrdPassword, qthLatitude, qthLongitude } = config;
const { logDebug, logError, logInfo, logWarn } = consoleCmd;
let currentUsers = 0;
let streamEnabled = false;
let incompleteDataBuffer = '';
let serverConfig = {
webserver: {
webserverIp: "0.0.0.0",
webserverPort: "8080",
audioPort: "8081"
},
xdrd: {
xdrdIp: "127.0.0.1",
xdrdPort: "7373",
xdrdPassword: "password"
},
identification: {
tunerName: "",
tunerDesc: "",
lat: "",
lon: ""
},
password: {
tunePass: "",
adminPass: ""
},
publicTuner: true,
lockToAdmin: false
};
if(fs.existsSync('config.json')) {
const configFileContents = fs.readFileSync('config.json', 'utf8');
serverConfig = JSON.parse(configFileContents);
}
app.use(bodyParser.urlencoded({ extended: true }));
const sessionMiddleware = session({
secret: 'GTce3tN6U8odMwoI',
resave: false,
saveUninitialized: true,
});
app.use(sessionMiddleware);
app.use(bodyParser.json());
/* Audio Stream */
commandExists('ffmpeg')
@@ -38,56 +88,6 @@ commandExists('ffmpeg')
// Should never happen but better handle it just in case
})
/* webSocket handlers */
wss.on('connection', (ws, request) => {
const clientIp = request.connection.remoteAddress;
currentUsers++;
dataHandler.showOnlineUsers(currentUsers);
// Use ipinfo.io API to get geolocation information
https.get(`https://ipinfo.io/${clientIp}/json`, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
try {
const locationInfo = JSON.parse(data);
if(locationInfo.country === undefined) {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
} else {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`);
}
} catch (error) {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
}
});
});
ws.on('message', (message) => {
logDebug('Command received from \x1b[90m' + request.connection.remoteAddress + '\x1b[0m:', message.toString());
command = message.toString();
if(command.startsWith('X')) {
logWarn('Remote tuner shutdown attempted by \x1b[90m' + request.connection.remoteAddress + '\x1b[0m. You may consider blocking this user.');
} else {
client.write(command + "\n");
}
});
ws.on('close', (code, reason) => {
currentUsers--;
logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
});
ws.on('error', console.error);
});
/* Serving of HTML files */
app.use(express.static(path.join(__dirname, 'web')));
// Function to authenticate with the xdrd server
function authenticateWithXdrd(client, salt, password) {
const sha1 = crypto.createHash('sha1');
@@ -101,8 +101,8 @@ function authenticateWithXdrd(client, salt, password) {
client.write('x\n');
}
// WebSocket client connection
client.connect(xdrdServerPort, xdrdServerHost, () => {
// xdrd connection
client.connect(serverConfig.xdrd.xdrdPort, serverConfig.xdrd.xdrdIp, () => {
logInfo('Connection to xdrd established successfully.');
const authFlags = {
@@ -119,7 +119,7 @@ client.connect(xdrdServerPort, xdrdServerHost, () => {
if (!authFlags.receivedPassword) {
authFlags.receivedSalt = line.trim();
authenticateWithXdrd(client, authFlags.receivedSalt, xdrdPassword);
authenticateWithXdrd(client, authFlags.receivedSalt, serverConfig.xdrd.xdrdPassword);
authFlags.receivedPassword = true;
} else {
if (line.startsWith('a')) {
@@ -176,40 +176,237 @@ client.connect(xdrdServerPort, xdrdServerHost, () => {
});
client.on('close', () => {
console.log('Disconnected from xdrd');
logWarn('Disconnected from xdrd.');
});
client.on('error', (err) => {
switch (true) {
case err.message.includes("ECONNRESET"):
logError("Connection to xdrd lost. Exiting...");
break;
process.exit(1);
case err.message.includes("ETIMEDOUT"):
logError("Connection to xdrd @ " + xdrdServerHost + ":" + xdrdServerPort + " timed out.");
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xrd.xdrdPort + " timed out.");
process.exit(1);
case err.message.includes("ECONNREFUSED"):
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " failed. Is xdrd running?");
break;
default:
logError("Unhandled error: ", err.message);
}
process.exit(1);
});
/* HTTP Server */
httpServer.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
httpServer.listen(webServerPort, webServerHost, () => {
logInfo(`Web server is running at \x1b[34mhttp://${webServerHost}:${webServerPort}\x1b[0m.`);
});
/* Static data are being sent through here on connection - these don't change when the server is running */
app.get('/static_data', (req, res) => {
res.json({ qthLatitude, qthLongitude, webServerName, audioPort, streamEnabled});
res.json({
qthLatitude: serverConfig.identification.lat,
qthLongitude: serverConfig.identification.lon,
audioPort: serverConfig.webserver.audioPort,
streamEnabled: streamEnabled
});
});
/**
* AUTHENTICATION BLOCK
*/
const authenticate = (req, res, next) => {
const { password } = req.body;
// Check if the entered password matches the admin password
if (password === serverConfig.password.adminPass) {
req.session.isAdminAuthenticated = true;
req.session.isTuneAuthenticated = true;
logInfo('User from ' + req.connection.remoteAddress + ' logged in as an administrator.');
next();
} else if (password === serverConfig.password.tunePass) {
req.session.isAdminAuthenticated = false;
req.session.isTuneAuthenticated = true;
logInfo('User from ' + req.connection.remoteAddress + ' logged in with tune permissions.');
next();
} else {
res.status(403).json({ message: 'Login failed. Wrong password?' });
}
};
app.set('view engine', 'ejs'); // Set EJS as the template engine
app.set('views', path.join(__dirname, '/web'))
app.get('/', (req, res) => {
if (!fs.existsSync("config.json")) {
parseAudioDevice((result) => {
res.render('setup', {
isAdminAuthenticated: true,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices });
});;
} else {
res.render('index', {
isAdminAuthenticated: req.session.isAdminAuthenticated,
isTuneAuthenticated: req.session.isTuneAuthenticated,
tunerName: serverConfig.identification.tunerName,
tunerDesc: serverConfig.identification.tunerDesc,
tunerLock: serverConfig.lockToAdmin
})
}
});
app.get('/setup', (req, res) => {
parseAudioDevice((result) => {
res.render('setup', {
isAdminAuthenticated: req.session.isAdminAuthenticated,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices });
});
});
// Route for login
app.post('/login', authenticate, (req, res) => {
// Redirect to the main page after successful login
res.status(200).json({ message: 'Logged in successfully, refreshing the page...' });
});
app.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...' });
});
});
app.post('/saveData', (req, res) => {
const data = req.body;
let firstSetup;
if(req.session.isAdminAuthenticated || !fs.existsSync('config.json')) {
if(!fs.existsSync('config.json')) {
firstSetup = true;
}
// Save data to a JSON file
fs.writeFile('config.json', JSON.stringify(data, null, 2), (err) => {
if (err) {
logError(err);
res.status(500).send('Internal Server Error');
} else {
logInfo('Server config changed successfully.');
const configFileContents = fs.readFileSync('config.json', 'utf8');
serverConfig = JSON.parse(configFileContents);
if(firstSetup === true) {
res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
} else {
res.status(200).send('Data saved successfully!\nSome settings may need a server restart to apply.');
}
}
});
}
});
// Serve the data.json file when the /getData endpoint is accessed
app.get('/getData', (req, res) => {
if(req.session.isAdminAuthenticated) {
// Check if the file exists
fs.access(configPath, fs.constants.F_OK, (err) => {
if (err) {
// File does not exist
res.status(404).send('Data not found');
} else {
// File exists, send it as the response
res.sendFile(configPath);
}
});
}
});
app.get('/getDevices', (req, res) => {
if (req.session.isAdminAuthenticated || !fs.existsSync('config.json')) {
parseAudioDevice((result) => {
console.log(result);
res.json(result);
});
} else {
res.status(403).json({ error: 'Unauthorized' });
}
});
/**
* WEBSOCKET BLOCK
*/
wss.on('connection', (ws, request) => {
const clientIp = request.connection.remoteAddress;
currentUsers++;
dataHandler.showOnlineUsers(currentUsers);
// Use ipinfo.io API to get geolocation information
https.get(`https://ipinfo.io/${clientIp}/json`, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
try {
const locationInfo = JSON.parse(data);
if(locationInfo.country === undefined) {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
} else {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`);
}
} catch (error) {
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
}
});
});
ws.on('message', (message) => {
logDebug('Command received from \x1b[90m' + request.connection.remoteAddress + '\x1b[0m:', message.toString());
command = message.toString();
if(command.startsWith('X')) {
logWarn('Remote tuner shutdown attempted by \x1b[90m' + request.connection.remoteAddress + '\x1b[0m. You may consider blocking this user.');
return;
}
if((serverConfig.publicTuner === true) || (request.session && request.session.isTuneAuthenticated === true)) {
if(serverConfig.lockToAdmin === true) {
if(request.session && request.session.isAdminAuthenticated === true) {
client.write(command + "\n");
} else {
return;
}
} else {
client.write(command + "\n");
}
}
});
ws.on('close', (code, reason) => {
currentUsers--;
logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
});
ws.on('error', console.error);
});
httpServer.on('upgrade', (request, socket, head) => {
sessionMiddleware(request, {}, () => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
});
/* Serving of HTML files */
app.use(express.static(path.join(__dirname, 'web')));
httpServer.listen(serverConfig.webserver.webserverPort, serverConfig.webserver.webserverIp, () => {
logInfo(`Web server is running at \x1b[34mhttp://${serverConfig.webserver.webserverIp}:${serverConfig.webserver.webserverPort}\x1b[0m.`);
});
module.exports = {
serverConfig
}

241
package-lock.json generated
View File

@@ -10,8 +10,11 @@
"license": "ISC",
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"body-parser": "^1.20.2",
"command-exists-promise": "^2.0.2",
"ejs": "^3.1.9",
"express": "4.18.2",
"express-session": "^1.18.0",
"http": "^0.0.1-security",
"https": "1.0.0",
"koffi": "2.7.2",
@@ -98,6 +101,20 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -120,18 +137,23 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/async": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
@@ -139,7 +161,7 @@
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
@@ -190,6 +212,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@@ -198,6 +235,22 @@
"node": ">=10"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -331,6 +384,20 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/ejs": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
"integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -431,6 +498,74 @@
"node": ">= 0.10.0"
}
},
"node_modules/express-session": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz",
"integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==",
"dependencies": {
"cookie": "0.6.0",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/express/node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/express/node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
@@ -444,6 +579,33 @@
"resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
"integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/finalhandler": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
@@ -575,6 +737,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz",
@@ -728,6 +898,23 @@
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/jake": {
"version": "10.8.7",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz",
"integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==",
"dependencies": {
"async": "^3.2.3",
"chalk": "^4.0.2",
"filelist": "^1.0.4",
"minimatch": "^3.1.2"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/koffi": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.7.2.tgz",
@@ -983,6 +1170,14 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1038,6 +1233,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1047,9 +1250,9 @@
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -1249,6 +1452,17 @@
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
@@ -1303,6 +1517,17 @@
"is-typedarray": "^1.0.0"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -4,15 +4,18 @@
"description": "",
"main": "index.js",
"scripts": {
"debug": "node index.js",
"start": "node index.js"
"debug": "node index.js --debug",
"webserver": "node index.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"body-parser": "^1.20.2",
"command-exists-promise": "^2.0.2",
"ejs": "^3.1.9",
"express": "4.18.2",
"express-session": "^1.18.0",
"http": "^0.0.1-security",
"https": "1.0.0",
"koffi": "2.7.2",

View File

@@ -1,4 +1,17 @@
"use strict";
var fs = require('fs');
let serverConfig = {
audio: {
audioBitrate: "128k"
},
};
if(fs.existsSync('./config.json')) {
const configFileContents = fs.readFileSync('./config.json', 'utf8');
serverConfig = JSON.parse(configFileContents);
}
/*
Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming)
https://github.com/JoJoBond/3LAS
@@ -340,7 +353,7 @@ class FallbackProviderMp3 extends AFallbackProvider {
"-ac", this.Server.Channels.toString(),
"-i", "pipe:0",
"-c:a", "libmp3lame",
"-b:a", Settings.FallbackMp3Bitrate.toString() + "k",
"-b:a", serverConfig.audio.audioBitrate,
"-ac", this.Server.Channels.toString(),
"-reservoir", "0",
"-f", "mp3", "-write_xing", "0", "-id3v2_version", "0",

View File

@@ -1,27 +1,44 @@
const { spawn } = require('child_process');
const config = require('../userconfig.js');
const fs = require('fs');
const consoleCmd = require('../console.js');
let serverConfig = {
webserver: {
audioPort: "8081"
},
audio: {
audioDevice: "Microphone (High Definition Audio Device)",
audioChannels: 2,
audioBitrate: "128k"
},
};
if(fs.existsSync('./config.json')) {
const configFileContents = fs.readFileSync('./config.json', 'utf8');
serverConfig = JSON.parse(configFileContents);
}
function enableAudioStream() {
var ffmpegCommand;
// Specify the command and its arguments
const command = 'ffmpeg';
const flags = '-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 64';
const codec = '-acodec pcm_s16le -ar 48000 -ac 2';
const output = '-f s16le -fflags +nobuffer+flush_packets -packetsize 384 -flush_packets 1 -bufsize 960';
const flags = `-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 32`;
const codec = `-acodec pcm_s16le -ar 32000 -ac ${serverConfig.audio.audioChannels}`;
const output = `-f s16le -fflags +nobuffer+flush_packets -packetsize 384 -flush_packets 1 -bufsize 960`;
// Combine all the settings for the ffmpeg command
if (process.platform === 'win32') {
// Windows
ffmpegCommand = `${flags} -f dshow -i audio="${config.audioDeviceName}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${config.audioPort} -samplerate 48000 -channels 2`;
ffmpegCommand = `${flags} -f dshow -i audio="${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.audioPort} -samplerate 32000 -channels ${serverConfig.audio.audioChannels}`;
} else {
// Linux
ffmpegCommand = `${flags} -f alsa -i "${config.audioDeviceName}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${config.audioPort} -samplerate 48000 -channels 2`;
ffmpegCommand = `${flags} -f alsa -i "${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.audioPort} -samplerate 32000 -channels ${serverConfig.audio.audioChannels}`;
}
consoleCmd.logInfo("Launching audio stream on port " + config.audioPort + ".");
consoleCmd.logInfo("Using audio device: " + serverConfig.audio.audioDevice);
consoleCmd.logInfo("Launching audio stream on port " + serverConfig.webserver.audioPort + ".");
// Spawn the child process
if(config.audioDeviceName.length > 2) {
if(serverConfig.audio.audioDevice.length > 2) {
const childProcess = spawn(command, [ffmpegCommand], { shell: true });
// Handle the output of the child process (optional)

107
stream/parser.js Normal file
View File

@@ -0,0 +1,107 @@
'use strict';
const exec = require('child_process').exec;
const platform = process.platform;
function parseAudioDevice(options, callback) {
if (typeof options === 'function') {
callback = options;
options = null;
}
options = options || {};
const ffmpegPath = options.ffmpegPath || 'ffmpeg';
const callbackExists = typeof callback === 'function';
let inputDevice, prefix, audioSeparator, alternativeName, deviceParams;
switch (platform) {
case 'win32':
inputDevice = 'dshow';
prefix = /\[dshow/;
audioSeparator = /DirectShow\saudio\sdevices/;
alternativeName = /Alternative\sname\s*?\"(.*?)\"/;
deviceParams = /\"(.*?)\"/;
break;
case 'darwin':
inputDevice = 'avfoundation';
prefix = /^\[AVFoundation/;
audioSeparator = /AVFoundation\saudio\sdevices/;
deviceParams = /^\[AVFoundation.*?\]\s\[(\d*?)\]\s(.*)$/;
break;
case 'linux':
exec("cat /proc/asound/cards | sed -r 's/^ *([0-9]+) \\[(.*) *\\]: (.*)/hw:\\2/' | grep -E '^hw:'", (err, stdout) => {
audioDevices = stdout.trim().split('\n').map(device => ({ name: device }));
const result = { audioDevices };
if (callbackExists) {
callback(result);
} else {
Promise.resolve(result);
}
});
break;
}
const searchPrefix = (line) => (line.search(prefix) > -1);
const searchAudioSeparator = (line) => isVideo && (line.search(audioSeparator) > -1);
const searchAlternativeName = (line) => (platform === 'win32') && (line.search(/Alternative\sname/) > -1);
let videoDevices = [];
let audioDevices = [];
let isVideo = true;
const execute = (fulfill, reject) => {
exec(`${ffmpegPath} -f ${inputDevice} -list_devices true -i ""`, (err, stdout, stderr) => {
stderr.split("\n")
.filter(searchPrefix)
.forEach((line) => {
const deviceList = isVideo ? videoDevices : audioDevices;
if (searchAudioSeparator(line)) {
isVideo = false;
return;
}
if (searchAlternativeName(line)) {
const lastDevice = deviceList[deviceList.length - 1];
lastDevice.alternativeName = line.match(alternativeName)[1];
return;
}
const params = line.match(deviceParams);
if (params) {
let device;
switch (platform) {
case 'win32':
device = {
name: params[1]
};
break;
case 'darwin':
device = {
id: parseInt(params[1]),
name: params[2]
};
break;
case 'linux':
device = {
name: params[1]
};
break;
}
deviceList.push(device);
}
});
const result = { videoDevices, audioDevices };
if (callbackExists) {
callback(result);
} else {
fulfill(result);
}
});
};
if (callbackExists) {
execute();
} else {
return new Promise(execute);
}
}
module.exports = { parseAudioDevice };

View File

@@ -1,3 +1,15 @@
h1 {
color: var(--color-4);
font-size: 52px;
font-weight: 300;
margin-top: 0;
margin-bottom: 0;
}
h1#tuner-name {
font-size: 32px;
}
h2 {
color: var(--color-4);
margin-bottom: 0;
@@ -8,9 +20,22 @@ h3 {
font-size: 22px;
}
p#tuner-desc {
margin: 0;
}
label {
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
display: block;
text-align: left;
color: var(--color-4);
}
.canvas-container {
width: 100%;
height: 200px;
height: 175px;
}
#data-ant {
@@ -36,7 +61,7 @@ h3 {
margin-right: 5px;
}
#color-settings, #settings {
#settings, #back-btn {
background: transparent;
border: 0;
color: white;
@@ -53,12 +78,13 @@ h3 {
cursor: pointer;
}
#color-settings {
top: 96px;
#settings:hover, #back-btn:hover {
background: var(--color-3);
}
#settings:hover, #color-settings:hover {
background: var(--color-3);
#back-btn {
left: 15px;
right: auto;
}
#af-list ul {
@@ -79,6 +105,49 @@ h3 {
display: none;
}
.checkbox input[type="checkbox"] {
padding: 0;
height: initial;
width: initial;
margin-bottom: 0;
display: none;
cursor: pointer;
}
.checkbox label {
position: relative;
cursor: pointer;
}
.checkbox label:before {
content:'';
appearance: none;
-webkit-appearance: none;
background-color: transparent;
border: 2px solid var(--color-4);
padding: 10px;
display: inline-block;
position: relative;
vertical-align: middle;
cursor: pointer;
margin-right: 5px;
}
.form-group input:checked + label:after {
content: '✓';
display: block;
position: absolute;
top: 2px;
left: 6px;
width: 16px;
height: 16px;
}
.tuner-info {
margin-top: 0px !important;
margin-bottom: 0px !important;
}
@media (max-width: 768px) {
canvas, #flags-container {
display: none;
@@ -153,6 +222,9 @@ h3 {
.button-ims {
order: 3;
}
.tuner-info {
margin-bottom: -60px !important;
}
}
@media only screen and (min-width: 960px) and (max-height: 860px) {
@@ -165,4 +237,13 @@ h3 {
.canvas-container {
height: 120px;
}
.tuner-info #tuner-name {
float: left;
font-size: 24px;
}
.tuner-info #tuner-desc {
float: right;
text-align: right;
}
}

View File

@@ -1,4 +1,4 @@
button {
button, input[type="submit"] {
width: 100%;
height: 100%;
border: 0;
@@ -18,6 +18,18 @@ button:hover {
opacity: 0.6;
}
input[type="text"], textarea, input[type="password"] {
width: 300px;
min-height: 46px;
padding-left: 20px;
box-sizing: border-box;
border: 2px solid transparent;
outline: 0;
color: white;
background-color: var(--color-1);
font-family: 'Titillium Web', sans-serif;
}
#tune-buttons input[type="text"] {
width: 50%;
height: 100%;

View File

@@ -53,8 +53,7 @@
border: none;
font-size: 16px;
overflow: hidden;
opacity: 0;
visibility: hidden;
display: none;
background: var(--color-main);
color: var(--color-4);
border: 1px solid var(--color-4);
@@ -70,9 +69,9 @@
background: var(--color-4);
}
.dropdown.opened .options {
opacity: 1;
visibility: visible;
display:block;
transform: translateY(0);
position:absolute;
}
.dropdown.opened::before {
transform: rotate(-225deg);

View File

@@ -6,4 +6,5 @@
@import url("dropdown.css"); /* Custom dropdown menus */
@import url("panels.css"); /* Different panels and their sizes */
@import url("modal.css"); /* Modal window */
@import url("setup.css"); /* Web setup interface */
@import url("helpers.css"); /* Stuff that is used often such as text changers etc */

View File

@@ -30,6 +30,10 @@
color: var(--color-4);
}
.br-0 {
border-radius: 0px;
}
.br-5 {
border-radius: 5px;
}
@@ -137,6 +141,14 @@
padding: 10px;
}
.p-bottom-20 {
padding-bottom: 20px;
}
.input-text {
background-color: var(--color-2) !important;
}
@media only screen and (max-width: 960px) {
.text-medium-big {
font-size: 32px;

View File

@@ -55,6 +55,11 @@ body {
width: 1180px;
max-width: 1180px;
}
#wrapper.setup-wrapper {
margin: auto;
position: static;
transform: none;
}
@media (max-width: 1180px) {
#wrapper {

View File

@@ -68,13 +68,6 @@
background: var(--color-5);
}
.modal label {
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
display: block;
}
@media only screen and (max-width: 768px) {
.modal-content {
min-width: 90% !important;

View File

@@ -17,6 +17,10 @@
width: 33%;
}
.panel-50 {
width: 50%;
}
.panel-75 {
width: 68%;
}

42
web/css/setup.css Normal file
View File

@@ -0,0 +1,42 @@
.setup-wrapper .form-group, .setup-wrapper textarea {
display: inline-block;
float: none;
}
.setup-wrapper .form-group {
margin-right: 5px;
margin-left: 5px;
}
.setup-wrapper textarea {
width: 100%;
max-width: 768px;
background-color: var(--color-2);
height: 100px;
font-size: 14px;
padding-top: 10px;
}
#map {
height:400px;
width:100%;
overflow: hidden;
max-width:800px;
margin: auto;
margin-bottom: 20px;
}
.setup-wrapper h3 {
font-weight: 300;
margin: 8px;
}
.w-150 {
width: 150px !important
}
.w-100 {
width: 100px !important;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>FM-DX Webserver</title>
<title>FM-DX Webserver [<%= tunerName %>]</title>
<link href="css/entry.css" type="text/css" rel="stylesheet">
<link href="css/flags.min.css" type="text/css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" type="text/css" rel="stylesheet">
@@ -9,6 +9,11 @@
<link rel="icon" type="image/png" href="favicon2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:title" content="FM-DX WebServer">
<meta property="og:type" content="website">
<meta property="og:image" content="/favicon2.png">
<meta property="og:description" content="This server us running the FM-DX Webserver software by Noobish.">
<!-- 3LAS Scripts for Audio streaming -->
<script src="js/3las/util/3las.helpers.js"></script>
<script src="js/3las/util/3las.logging.js"></script>
@@ -44,6 +49,11 @@
<body>
<audio id="audioTag"></audio>
<div id="wrapper">
<div class="panel-100 no-bg tuner-info">
<h1 id="tuner-name"><%= tunerName %> <% if (tunerLock) { %><i class="fa-solid fa-lock" title="Tuner is currently locked to admin."></i><% } %></h1>
<p id="tuner-desc"><%= tunerDesc %></p>
<div style="clear: both"></div>
</div>
<div class="canvas-container hide-phone">
<canvas id="signal-canvas"></canvas>
</div>
@@ -187,17 +197,6 @@
<div class="form-group">
<label for="themes" style="margin-top: 50px;"><i class="fa-solid fa-palette"></i> Theme:</label>
<!--<select name="themes" style="margin-bottom: 15px;" id="theme-selector">
<option value="theme1">Monochrome</option>
<option value="theme2">Red</option>
<option value="theme3">Green</option>
<option value="theme4">Cyan</option>
<option value="theme5">Orange</option>
<option value="theme6">Pink</option>
<option value="theme7">Blurple</option>
<option value="theme8">AMOLED</option>
</select>-->
<div class="dropdown" id="theme-selector">
<input type="text" placeholder="Theme" readonly />
<ul class="options">
@@ -215,13 +214,8 @@
<div class="form-group">
<label for="signal" style="margin-top: 50px;"><i class="fa-solid fa-signal"></i> Signal units:</label>
<!--<select name="signal" style="margin-bottom: 15px;" id="signal-selector">
<option value="dbf">dBf</option>
<option value="dbuv">dBuV</option>
<option value="dbm">dBm</option>
</select>-->
<div class="dropdown" id="signal-selector">
<input type="text" placeholder="Theme" readonly />
<input type="text" placeholder="Signal Units" readonly />
<ul class="options">
<li class="option" data-value="dbf">dBf</li>
<li class="option" data-value="dbuv">dBuV</li>
@@ -234,13 +228,27 @@
<label for="themes"><i class="fa-solid fa-user"></i> Users online:</label>
<span id="users-online" name="users-online">0</span>
</div>
<% if (isAdminAuthenticated) { %>
<p>You are logged in as an adminstrator. <a href="/setup">Setup</a> | <a class="logout-link" href="#">Logout</a></p>
<% } else if (isTuneAuthenticated) { %>
<p>You are logged in and can control the receiver. <a class="logout-link" href="#">Logout</a></p>
<% } else { %>
<form action="/login" method="post" id="login-form">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<button type="submit" class="br-0 w-100" style="height: 44px">Login</button>
</form>
<% } %>
<div id="login-message"></div>
<div class="flex-container flex-left text-left hover-brighten p-10 br-5" onclick="window.open('https://discord.com/invite/ZAVNdS74mC')">
<i class="fa-brands fa-discord"></i>&nbsp;<span>Join our <strong>OpenRadio Discord</strong> community!</span>
</div>
<div class="flex-container flex-left text-left bottom-20 hover-brighten p-10 br-5" onclick="window.open('https://buymeacoffee.com/noobish')">
<i class="fa-solid fa-hand-holding-medical"></i>&nbsp;<span><strong>Support</strong> the developer!</span>
</div>
<p class="text-small">FM-DX WebServer by <a href="https://noobish.eu" target="_blank">Noobish</a> & the OpenRadio community.</p>
<p class="text-small">FM-DX WebServer <span style="color: var(--color-3);">v1.0.0 [4/2/2024]</span> by <a href="https://noobish.eu" target="_blank">Noobish</a> & the OpenRadio community.</p>
<p class="text-small bottom-50">This app works thanks to these amazing projects: <br>
<span class="text-smaller">- librdsparser by <a href="https://fmdx.pl" target="_blank">Konrad Kosmatka</a></span><br>
<span class="text-smaller">- 3LAS by <a href="https://github.com/JoJoBond/3LAS" target="_blank">JoJoBond</a></span><br>

View File

@@ -127,11 +127,8 @@ function getInitialSettings() {
// Use the received data (data.qthLatitude, data.qthLongitude) as needed
localStorage.setItem('qthLatitude', data.qthLatitude);
localStorage.setItem('qthLongitude', data.qthLongitude);
localStorage.setItem('webServerName', data.webServerName);
localStorage.setItem('audioPort', data.audioPort);
localStorage.setItem('streamEnabled', data.streamEnabled);
document.title = 'FM-DX Webserver [' + data.webServerName + ']';
},
error: function(error) {
console.error('Error:', error);

View File

@@ -47,6 +47,63 @@
signalSelector.find('input').val($(event.target).text()); // Set the text of the clicked option to the input
localStorage.setItem('signalUnit', selectedSignalUnit);
});
$('#login-form').submit(function (event) {
event.preventDefault();
// Perform an AJAX request to the /login endpoint
$.ajax({
type: 'POST',
url: '/login',
data: $(this).serialize(),
success: function (data) {
// Update the content on the page with the message from the response
$('#login-message').text(data.message);
setTimeout(function () {
location.reload(true);
}, 1750);
},
error: function (xhr, status, error) {
// Handle error response
if (xhr.status === 403) {
// Update the content on the page with the message from the error response
$('#login-message').text(xhr.responseJSON.message);
} else {
// Handle other types of errors if needed
console.error('Error:', status, error);
}
}
});
});
// Assuming you have an anchor tag with id 'logout-link'
$('.logout-link').click(function (event) {
event.preventDefault();
// Perform an AJAX request to the /logout endpoint
$.ajax({
type: 'GET', // Assuming the logout is a GET request, adjust accordingly
url: '/logout',
success: function (data) {
// Update the content on the page with the message from the response
$('#login-message').text(data.message);
setTimeout(function () {
location.reload(true);
}, 1750);
},
error: function (xhr, status, error) {
// Handle error response
if (xhr.status === 403) {
// Update the content on the page with the message from the error response
$('#login-message').text(xhr.responseJSON.message);
} else {
// Handle other types of errors if needed
console.error('Error:', status, error);
}
}
});
});
});

243
web/js/setup.js Normal file
View File

@@ -0,0 +1,243 @@
var map;
var pin;
var tilesURL='https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png';
var mapAttrib='&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, Tiles by <a href="http://stadiamaps.com/" target="_blank">Stadiamaps</a>';
var hostParts = window.location.host.split(':');
var hostname = hostParts[0]; // Extract the hostname
var port = hostParts[1] || '80'; // Extract the port or use a default (e.g., 8080)
var serverAddress = 'http://' + hostname + ':' + port;
// add map container
$(document).ready(function() {
MapCreate();
fetchData();
map.on('click', function(ev) {
$('#lat').val((ev.latlng.lat).toFixed(6));
$('#lng').val((ev.latlng.lng).toFixed(6));
if (typeof pin == "object") {
pin.setLatLng(ev.latlng);
} else {
pin = L.marker(ev.latlng,{ riseOnHover:true,draggable:true });
pin.addTo(map);
pin.on('drag',function(ev) {
$('#lat').val((ev.latlng.lat).toFixed(6));
$('#lng').val((ev.latlng.lng).toFixed(6));
});
}
});
$('#login-form').submit(function (event) {
event.preventDefault();
// Perform an AJAX request to the /login endpoint
$.ajax({
type: 'POST',
url: '/login',
data: $(this).serialize(),
success: function (data) {
// Update the content on the page with the message from the response
$('#login-message').text(data.message);
setTimeout(function () {
location.reload(true);
}, 1750);
},
error: function (xhr, status, error) {
// Handle error response
if (xhr.status === 403) {
// Update the content on the page with the message from the error response
$('#login-message').text(xhr.responseJSON.message);
} else {
// Handle other types of errors if needed
console.error('Error:', status, error);
}
}
});
});
// Assuming you have an anchor tag with id 'logout-link'
$('.logout-link').click(function (event) {
event.preventDefault();
// Perform an AJAX request to the /logout endpoint
$.ajax({
type: 'GET', // Assuming the logout is a GET request, adjust accordingly
url: '/logout',
success: function (data) {
// Update the content on the page with the message from the response
$('#login-message').text(data.message);
setTimeout(function () {
location.reload(true);
}, 1750);
},
error: function (xhr, status, error) {
// Handle error response
if (xhr.status === 403) {
// Update the content on the page with the message from the error response
$('#login-message').text(xhr.responseJSON.message);
} else {
// Handle other types of errors if needed
console.error('Error:', status, error);
}
}
});
});
});
function MapCreate() {
// create map instance
if (!(typeof map == "object")) {
map = L.map('map', {
center: [40,0],
zoom: 3
});
}
else {
map.setZoom(3).panTo([40,0]);
}
// create the tile layer with correct attribution
L.tileLayer(tilesURL, {
attribution: mapAttrib,
maxZoom: 19
}).addTo(map);
}
function fetchData() {
// Make a GET request to retrieve the data.json file
fetch(serverAddress + "/getData")
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data => {
// Save the received JSON data to a local variable (you may want to handle this differently on the client)
console.log('Received data:', data);
$('#webserver-ip').val(data.webserver.webserverIp);
$('#webserver-port').val(data.webserver.webserverPort);
$('#audio-port').val(data.webserver.audioPort);
$('#xdrd-ip').val(data.xdrd.xdrdIp);
$('#xdrd-port').val(data.xdrd.xdrdPort);
$('#xdrd-password').val(data.xdrd.xdrdPassword);
$('#audio-devices').val(data.audio.audioDevice);
$('#audio-channels').val(data.audio.audioChannels);
$('#audio-quality').val(data.audio.audioBitrate);
$('#webserver-name').val(data.identification.tunerName);
$('#webserver-desc').val(data.identification.tunerDesc);
$('#lat').val(data.identification.lat);
$('#lng').val(data.identification.lon);
$('#tune-pass').val(data.password.tunePass);
$('#admin-pass').val(data.password.adminPass);
$("#tuner-public").prop("checked", data.publicTuner);
$("#tuner-lock").prop("checked", data.lockToAdmin);
// Check if latitude and longitude are present in the data
if (data.identification.lat && data.identification.lon) {
// Set the map's center to the received coordinates
map.setView([data.identification.lat, data.identification.lon], 13);
// Add a pin to the map
if (typeof pin == "object") {
pin.setLatLng([data.identification.lat, data.identification.lon]);
} else {
pin = L.marker([data.identification.lat, data.identification.lon], { riseOnHover:true, draggable:true });
pin.addTo(map);
pin.on('drag',function(ev) {
$('#lat').val((ev.latlng.lat).toFixed(6));
$('#lng').val((ev.latlng.lng).toFixed(6));
});
}
}
})
.catch(error => {
console.error('Error fetching data:', error.message);
});
}
function submitData() {
const webserverIp = $('#webserver-ip').val() || '0.0.0.0';
const webserverPort = $('#webserver-port').val() || '8080';
const audioPort = $('#audio-port').val() || '8081';
const xdrdIp = $('#xdrd-ip').val() || '127.0.0.1';
const xdrdPort = $('#xdrd-port').val() || '7373';
const xdrdPassword = $('#xdrd-password').val() || 'password';
const audioDevice = $('#audio-devices').val() || 'Microphone (High Definition Audio Device)';
const audioChannels = ($('.options .option').filter(function() {
return $(this).text() === $('#audio-channels').val();
}).data('value') || 2);
const audioBitrate = $('#audio-quality').val() || '192k';
const tunerName = $('#webserver-name').val() || 'FM Tuner';
const tunerDesc = $('#webserver-desc').val() || 'Default FM tuner description';
const lat = $('#lat').val();
const lon = $('#lng').val();
const tunePass = $('#tune-pass').val();
const adminPass = $('#admin-pass').val();
const publicTuner = $("#tuner-public").is(":checked");
const lockToAdmin = $("#tuner-lock").is(":checked");
const data = {
webserver: {
webserverIp,
webserverPort,
audioPort
},
xdrd: {
xdrdIp,
xdrdPort,
xdrdPassword
},
audio: {
audioDevice,
audioChannels,
audioBitrate,
},
identification: {
tunerName,
tunerDesc,
lat,
lon,
},
password: {
tunePass,
adminPass,
},
publicTuner,
lockToAdmin
};
if(adminPass.length < 1) {
alert('You need to fill in the admin password before continuing further.');
return;
}
// Send data to the server using jQuery
$.ajax({
url: serverAddress + '/saveData',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function (message) {
alert(message);
},
error: function (error) {
console.error(error);
}
});
}

175
web/setup.ejs Normal file
View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html>
<head>
<title>FM-DX Webserver</title>
<link href="css/entry.css" type="text/css" rel="stylesheet">
<link href="css/flags.min.css" type="text/css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<link rel="icon" type="image/png" href="favicon2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="wrapper" class="setup-wrapper">
<% if (isAdminAuthenticated) { %>
<div class="panel-100 no-bg">
<h1>FM-DX WebServer</h1>
<h2>[ADMIN PANEL]</h2>
<p>This web setup allows you to set up your entire tuner. <br>Some settings will only change after a server restart.</p>
<p>In case you are setting up the webserver for the first time, we already filled fail-safe defaults for you.</p>
</div>
<div class="flex-container">
<div class="panel-50" style="min-height: 120px;margin-bottom: 0;">
<h2>BASIC SETTINGS</h2>
<h3>Connection to xdrd:</h3>
<div class="form-group">
<label for="xdrd-ip">xdrd ip address:</label>
<input class="input-text w-150" type="text" name="xdrd-ip" id="xdrd-ip" placeholder="127.0.0.1">
</div>
<div class="form-group">
<label for="xdrd-port">xdrd port:</label>
<input class="input-text w-100" type="text" name="xdrd-port" id="xdrd-port" placeholder="7373">
</div>
<div class="form-group">
<label for="xdrd-password">xdrd server password:</label>
<input class="input-text w-150" type="text" name="xdrd-password" id="xdrd-password">
</div>
<br>
<h3>Webserver connection:</h3>
<div class="form-group">
<label for="webserver-ip">Webserver IP:</label>
<input class="input-text w-150" type="text" name="webserver-ip" id="webserver-ip" placeholder="0.0.0.0">
</div>
<div class="form-group">
<label for="webserver-port">Webserver port:</label>
<input class="input-text w-100" type="text" name="webserver-port" id="webserver-port" placeholder="8080">
</div>
<div class="form-group">
<label for="audio-port">Audio port:</label>
<input class="input-text w-150" type="text" name="audio-port" id="audio-port" placeholder="8081">
</div>
<br>
<!--tune password, public/private tuner, admin password, verbose mode-->
</div>
<div class="panel-50" style="min-height: 120px;margin-bottom: 0;">
<h2>AUDIO SETTINGS</h2>
<div class="panel-100 p-bottom-20">
<div class="form-group">
<label for="audio-devices"><i class="fa-solid fa-headphones"></i> STREAM AUDIO FROM:</label>
<div class="dropdown" style="width: 300px;">
<input id="audio-devices" type="text" name="audio-devices" placeholder="Choose your audio device" readonly />
<ul class="options" id="deviceList">
<% videoDevices.forEach(device => { %>
<li data-value="<%= device.name %>" class="option"><%= device.name %></li>
<% }); %>
<% audioDevices.forEach(device => { %>
<li data-value="<%= device.name %>" class="option"><%= device.name %></li>
<% }); %>
</ul>
</div>
</div>
<div class="form-group">
<label for="audio-devices"><i class="fa-solid fa-microphone-lines"></i> AUDIO CHANNELS:</label>
<div class="dropdown" style="width: 300px;">
<input id="audio-channels" type="text" name="audio-channels" placeholder="Stereo" readonly />
<ul class="options">
<li data-value="2" class="option">Stereo</li>
<li data-value="1" class="option">Mono</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="audio-quality"><i class="fa-solid fa-wave-square"></i> AUDIO QUALITY:</label>
<div class="dropdown" style="width: 300px;">
<input id="audio-quality" type="text" name="audio-quality" placeholder="128k (saves bandwidth)" readonly />
<ul class="options">
<li data-value="128k" class="option">128k (saves bandwidth)</li>
<li data-value="192k" class="option">192k (higher quality)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="flex-container bottom-20">
<div class="panel-100" style="padding-bottom: 20px;">
<h2>TUNER IDENTIFICATION INFO</h2>
<div class="panel-100" style="padding-left: 20px; padding-right: 20px;">
<label for="webserver-name" style="width: 100%;max-width: 768px; margin:auto;">Webserver name:</label>
<input style="width: 100%; max-width: 768px;" class="input-text" type="text" name="webserver-name" id="webserver-name" placeholder="Fill your server name here.">
<br>
<label for="webserver-desc" style="width: 100%;max-width: 768px; margin: auto;">Webserver description:</label>
<textarea id="webserver-desc" name="webserver-desc" placeholder="Fill the server description here. You can put useful info here such as your antenna setup."></textarea>
</div>
<h3>Tuner location:</h3>
<div class="form-group">
<label for="lat">Latitude:</label>
<input class="input-text" type="text" name="lat" id="lat">
</div>
<div class="form-group">
<label for="lng">Longitude:</label>
<input class="input-text" type="text" name="lng" id="lng">
</div>
<div id="map"></div>
</div>
<div class="panel-33">
<h2>MAINTENANCE</h2>
<div class="form-group checkbox">
<input type="checkbox" id="tuner-public">
<label for="tuner-public">Public tuner</label>
</div><br>
<div class="form-group checkbox">
<input type="checkbox" id="tuner-lock">
<label for="tuner-lock">Lock to admin</label>
</div><br>
<div class="form-group">
<label for="tune-pass">Tune password:</label>
<input class="input-text w-150" type="text" name="tune-pass" id="tune-pass">
</div>
<div class="form-group" style="margin-bottom: 40px;">
<label for="admin-pass">Admin setup password:</label>
<input class="input-text w-150" type="text" name="admin-pass" id="admin-pass">
</div><br>
<button style="height:48px; width: 200px;margin-bottom:20px;" onclick="submitData();">Save settings</button>
<button style="height: 48px; width: 200px;background:var(--color-3)" class="logout-link">Logout</button>
<div id="login-message"></div>
</div>
</div>
<div class="panel-100 no-bg">
<p>Feel free to contact us on <a href="https://discord.gg/ZAVNdS74mC" target="_blank"><strong><i class="fa-brands fa-discord"></i> Discord</strong></a> for community support.</p>
</div>
<button onclick="document.location.href='/'" id="back-btn" aria-label="Go back to tuning"><i class="fa-solid fa-arrow-left"></i></button>
<% } else { %>
<div class="panel-100 no-bg">
<h1>FM-DX WebServer</h1>
<h2>[ADMIN PANEL]</h2>
<p>You are currently not logged in as an administrator and therefore can't change the settings.</p>
<p>Please login below.</p>
</div>
<div class="panel-100 p-bottom-20">
<h2>LOGIN</h2>
<form action="/login" method="post" id="login-form">
<input style="background-color: var(--color-2);" type="password" id="password" name="password" required>
<button type="submit" class="br-0 w-100" style="height: 44px;">Login</button>
</form>
<div id="login-message"></div>
</div>
<% } %>
</div>
<script src="js/settings.js"></script>
<script src="js/dropdown.js"></script>
<script src="js/setup.js"></script>
</body>
</html>