Files
game-server-watcher/index.html
2022-07-02 06:06:21 +02:00

816 lines
36 KiB
HTML

<!DOCTYPE HTML>
<html lang="en">
<head>
<title>GSW Control Panel</title>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://i.imgur.com/2Ok3pxv.png" rel="icon" type="image/png">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- <link href="https://cdn.jsdelivr.net/npm/@forevolve/bootstrap-dark@latest/dist/css/toggle-bootstrap-dark.min.css" rel="stylesheet"> -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Marvel&family=Roboto&display=swap" rel="stylesheet">
<link href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/animate.css@4.1.1/animate.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@latest/dist/css/select2.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/css/jsoneditor.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/node-forge@1.3.1/dist/forge.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@latest/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mustache@latest/mustache.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@latest/dist/js/select2.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<style>
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;
}
</style>
</head>
<body>
<nav id="top-menu" class="navbar navbar-expand-lg navbar-dark invisible">
<span class="navbar-brand mb-0 h1">
<img src="https://i.imgur.com/2Ok3pxv.png" width="30" height="30" class="d-inline-block align-middle mr-sm-1 mr-md-2 mr-lg-3" alt="">
<span>GSW Control Panel</span>
</span>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a id="top-menu-configuration" class="nav-link active" href="#configuration">Configuration</a>
</li>
<li class="nav-item">
<a id="top-menu-flush-data" class="nav-link" href="#flush-data">Flush data</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<button id="logout" class="btn btn-outline-danger" type="submit">Logout</button>
</form>
</nav>
<main class="container">
<section id="protected-section" class="row d-none">
<div class="col-12">
<div id="configuration" class="p-4 m-2 bg-light border border-white rounded-lg shadow-lg">
<form method="post" id="config-form"></form>
</div>
<div id="flush-data" class="p-4 m-2 bg-light border border-white rounded-lg d-none shadow-lg">
<div id="flush-data-container">
<h3 class="card-title">Flush data</h3>
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-lg btn-danger" data-api="flush/servers">Flush
game servers data</button>
<button type="button" class="btn btn-lg btn-primary" data-api="flush/discord">Flush
discord data</button>
<button type="button" class="btn btn-lg btn-info" data-api="flush/telegram">Flush
telegram data</button>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="unprotected-section">
<div class="col-12 text-center sticky-top">
<div id="spinner" class="spinner-border m-2 justify-content-center text-primary mx-auto"></div>
</div>
<div id="bye" class="p-4 m-2 d-none">
<h3 class="text-light text-center">
Refresh the page to login...
</h3>
<div class="text-center">
<a href=""><kbd class="bg-light text-dark d-inline-block">F5</kbd></a>
</div>
</div>
</section>
</main>
<div id="toast-containers" class="container fixed-bottom mb-1 mb-lg-2 mb-xl-3">
<div id="notif-container" class="col-12 mx-auto col-lg-10 col-xl-8">
</div>
<div id="action-container" class="col-12 mx-auto col-lg-10 col-xl-8">
<div id="save-config" class="alert rounded d-none animate__animated animate__fadeInUp animate__faster shadow" role="alert">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex-inline text-light">Careful &mdash; you have unsaved changes!</div>
<form class="d-flex-inline mr-2 mr-lg-0">
<button id="save-config-reset" class="btn btn-sm text-light" type="reset">Reset</button>
<button id="save-config-submit" class="btn btn-sm btn-success" type="submit">
<span class="_save_btn">Save</span>
<span class="spinner-grow spinner-grow-sm _saving_btn" role="status" aria-hidden="true"></span>
<span class="_saving_btn">Saving...</span>
</button>
</form>
</div>
</div>
</div>
</div>
<footer class="mx-auto text-center py-2 d-none">
<a href="https://github.com/a-sync/game-server-watcher"><svg width="16" height="16" viewBox="0 0 16 16" version="1.1" aria-hidden="true">
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z">
</path>
</svg></a>
</footer>
<script>
const id = s => document.getElementById(s);
const q = qs => document.querySelectorAll(qs);
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 + 'logout?')) 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 <i>GameDig Type ID</i> in the <a target=\"_blank\" href=\"https://github.com/a-sync/node8-gamedig#games-list\">games list</a>.",
"type": "string",
"options": {
"grid_columns": 6
}
},
"appId": {
"title": "Steam App ID",
"description": "Look for the <i>AppID</i> in the <a target=\"_blank\" href=\"https://steamdb.info/apps/\">apps list</a>.",
"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 [NOT IMPLEMENTED]",
"default": "Europe/London",
"type": "number",
"minimum": -24,
"maximum": 24,
"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 <b>Copy ID</b> 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 <a target=\"_blank\" href=\"https://telegram.me/getidsbot\">@getidsbot</a> and use <code>/start</code> 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('<br>'), 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, 2);
} 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 = $('<div/>', { class: 'alert alert-dismissible fade show shadow alert-' + type, role: 'alert' });
el.attr('role', 'alert');
if (html2) {
const h4 = $('<h4/>', { class: 'alert-heading', html: html1 });
el.append(h4);
const p1 = $('<p/>', { html: html2 });
el.append(p1);
if (html3) {
el.append($('<hr/>'));
const p2 = $('<p/>', { class: 'mb-0', html: html3 });
el.append(p2);
}
} else {
el.html(html1);
}
if (dismissable !== false) {
const button = $('<button/>', { class: 'close', type: 'button', 'data-dismiss': 'alert', 'aria-label': 'Close' });
const span = $('<span/>', { 'aria-hidden': 'true', html: '&times;' });
button.append(span);
el.append(button);
}
el.alert();
if (closeTimeout > 0) {
const a = $(el);
const timer = setTimeout(a => {
a.alert('close');
}, 1000 * closeTimeout, a);
a.on('close.bs.alert', function () {
clearTimeout(timer);
})
}
$('#notif-container').append(el);
return el;
}
function activateMenu(target) {
if (!target) {
target = id('top-menu-configuration');
if (document.location.hash.length > 1) {
const hashTargetMenu = id('top-menu-' + document.location.hash.slice(1));
if (hashTargetMenu) {
target = hashTargetMenu;
}
} else if (window.localStorage.getItem('active.menu')) {
const stateTargetMenu = id('top-menu-' + window.localStorage.getItem('active.menu').slice(1));
if (stateTargetMenu) {
target = stateTargetMenu;
}
}
}
const menu = target.getAttribute('href');
const menuItems = q('#top-menu > ul > li > a');
for (const a of menuItems) {
const at = a.getAttribute('href');
if (at === menu) {
$(at).removeClass('d-none');
$(a).addClass('active');
window.history.replaceState(null, null, document.location.pathname + menu);
window.localStorage.setItem('active.menu', menu);
const titleArr = document.title.split(' #');
if (titleArr.length < 2) {
titleArr.push(menu.slice(1));
} else {
titleArr[titleArr.length - 1] = menu.slice(1);
}
document.title = titleArr.join(' #');
} else {
$(at).addClass('d-none');
$(a).removeClass('active');
}
}
}
async function fetchApi(method, endpoint, body) {
let re = null;
let errMsg = '';
try {
for (let i = 1; i <= 2; i++) {
const btoken = await getBearerToken();
if (btoken) {
const res = await fetch('/' + endpoint, {
method,
body: (body ? JSON.stringify(body) : undefined),
cache: 'no-cache',
headers: {
'x-btoken': btoken,
'content-type': 'application/json'
}
});
if (res.status === 401) {
re = false;
window.localStorage.removeItem('btoken');
} else if (res.status === 200) {
re = await res.json();
break;
}
} else {
re = false;
break;
}
}
} catch (err) {
console.error('reqApi: method, endpoint, body.length, err, re', method, endpoint, (body ? body.length : 0), err, re);
re = null;
}
if (!re) {
logout();
if (errMsg !== '') errMsg = '<br><span class="text-error">API ' + errMsg + '</span>';
$('#bye > h3').html($('#bye > h3').html() + errMsg);
}
return re;
}
async function getBearerToken() {
const bt = window.localStorage.getItem('btoken');
if (bt) {
const salt = bt.slice(0, bt.length - 141);
const valid = bt.slice(-141, -128);
const hash = bt.slice(-128);
if (salt.length > 24
&& valid.length === 13
&& hash.length === 128
&& /^\d{13}$/.test(valid)
&& Date.now() < Number(valid)) {
return bt;
}
}
// TODO: use modalDialog
// if (typeof window.showModalDialog === 'function') {} else
if (typeof HTMLDialogElement === 'function') {
const ans = await prompt('Admin secret');
if (ans) window.localStorage.setItem('btoken', await generateBearerToken(ans));
else window.localStorage.removeItem('btoken');
} else {
const hint = '<br><span class="text-error">Close the tab to re-enable dialog boxes.</span>';
$('#bye > h3').html($('#bye > h3').html() + hint);
}
return window.localStorage.getItem('btoken');
}
async function generateBearerToken(secret) {
const valid = String(Date.now() + 3600000 * 12); // Note: valid for 12 hours
const salt = Array.from({ length: 3 }).map(() => Math.random().toString(36).slice(2)).join('');
let hash;
if (crypto.subtle) {
const buff = await crypto.subtle.digest('SHA-512', new TextEncoder('utf-8').encode(salt + valid + secret));
hash = Array.from(new Uint8Array(buff)).map(x => ('00' + x.toString(16)).slice(-2)).join('');
} else {
const md = forge.md.sha512.create();
md.update(salt + valid + secret);
hash = md.digest().toHex();
}
return salt + valid + hash;
}
function setRandomBg() {
const bgs = [
'https://i.imgur.com/bDzhwG5.png',
'https://i.imgur.com/rA8JXuI.png',
'https://i.imgur.com/pstAPIw.png',
'https://i.imgur.com/gQD3xfo.png',
'https://i.imgur.com/iKTfM8z.png'
];
const bgImg = new Image();
bgImg.src = bgs[Math.floor(Math.random() * bgs.length)];
return new Promise((resolve, reject) => {
bgImg.onload = () => {
document.body.style.backgroundAttachment = 'scroll';
document.body.style.backgroundRepeat = 'repeat';
document.body.style.backgroundSize = 'auto';
document.body.style.backgroundImage = 'url(' + bgImg.src + ')';
return resolve(bgImg);
};
bgImg.onerror = () => {
return reject(bgImg);
};
});
}
</script>
</body>
</html>