From 5266e5854faf2160a83079dd74d7396e4702dc92 Mon Sep 17 00:00:00 2001 From: Smith Date: Thu, 21 Jul 2022 00:35:12 +0200 Subject: [PATCH] refactor web server --- dist/server.js | 34 +- index.html | 816 ---------------------------------------------- public/index.html | 125 +++++++ public/main.css | 127 ++++++++ public/main.js | 566 ++++++++++++++++++++++++++++++++ src/server.ts | 36 +- 6 files changed, 860 insertions(+), 844 deletions(-) delete mode 100644 index.html create mode 100644 public/index.html create mode 100644 public/main.css create mode 100644 public/main.js diff --git a/dist/server.js b/dist/server.js index d95736e..29bf067 100644 --- a/dist/server.js +++ b/dist/server.js @@ -23,20 +23,25 @@ let loop; if (DBG) console.log('DBG: %j %j', (new Date()), req.url); const reqUrl = new url_1.URL(req.url || '', 'http://localhost'); - const reqPath = reqUrl.pathname.split('/'); - if (reqUrl.pathname === '/') { + const p = reqUrl.pathname === '/' ? 'index.html' : reqUrl.pathname.slice(1); + if (['index.html', 'main.css', 'main.js'].includes(p)) { if (SECRET !== '') { + let ct = 'text/html'; + if (p === 'main.css') + ct = 'text/css'; + else if (p === 'main.js') + ct = 'text/javascript'; res.writeHead(200, { - 'Content-Type': 'text/html', + 'Content-Type': ct, 'Cache-Control': 'max-age=' + String(CACHE_MAX_AGE) }); - fs_1.default.createReadStream('./index.html').pipe(res); + fs_1.default.createReadStream('./public/' + p).pipe(res); } else { res.end('Configure the `SECRET` env var to enable the web UI!'); } } - else if (reqUrl.pathname === '/ping') { + else if (p === 'ping') { if (DBG) console.log('ping'); res.end('pong'); @@ -45,15 +50,16 @@ let loop; let status = 200; let re = {}; if (validateBearerToken(String(req.headers['x-btoken']))) { + const reqPath = p.split('/'); try { - if (reqPath[1] === 'features') { + if (reqPath[0] === 'features') { re.features = { steam: FEET_STEAM, discord: FEET_DISCORD, telegram: FEET_TELEGRAM }; } - else if (reqPath[1] === 'config') { + else if (reqPath[0] === 'config') { if (req.method === 'GET') { re.config = await (0, watcher_1.readConfig)(); } @@ -77,9 +83,9 @@ let loop; re.error = 'Invalid Request'; } } - else if (reqPath[1] === 'flush' && ['servers', 'discord', 'telegram'].includes(reqPath[2])) { - await restart(reqPath[2]); - re.message = '🗑️ ' + reqPath[2].slice(0, 1).toUpperCase() + reqPath[2].slice(1) + ' data flushed.'; + else if (reqPath[0] === 'flush' && ['servers', 'discord', 'telegram'].includes(reqPath[1])) { + await restart(reqPath[1]); + re.message = '🗑️ ' + reqPath[1].slice(0, 1).toUpperCase() + reqPath[1].slice(1) + ' data flushed.'; } else { status = 400; @@ -105,8 +111,9 @@ let loop; res.writeHead(404, { 'Content-Type': 'text/html' }); res.end('404 💢'); } -}).listen(APP_PORT); -console.log('Web service started %s:%s', APP_HOST, APP_PORT); +}).listen(APP_PORT, APP_HOST, () => { + console.log('Web service started %s:%s', APP_HOST, APP_PORT); +}); (0, watcher_1.main)().then(l => { loop = l; }); @@ -136,7 +143,8 @@ function validateBearerToken(btoken) { if (salt.length > 24 && /^\d{13}$/.test(valid) && /^[a-f0-9]{128}$/.test(hash) - && Date.now() < Number(valid)) { + && Date.now() < Number(valid) + && Number(valid) - Date.now() < 3600000 * 12) { return hash === crypto_1.default.createHash('sha512').update(salt + valid + SECRET).digest('hex'); } return false; diff --git a/index.html b/index.html deleted file mode 100644 index b75565a..0000000 --- a/index.html +++ /dev/null @@ -1,816 +0,0 @@ - - - - - GSW Control Panel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-

Flush data

-
-
- - - -
-
-
-
-
-
-
-
-
-
-
-

- Refresh the page to login... -

-
- F5 -
-
-
-
- -
-
-
-
- -
-
- - - - - - - diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2267cf3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,125 @@ + + + + + GSW Control Panel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+

Flush data

+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+

+ Refresh the page to login... +

+
+ F5 +
+
+
+
+ +
+
+
+
+ +
+
+ + + + + + + diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..0b6e216 --- /dev/null +++ b/public/main.css @@ -0,0 +1,127 @@ +html, +body { + margin: 0; + padding: 0; + font-family: 'Roboto', Helvetica, Arial, sans-serif; +} + +body { + background-color: #000; + background-position: top center; + transition: background-image 2s ease-in-out 0.5s; + background-attachment: fixed; + background-repeat: no-repeat; + background-size: cover; + background-image: url('https://i.imgur.com/lTly23N.png'); +} + +body>nav { + background-color: rgba(0, 0, 0, 0.75); +} + +.navbar-brand img { + width: 40px; + height: 40px; +} + +.navbar-brand span { + color: #fff; + font-family: 'Marvel', Helvetica, Arial, sans-serif; + text-decoration: none; + text-shadow: 0 0 4px #fff; + text-decoration: none; +} + +#spinner { + zoom: 2; + opacity: 0.7; +} + +#protected-section>div>div { + background-color: rgba(245, 250, 255, 0.75); +} + +#config-form h3.card_title>label { + font-weight: 900; +} + +#config-form h3>label { + margin-right: 1rem; +} + +#config-form div[data-schemaid="root"]>div.card-body>div>div { + margin-bottom: 3rem; +} + +#config-form div[data-schemaid="root"]>div.card-body>div>div:last-child { + margin-bottom: inherit; +} + +#config-form button { + vertical-align: top; +} + +#config-form input[type=checkbox] { + vertical-align: middle; + zoom: 2; + cursor: pointer; + margin-right: 0.25rem !important; +} + +#config-form .btn-group { + display: inline-flex !important; + margin-bottom: 0.25rem; +} + +#config-form .btn-group button { + margin-left: 1px; + margin-right: 1px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +#config-form .btn-group.je-object__controls { + margin: 0 0 0.75rem 0; +} + +#config-form .json-editor-btn-add { + background-color: darkgreen; +} + +#config-form .json-editor-btn-delete { + background-color: darkred; +} + +footer>a>svg { + fill: #c6cbd1; +} + +#save-config { + background-color: rgba(15, 20, 25, 0.9); +} + +#save-config ._saving_btn { + display: none; +} + +#save-config ._btn ._saving_btn { + display: inherit; +} + +#save-config ._btn ._save_btn { + display: none; +} + +#bye>h3 { + text-shadow: 0 0 6px #fff; +} + +#bye>div>a:hover { + text-decoration: none; +} + +#bye kbd { + box-shadow: 0 0.125rem 0.25rem rgb(255, 255, 255, 0.75) !important; +} \ No newline at end of file diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..58b931d --- /dev/null +++ b/public/main.js @@ -0,0 +1,566 @@ +const id = s => document.getElementById(s); + +let configEditor; +$(async () => { + const gswFeatures = await fetchApi('GET', 'features'); + if (gswFeatures) { + await setRandomBg().catch(() => { }); + setInterval(setRandomBg, 60000); + + let unsavedChanges = false; + const confirmText = 'You have unsaved changes! Are you sure you want to '; + $(window).on('beforeunload', e => { + if (unsavedChanges) return confirmText + 'navigate away?'; + }); + $('#logout').click(e => { + e.preventDefault(); + if (unsavedChanges && !confirm(confirmText + 'log out?')) return; + logout(); + }); + + activateMenu(); + $('body > nav > ul > li > a').click(e => { + e.preventDefault(); + activateMenu(e.currentTarget); + }); + + $('#top-menu').removeClass('invisible'); + $('body > footer').removeClass('d-none'); + + // Configuration + const gswConfig = await fetchApi('GET', 'config'); + if (gswConfig && gswConfig.config) { + configEditor = new JSONEditor(id('config-form'), { + "theme": "bootstrap4", + "iconlib": "fontawesome5", + "object_layout": "normal", + "template": "mustache", + "show_errors": "always", + "required_by_default": 0, + "no_additional_properties": 1, + "display_required_only": 0, + "remove_empty_properties": 1, + "keep_oneof_values": 0, + "ajax": 0, + "ajaxCredentials": 0, + "show_opt_in": 1, + "disable_edit_json": 0, + "disable_collapse": 1, + "disable_properties": 1, + "disable_array_add": 0, + "disable_array_reorder": 1, + "disable_array_delete": 0, + "enable_array_copy": 1, + "array_controls_top": 0, + "disable_array_delete_all_rows": 1, + "disable_array_delete_last_row": 1, + "prompt_before_delete": 1, + "schema": { + "title": "Configuration", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "format": "tabs-top", + "items": { + "title": "Game Server", + "type": "object", + "required": [ + "name", + "type", + "host", + "port" + ], + "properties": { + "name": { + "title": "Name", + "type": "string", + "options": { + "grid_columns": 12 + } + }, + "type": { + "title": "Gamedig type", + "description": "Look for the GameDig Type ID in the games list.", + "type": "string", + "options": { + "grid_columns": 6 + } + }, + "appId": { + "title": "Steam App ID", + "description": "Look for the AppID in the apps list.", + "type": "integer", + "format": "number", + "options": { + "grid_columns": 6 + } + }, + "host": { + "title": "Host name or IP", + "type": "string", + "options": { + "grid_columns": 6 + } + }, + "port": { + "title": "Port number", + "type": "integer", + "minimum": 1, + "maximum": 65535, + "format": "number", + "options": { + "grid_columns": 6 + } + }, + "updateIntervalMinutes": { + "title": "Update interval (minutes) [NOT IMPLEMENTED]", + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 60, + "format": "range", + "options": { + "grid_columns": 12 + } + }, + "graphHistoryHours": { + "title": "Graph history time span (hours)", + "type": "integer", + "default": 12, + "minimum": 1, + "maximum": 24, + "format": "range", + "options": { + "grid_columns": 12 + } + }, + "timezoneOffset": { + "title": "Time zone offset of the server", + "default": 0, + "type": "number", + "minimum": -12, + "maximum": 14, + "format": "range", + "options": { + "grid_columns": 6, + "grid_break": true + } + }, + "discord": { + "type": "array", + "title": "Discord channels", + "description": "The simplest way to get a discord channel ID is to enable developer mode in settings, then right clicking on the channel name will give you the Copy ID option.", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "object", + "title": "Channel", + "required": [ + "channelId" + ], + "properties": { + "channelId": { + "title": "Channel ID", + "type": "string" + } + } + }, + "format": "table", + "options": { + "grid_columns": 12 + } + }, + "telegram": { + "type": "array", + "title": "Telegram chats", + "description": "The simplest way to get a telegram chat ID is to invite @getidsbot and use /start to make it post (among other data) the chat ID.", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "object", + "title": "Chat", + "required": [ + "chatId" + ], + "properties": { + "chatId": { + "type": "string", + "title": "Chat ID" + } + } + }, + "format": "table", + "options": { + "grid_columns": 12 + } + } + }, + "format": "grid", + "headerTemplate": "{{#self.name}}{{self.name}}{{/self.name}}{{^self.name}}{{self.type}}:{{self.host}}:{{self.port}}{{/self.name}}" + } + }, + startval: gswConfig.config + }); + + configEditor.on('ready', () => { + addExportButton(); + $('#config-form').submit(e => { + e.preventDefault(); + }); + $('#spinner').addClass('d-none'); + $('#protected-section').removeClass('d-none'); + + let editorCurrVal = configEditor.getValue(); + let formCurrVal = actualFormValues(); + $('#config-form input, #config-form textarea').keyup(() => { + unsavedChanges = checkForChanges(formCurrVal); + }); + configEditor.on('change', () => { + unsavedChanges = checkForChanges(formCurrVal); + }); + + $('#save-config-submit').click(async e => { + e.preventDefault(); + if (!configEditor.isEnabled()) return; + configEditor.disable(); + $(e.currentTarget).addClass('_btn'); + + const uerr = configEditor.validate(); + if (uerr.length) { + notif('warning', 'Validation errors', uerr.map(err => err.path + ' ' + err.message).join('
'), undefined, 3 + uerr.length); + } else { + const res = await fetchApi('POST', 'config', configEditor.getValue()); + if (res && res.message) { + editorCurrVal = configEditor.getValue(); + formCurrVal = actualFormValues(); + unsavedChanges = false; + dismissSaveConfig(); + notif('success', '✔️ Changes saved successfully.', undefined, undefined, 3); + } else { + notif('danger', '⚠️ Error while saving config.', (res ? res.error : undefined), undefined, 3); + } + } + + $(e.currentTarget).removeClass('_btn'); + configEditor.enable(); + }); + + $('#save-config-reset').click(e => { + e.preventDefault(); + if (!configEditor.isEnabled()) return; + configEditor.setValue(editorCurrVal, true); + unsavedChanges = false; + dismissSaveConfig(); + if (formCurrVal !== actualFormValues()) { + document.location.reload(); + } + }); + }); + } else { + notif('danger', '⚠️ Error while loading config.'); + } + + // Flush data + $('button[data-api]').click(async e => { + e.preventDefault(); + const endpoint = e.currentTarget.dataset.api; + + if (confirm('Are you sure you want to call `' + endpoint + '`?')) { + const res = await fetchApi('GET', endpoint); + if (res && res.message) { + notif('success', res.message, undefined, undefined, 3); + } else { + notif('danger', (res ? res.error : undefined), undefined, undefined, 3); + } + } + }); + } +}); + +function dismissSaveConfig() { + $('#save-config').addClass('animate__fadeOutDown'); + setTimeout(() => { + if ($('#save-config').hasClass('animate__fadeOutDown')) { + $('#save-config').addClass('d-none'); + } + }, 500); +} + +// NOTE: sadly configEditor.getValue() can not be trusted when the +// change event originates from a keypress event +function actualFormValues() { + const formItems = $('#config-form .tab-pane:not([style*="display: none"]) input:enabled, #config-form .tab-pane:not([style*="display: none"]) textarea:enabled, #config-form .tab-pane:not([style*="display: none"]) select:enabled').toArray(); + return JSON.stringify(formItems.map(e => { + if (e.type === 'checkbox') { + return e.checked ? e.value : undefined; + } + return e.value; + })); +} + +function checkForChanges(formCurrVal) { + if (formCurrVal !== actualFormValues()) { + $('#save-config').removeClass('d-none'); + $('#save-config').removeClass('animate__fadeOutDown'); + return true; + } else { + dismissSaveConfig(); + return false; + } +}; + +function addExportButton() { + const button_holder = configEditor.root.theme.getHeaderButtonHolder(); + const exportBtn = configEditor.root.getButton('Export', 'save', 'Export As JSON File'); + button_holder.appendChild(exportBtn); + configEditor.root.header.parentNode.insertBefore(button_holder, configEditor.root.header.nextSibling); + + exportBtn.addEventListener('click', e => { + e.preventDefault(); + const dt = (new Date()).toISOString().slice(0, 19).replace(/\D/g, ''); + const filename = dt + '-gsw.config.json'; + const blob = new Blob([JSON.stringify(configEditor.getValue(), null, 2)], { + type: 'application/json;charset=utf-8' + }); + + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(blob, filename); + } else { + const a = document.createElement('a'); + a.download = filename; + a.href = URL.createObjectURL(blob); + a.dataset.downloadurl = ['text/plain', a.download, a.href].join(':'); + + a.dispatchEvent(new MouseEvent('click', { + 'view': window, + 'bubbles': true, + 'cancelable': false + })); + } + }, false); +} + +function logout() { + $(window).off('beforeunload'); + $('#save-config').addClass('d-none'); + $('#top-menu').addClass('invisible'); + $('#protected-section').addClass('d-none'); + $('#spinner').addClass('d-none'); + $('#bye').removeClass('d-none'); + $('body > footer').addClass('d-none'); + if (configEditor) configEditor.destroy(); + window.localStorage.removeItem('btoken'); +} + +function notif(type, html1, html2, html3, dismissable) { + let closeTimeout = 0; + if (typeof dismissable === 'number') { + closeTimeout = dismissable; + dismissable = true; + } + + const el = $('
', { class: 'alert alert-dismissible fade show shadow alert-' + type, role: 'alert' }); + el.attr('role', 'alert'); + if (html2) { + const h4 = $('

', { class: 'alert-heading', html: html1 }); + el.append(h4); + + const p1 = $('

', { html: html2 }); + el.append(p1); + if (html3) { + el.append($('


')); + const p2 = $('

', { class: 'mb-0', html: html3 }); + el.append(p2); + } + } else { + el.html(html1); + } + + if (dismissable !== false) { + const button = $('