diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4f01771
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+uploads
+vendor
\ No newline at end of file
diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css
new file mode 100644
index 0000000..86eb6b1
--- /dev/null
+++ b/assets/css/dashboard.css
@@ -0,0 +1,104 @@
+body {
+ font-size: .875rem;
+}
+
+.feather {
+ width: 16px;
+ height: 16px;
+ vertical-align: text-bottom;
+}
+
+/*
+* Sidebar
+*/
+
+.sidebar {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 100; /* Behind the navbar */
+ padding: 0;
+ box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
+}
+
+.sidebar-sticky {
+ position: -webkit-sticky;
+ position: sticky;
+ top: 48px; /* Height of navbar */
+ height: calc(100vh - 48px);
+ padding-top: .5rem;
+ overflow-x: hidden;
+ overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
+}
+
+.sidebar .nav-link {
+ font-weight: 500;
+ color: #333;
+}
+
+.sidebar .nav-link .feather {
+ margin-right: 4px;
+ color: #999;
+}
+
+.sidebar .nav-link.active {
+ color: #007bff;
+}
+
+.sidebar .nav-link:hover .feather,
+.sidebar .nav-link.active .feather {
+ color: inherit;
+}
+
+.sidebar-heading {
+ font-size: .75rem;
+ text-transform: uppercase;
+}
+
+/*
+* Navbar
+*/
+
+.navbar-brand {
+ padding-top: .75rem;
+ padding-bottom: .75rem;
+ font-size: 1rem;
+ background-color: rgba(0, 0, 0, .25);
+ box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
+}
+
+.navbar .form-control {
+ padding: .75rem 1rem;
+ border-width: 0;
+ border-radius: 0;
+}
+
+.form-control-dark {
+ color: #fff;
+ background-color: rgba(255, 255, 255, .1);
+ border-color: rgba(255, 255, 255, .1);
+}
+
+.form-control-dark:focus {
+ border-color: transparent;
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
+}
+
+/*
+* Utilities
+*/
+
+.border-top { border-top: 1px solid #e5e5e5; }
+.border-bottom { border-bottom: 1px solid #e5e5e5; }
+
+#toastsContainer {
+ position: absolute;
+ top: 1em;
+ right: 1em;
+ z-index: 999999;
+}
+
+.toast { min-width: 9rem; }
+
+.modal-body img { width: 100%; }
\ No newline at end of file
diff --git a/assets/js/index.js b/assets/js/index.js
new file mode 100644
index 0000000..6655273
--- /dev/null
+++ b/assets/js/index.js
@@ -0,0 +1,655 @@
+$(document).ready(() => {
+ const
+ DEFAULT_TOAST_FADEIN_DELAY = 250,
+ DEFAULT_TOAST_FADEOUT_DELAY = 5000;
+
+ let progressBar = $('#serverListProgress').find('.progress-bar');
+ let serverListTable = $('#serverListTable').find('tbody');
+ let serverImage = $('#serverImage');
+
+ let serverDataModal = $('#serverDataModal');
+ let serverDescription = $('#serverDescription');
+ let serverHostname = $('#serverHostname');
+ let serverIP = $('#serverIP');
+ let serverImageLabel = $('#serverImage').parent().find('label');
+ let serverImageDefaultLabel = serverImageLabel.text();
+ let serverImageData = null;
+
+ let removeServerModal = $('#removeServerModal');
+ let removeConfirmBtn = $('#removeConfirmBtn');
+ let removeConfirmDefaultLabel = $('#removeConfirmBtn').text();
+
+ let dragging = null, currentOrder = null;
+
+ let createChart = (vanillaElement, labels, data) => {
+ labels.unshift('No data');
+ data.unshift(0);
+
+ return new Chart(vanillaElement, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [{
+ data: data,
+ lineTension: 0,
+ backgroundColor: 'transparent',
+ borderColor: '#007bff',
+ borderWidth: 4,
+ pointBackgroundColor: '#007bff'
+ }]
+ },
+ options: {
+ scales: {
+ yAxes: [{
+ ticks: {
+ beginAtZero: false
+ }
+ }]
+ },
+ legend: {
+ display: false,
+ }
+ }
+ });
+ };
+
+ let attachServerListEvents = () => {
+ let tableRows = serverListTable.find('tr');
+
+ // Track which element is being dragged
+ tableRows.on('dragstart', (event) => {
+ let target = $(event.target);
+
+ while (typeof(target.attr('draggable')) == 'undefined') {
+ target = target.parent();
+ }
+
+ console.log('over', $(target));
+
+ dragging = target;
+
+ currentOrder = [];
+
+ tableRows = serverListTable.find('tr');
+
+ tableRows.each(function () {
+ rowId = $(this).data().id;
+
+ currentOrder.push(rowId);
+ });
+ });
+
+ // Allow "drop" to trigger
+ tableRows.on('dragover', (event) => {
+ // Prevent default, allow drop.
+ event.preventDefault();
+ });
+
+ // Handle drop by swapping A and B
+ tableRows.on('drop', (event) => {
+ // Workaround default "bubbling" behavior: "drop" runs for N prevented "dragover(s)".
+ event.stopImmediatePropagation();
+
+ let target = $(event.target);
+
+ while (typeof(target.attr('draggable')) == 'undefined') {
+ target = target.parent();
+ }
+
+ if (target != dragging && dragging != null) {
+ self = target.clone();
+
+ target.replaceWith(dragging.clone());
+ dragging.replaceWith(self);
+
+ console.log('drop', target, dragging);
+
+ newOrder = [];
+
+ tableRows = serverListTable.find('tr');
+
+ tableRows.each(function () {
+ rowId = $(this).data().id;
+
+ newOrder.push(rowId);
+ });
+
+ let isOrderDifferent = false;
+
+ newOrder.forEach((value, key) => {
+ if (currentOrder[key] != newOrder[key]) {
+ isOrderDifferent = true;
+ }
+ });
+
+ if (isOrderDifferent) {
+ savingToast = createToast('savingOrderModal', 'Guardando', 'Los cambios que realizaste están siendo guardados', false, false);
+
+ $.ajax({
+ type: 'POST',
+ url: 'services/serverListManager.php',
+ data: JSON.stringify({
+ operation: 'saveOrder',
+ values: { order: newOrder }
+ })
+ })
+ .done((response) => {
+ console.log(response);
+
+ switch (response.status) {
+ case HTTP_STATUS.DATABASE_ERROR:
+ showDatabaseErrorToast();
+
+ break;
+ }
+ })
+ .fail((error) => {
+ console.error(error);
+
+ showGenericErrorToast();
+ })
+ .always(() => {
+ removeToast(savingToast);
+ });
+ }
+
+ attachServerListEvents();
+ }
+ });
+
+ serverListTable.find('[data-image]').on('click', (event) => {
+ serverImageModal = $('#serverImageModal');
+
+ modalBody = serverImageModal.find('.modal-body');
+
+ modalBody.html('');
+
+ let target = $(event.target);
+
+ while (typeof(target.data().image) == 'undefined') {
+ target = target.parent();
+ }
+
+ img = document.createElement('img');
+ img.src = IMAGE_UPLOAD_PATH + `/` + target.data().image;
+ img.onerror = (event) => {
+ target = $(event.target);
+
+ target.parent().append('
Couldn\'t load image.
');
+ target.remove();
+ }
+
+ modalBody.append(img);
+
+ serverImageModal.modal('show');
+ });
+
+ tableRows.find('[data-edit]').on('click', (event) => {
+ let target = $(event.target);
+
+ while (typeof(target.data().edit) == 'undefined') {
+ target = target.parent();
+ }
+
+ serverLoadingToast = createToast('serverLoadingToast', 'Processing data', 'We\'re retrieving the contents of the server you selected, please wait for a while.', false, false);
+
+ let toEditId = target.data().edit;
+
+ serverDataModal.data('edit', toEditId);
+
+ $.ajax({
+ type: 'POST',
+ url: 'services/serverListManager.php',
+ data: JSON.stringify({
+ operation: 'getServer',
+ values: { id: toEditId }
+ })
+ })
+ .done((response) => {
+ console.log(response);
+
+ switch (response.status) {
+ case HTTP_STATUS.OK:
+ if (response.result == null) {
+ createToast('noSuchServer', 'No such server', 'We were unable to find the server you tried to edit, please reload the page and try again.');
+ } else {
+ const server = response.result;
+
+ serverDataModal.data('edir', server.id);
+
+ serverDataModal.find('.modalMode').html('Edit');
+
+ serverDescription.val(server.description);
+ serverHostname.val(server.hostname);
+ serverIP.val(server.ip);
+
+ serverDataModal.find('textarea, input').trigger('change');
+
+ serverImageLabel.html(server.image);
+
+ serverDataModal.modal('show');
+ }
+
+ break;
+ case HTTP_STATUS.DATABASE_ERROR:
+ createToast('crashToast', 'Something went wrong', 'An unexpected exception caused your request to fail, please try again.');
+
+ break;
+ }
+ })
+ .fail((error) => {
+ console.error(error);
+
+ showGenericErrorToast();
+ })
+ .always(() => {
+ removeToast(serverLoadingToast);
+ });
+ });
+
+ tableRows.find('[data-remove]').on('click', (event) => {
+ let target = $(event.target);
+
+ while (typeof(target.data().remove ) == 'undefined') {
+ target = target.parent();
+ }
+
+ let toRemoveId = target.data().remove;
+
+ removeServerModal.data('remove', toRemoveId);
+ removeServerModal.data('confirmed', false);
+
+ $('#removeServerModal').modal('show');
+ });
+
+ feather.replace();
+ };
+
+ let createToast = (type, title, content, allowDismiss = true, autoHide = true, delay = DEFAULT_TOAST_FADEOUT_DELAY) => {
+ $('#toastsContainer').append(
+ ``
+ );
+
+ toast = $('.toast.' + type).toast('show');
+
+ toast.on('hidden.bs.toast', () => { toast.remove(); });
+
+ return toast;
+ };
+
+ let removeToast = (toast) => {
+ setTimeout(() => {
+ if (typeof(toast) == 'undefined') {
+ console.info('removeToast: tried to remove a non-existing toast, skipping...');
+ } else {
+ toast.toast('hide');
+
+ toast.on('hidden.bs.toast', () => { toast.remove(); });
+ }
+ }, DEFAULT_TOAST_FADEIN_DELAY);
+ };
+
+ let showGenericErrorToast = () => (
+ createToast('genericCrashToast', 'Something went wrong', 'An unexpected error has occured and it couldn\'t be handled, please try again later.')
+ );
+
+ let showDatabaseErrorToast = () => (
+ createToast('crashToast', 'Something went wrong', 'An unexpected exception caused your changes to get lost, please try again.')
+ );
+
+ let getServerListRow = (server) => (
+ `
+
+
+ |
+ ` + server.description + ` |
+ ` + server.hostname + ` |
+ ` + server.ip + ` |
+
+
+
+ |
+
`
+ );
+
+ $.ajax({
+ xhr: () => {
+ let xhr = new window.XMLHttpRequest();
+
+ xhr.addEventListener('progress', (event) => {
+ let loadedPercentage = Math.round((event.loaded * 100) / event.total);
+
+ if (loadedPercentage > 100) {
+ loadedPercentage = 100;
+ }
+
+ progressBar
+ .attr('aria-valuenow', loadedPercentage )
+ .css('width' , loadedPercentage + '%');
+ });
+
+ return xhr;
+ },
+ type: 'POST',
+ url: 'services/serverListManager.php',
+ data: JSON.stringify({ 'operation': 'getServers' })
+ })
+ .done((response) => {
+ console.log(response);
+
+ switch (response.status) {
+ case HTTP_STATUS.OK:
+ serverListBody = '';
+
+ response.result.forEach((server) => {
+ serverListBody += getServerListRow(server);
+ });
+
+ serverListTable.html(serverListBody);
+
+ $('#serverListCount').html(response.result.length);
+
+ attachServerListEvents();
+
+ break;
+ case HTTP_STATUS.DATABASE_ERROR:
+ break;
+ }
+ })
+ .fail((error) => {
+ console.error(error);
+
+ showGenericErrorToast();
+ })
+ .always(() => {
+ progressBar.parent().fadeOut(() => {
+ $('#serverListTable').fadeIn();
+ });
+ });
+
+ createChart($('#myChart')[0], ['test'], [1]);
+
+ $('#addServerBtn').on('click', () => {
+ serverDataModal.find('modalMode').html('Add');
+
+ serverDataModal.modal('show');
+ });
+
+ serverDescription.on('change keyup keydown', (event) => {
+ let target = $(event.target);
+
+ if (target.val().length > 0) {
+ target.removeClass('is-invalid').addClass('is-valid');
+ } else {
+ target.removeClass('is-valid is-invalid');
+ }
+ });
+
+ serverHostname.on('change keyup keydown', (event) => {
+ let target = $(event.target);
+ let value = target.val();
+
+ if (value.length > 0 && value.length < 256) {
+ if (validator.isFQDN(value)) {
+ target.removeClass('is-invalid').addClass('is-valid');
+ } else {
+ target.addClass('is-invalid').removeClass('is-valid');
+ }
+ } else {
+ target.removeClass('is-valid is-invalid');
+ }
+ });
+
+ serverIP.on('change keyup keydown', (event) => {
+ let target = $(event.target);
+ let value = target.val();
+
+ if (value.length > 0 && value.length < 256) {
+ if (validator.isIP(value)) {
+ target.removeClass('is-invalid').addClass('is-valid');
+ } else {
+ target.addClass('is-invalid').removeClass('is-valid');
+ }
+ } else {
+ target.removeClass('is-valid is-invalid');
+ }
+ });
+
+ serverImage.on('change', () => {
+ let files = serverImage[0].files;
+
+ if (files.length > 0) {
+ let file = files[0];
+
+ // Safari workaround
+ if (typeof(file.name) == 'undefined') {
+ file.name = 'Image';
+ }
+
+ if (
+ file.type.indexOf('image/jpg') > -1
+ ||
+ file.type.indexOf('image/jpeg') > -1
+ ||
+ file.type.indexOf('image/png') > -1
+ ||
+ file.type.indexOf('image/gif') > -1
+ ) {
+ serverImage.addClass('is-valid').removeClass('is-invalid');
+
+ serverImageLabel.text(file.name);
+
+ const reader = new FileReader();
+
+ reader.addEventListener('load', (event) => {
+ let result = event.target.result;
+
+ serverImageData = result.split(',')[1];
+ });
+
+ reader.readAsDataURL(file);
+ } else {
+ serverImage.removeClass('is-valid is-invalid');
+
+ serverImageLabel.text(serverImageDefaultLabel);
+
+ serverImageData = null;
+
+ createToast('invalidImageToast', 'Invalid image', 'The file you selected doesn\'t appear to be a valid image. Please make sure that it\'s either a jpg, jpeg, gif or png file, its size must be 300x300 px.');
+ }
+ } else {
+ serverImage.removeClass('is-valid is-invalid');
+
+ serverImageLabel.text(serverImageDefaultLabel);
+
+ serverImageData = null;
+ }
+ });
+
+ $('#saveServerBtn').on('click', () => {
+ let willSubmit = true;
+ let toEditId = serverDataModal.data().edit;
+
+ serverDataModal.find('textarea, input').each(function () {
+ if ($(this).attr('id') != 'serverImage' && toEditId == null) {
+ if (!$(this).hasClass('is-valid')) {
+ $(this).addClass('is-invalid');
+
+ willSubmit = false;
+ }
+ }
+ });
+
+ if (willSubmit) {
+ let operationPrefix = toEditId == null ? 'add' : 'edit';
+
+ let values = {
+ edit: toEditId,
+ description: serverDescription.val(),
+ hostname: serverHostname.val(),
+ ipAddress: serverIP.val(),
+ image: serverImageData
+ };
+
+ console.log('serverManager/' + operationPrefix + ':', values);
+
+ $.ajax({
+ type: 'POST',
+ url: 'services/serverListManager.php',
+ data: JSON.stringify({
+ operation: operationPrefix + 'Server',
+ values: values
+ })
+ })
+ .done((response) => {
+ console.log(response);
+
+ switch (response.status) {
+ case HTTP_STATUS.DATABASE_ERROR:
+ showDatabaseErrorToast();
+
+ break;
+ case HTTP_STATUS.OK:
+ newRow = getServerListRow({
+ id: response.result.id,
+ description: serverDescription.val(),
+ hostname: serverHostname.val(),
+ ip: serverIP.val(),
+ image: response.result.image
+ });
+
+ if (toEditId == null) {
+ serverListTable.append(newRow);
+
+ serverListCount = $('#serverListCount');
+
+ currentCount = parseInt(serverListCount.text());
+
+ serverListCount.text(currentCount + 1);
+ } else {
+ $('tr[data-id="' + toEditId + '"]').replaceWith(newRow);
+ }
+
+ attachServerListEvents();
+
+ serverDataModal.modal('hide');
+
+ break;
+ case HTTP_STATUS.BAD_REQUEST:
+ Object.keys(response.errors).forEach((error) => {
+ switch (error) {
+ case 'edit':
+ createToast('invalidEdit', 'Invalid server', 'The server you tried to edit, isn\'t valid.');
+
+ break;
+ case 'description':
+ serverDescription.addClass('is-invalid');
+
+ break;
+ case 'hostnameValidator':
+ serverHostname.addClass('is-invalid');
+
+ break;
+ case 'ipAddress':
+ serverIP.addClass('is-invalid');
+
+ break;
+ case 'image':
+ serverImage.addClass('is-invalid');
+
+ break;
+ }
+ });
+
+ break;
+ }
+ })
+ .fail((error) => {
+ console.error(error);
+
+ showGenericErrorToast();
+ });
+ }
+ });
+
+ serverDataModal.on('hidden.bs.modal', () => {
+ serverDataModal
+ .data('edit', null)
+ .find('textarea, input')
+ .val('')
+ .trigger('change');
+ });
+
+ removeServerModal.on('hidden.bs.modal', () => {
+ removeServerModal
+ .data('remove', null)
+
+ removeConfirmBtn.text(removeConfirmDefaultLabel);
+ });
+
+ $('#removeConfirmBtn').on('click', () => {
+ if (!removeServerModal.data().confirmed) {
+ removeServerModal.data('confirmed', true);
+
+ removeConfirmBtn.text('Again!');
+
+ return;
+ }
+
+ let toRemoveId = removeServerModal.data().remove;
+
+ removingServerToast = createToast('removingServerToast', 'Removing server', 'We\'re removing the server you selected, please wait for a while.', false, false);
+
+ $.ajax({
+ type: 'POST',
+ url: 'services/serverListManager.php',
+ data: JSON.stringify({
+ operation: 'removeServer',
+ values: { id: toRemoveId }
+ })
+ })
+ .done((response) => {
+ console.log(response);
+
+ switch (response.status) {
+ case HTTP_STATUS.OK:
+ toRemoveElement = $('tr[data-id="' + toRemoveId + '"]');
+
+ removeServerModal.modal('hide');
+
+ toRemoveElement.slideUp(() => {
+ toRemoveElement.remove();
+ });
+
+ break;
+ case HTTP_STATUS.DATABASE_ERROR:
+ showDatabaseErrorToast();
+
+ break;
+ }
+ })
+ .fail((error) => {
+ console.error(error);
+
+ showGenericErrorToast();
+ })
+ .always(() => {
+ removeToast(removingServerToast);
+ });
+ })
+});
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..afa7983
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "wixel/gump": "^1.12"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..788c875
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,80 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "ebc96e0622c4739953bd5af44f70c3dc",
+ "packages": [
+ {
+ "name": "wixel/gump",
+ "version": "v1.12.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Wixel/GUMP.git",
+ "reference": "e8416a3942f8e868c930e3577e12ba23ef02e8fe"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Wixel/GUMP/zipball/e8416a3942f8e868c930e3577e12ba23ef02e8fe",
+ "reference": "e8416a3942f8e868c930e3577e12ba23ef02e8fe",
+ "shasum": ""
+ },
+ "require": {
+ "ext-bcmath": "*",
+ "ext-iconv": "*",
+ "ext-intl": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "kamermans/docblock-reflection": "^1.0",
+ "maddhatter/markdown-table": "^1.0",
+ "mockery/mockery": "^1.3",
+ "php-coveralls/php-coveralls": "^2.2",
+ "phpunit/phpunit": "^6"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "gump.class.php"
+ ],
+ "psr-4": {
+ "GUMP\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Sean Nieuwoudt",
+ "homepage": "https://wixelhq.com"
+ },
+ {
+ "name": "Filis Futsarov",
+ "homepage": "https://filis.me"
+ }
+ ],
+ "description": "A fast, extensible & stand-alone PHP input validation class that allows you to validate any data.",
+ "homepage": "https://wixelhq.com/",
+ "keywords": [
+ "validate",
+ "validation",
+ "validator"
+ ],
+ "time": "2020-12-13T17:54:26+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "1.1.0"
+}
diff --git a/includes/constants.php b/includes/constants.php
new file mode 100644
index 0000000..a28c0a6
--- /dev/null
+++ b/includes/constants.php
@@ -0,0 +1,23 @@
+ 200,
+ 'CREATED' => 201,
+ 'BAD_REQUEST' => 400,
+ 'FORBIDDEN' => 403,
+ 'NOT_FOUND' => 404,
+ 'DATABASE_ERROR' => -1000
+]);
+
+?>
\ No newline at end of file
diff --git a/includes/database.php b/includes/database.php
new file mode 100644
index 0000000..d6d6d1b
--- /dev/null
+++ b/includes/database.php
@@ -0,0 +1,29 @@
+ PDO::FETCH_ASSOC ]
+ );
+ } catch (PDOException $exception) {
+ error_log($exception->getMessage());
+
+ $this->isUsable = false;
+ }
+ }
+}
+
+?>
\ No newline at end of file
diff --git a/includes/functions.php b/includes/functions.php
new file mode 100644
index 0000000..0b0812f
--- /dev/null
+++ b/includes/functions.php
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/includes/main.php b/includes/main.php
new file mode 100644
index 0000000..4e6d64c
--- /dev/null
+++ b/includes/main.php
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..90836a5
--- /dev/null
+++ b/index.php
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+ Image |
+ Description |
+ Hostname |
+ IP address |
+ Actions |
+
+
+
+
+
+
+
+
+ Displaying a total of 0 servers.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Do you really want to remove this server?
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/services/serverListManager.php b/services/serverListManager.php
new file mode 100644
index 0000000..2270a34
--- /dev/null
+++ b/services/serverListManager.php
@@ -0,0 +1,284 @@
+operation)) {
+ switch ($request->operation) {
+ case 'getServers':
+ if ($database->isUsable) {
+ $output = [ 'status' => HTTP_STATUS['OK'] ];
+
+ $statement = $database->query(
+ 'SELECT *
+ FROM `sm_servers`
+ WHERE `enabled`
+ ORDER BY `order` ASC'
+ );
+
+ $output['result'] = $statement->fetchAll();
+
+ reply($output);
+ } else {
+ reply([ 'status' => HTTP_STATUS['DATABASE_ERROR'] ]);
+ }
+
+ break;
+ case 'getServer':
+ if ($database->isUsable) {
+ if (isset($request->values)) {
+ $values = &$request->values;
+
+ if (isset($values->id) && is_numeric($values->id)) {
+ $output = [ 'status' => HTTP_STATUS['OK'] ];
+
+ $statement = $database->prepare(
+ 'SELECT *
+ FROM `sm_servers`
+ WHERE `id` = :id
+ AND `enabled`'
+ );
+
+ $statement->execute([ 'id' => $values->id ]);
+
+ $output['result'] = $statement->fetch();
+
+ reply($output);
+ } else {
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+ } else {
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+ } else {
+ reply([ 'status' => HTTP_STATUS['DATABASE_ERROR'] ]);
+ }
+
+ break;
+ case 'saveOrder':
+ if (
+ isset($request->values)
+ &&
+ isset($request->values->order)
+ &&
+ is_array($request->values->order)
+ ) {
+ $updateCount = 0;
+
+ foreach ($request->values->order as $key => $value) {
+ $statement = $database->prepare(
+ 'UPDATE `sm_servers`
+ SET `order` = :order
+ WHERE `id` = :id'
+ );
+
+ $statement->execute([
+ 'order' => $key,
+ 'id' => $value
+ ]);
+ }
+
+ reply([ 'status' => HTTP_STATUS['OK'] ]);
+ } else {
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+
+ break;
+ case 'addServer':
+ // FALLTHROUGH
+ case 'editServer':
+ if (isset($request->values)) {
+ $values = (array) $request->values; // re-cast to Array
+
+ $result = [
+ 'id' => null,
+ 'image' => null
+ ];
+
+ // Workaround GUMP validator limitation.
+ if (isset($values['hostname'])) {
+ $values['hostnameValidator'] = 'https://' . $values['hostname'];
+ }
+
+ $gump = new GUMP();
+
+ $gump->validation_rules([
+ 'description' => 'required|min_len,1|max_len,65535',
+ 'hostnameValidator' => 'required|valid_url',
+ 'ipAddress' => 'required|valid_ip'
+ ]);
+
+ $gump->set_fields_error_messages([
+ 'description' => [
+ 'required' => 'Please provide a description.',
+ 'min_len,1' => 'The description must have at least one character.',
+ 'max_len,65535' => 'The description can\'t be over 65535 characters long.'
+ ],
+ 'hostnameValidator' => [
+ 'required' => 'Please provide a hostname.',
+ 'valid_url' => 'Please provide a valid hostname.'
+ ],
+ 'ipAddress' => [
+ 'required' => 'Please provide an IP address.',
+ 'valid_ip' => 'Please provide a valid IP address (can be either IPv4 or IPv6).'
+ ]
+ ]);
+
+ $gump->filter_rules([
+ 'description' => 'trim|sanitize_string',
+ 'hostnameValidator' => 'trim|sanitize_string',
+ 'ipAddress' => 'trim|sanitize_string'
+ ]);
+
+ $validData = $gump->run($values);
+ $errors = $gump->get_errors_array();
+
+ $isImageValid = false;
+
+ if (isset($values['image']) && !empty($values['image'])) {
+ $imageBinary = base64_decode(
+ $values['image']
+ );
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+
+ $mime = finfo_buffer($finfo, $imageBinary);
+
+ $isImageValid = in_array($mime, IMAGE_VALID_MIMES);
+
+ if ($isImageValid) {
+ $imageResource = imagecreatefromstring($imageBinary);
+
+ if ($imageResource === false) {
+ $errors['image'] = 'The provided image is either corrupt or unsupported.';
+ } else {
+ // unique ID + _ + . + extension (image/jpg => jpg)
+ $imageFilename = uniqid() . '_' . sha1($imageBinary) . '.' . explode('/', $mime)[1];
+
+ file_put_contents(IMAGE_UPLOAD_PATH . '/' . $imageFilename, $imageBinary);
+ }
+ }
+ }
+
+ $changes = -1;
+
+ if ($values['edit'] == null && !$isImageValid) {
+ $errors['image'] = 'The provided image isn\'t valid and you must provide one.';
+ } else {
+ if ($values['edit'] == null) {
+ $statement = $database->prepare(
+ 'INSERT INTO `sm_servers` (
+ `description`,
+ `hostname`,
+ `ip`,
+ `order`
+ ) VALUES (
+ :description,
+ :hostname,
+ :ip, (
+ SELECT `order`
+ FROM (
+ SELECT `order` + 1 AS `order`
+ FROM `sm_servers`
+ ORDER BY `order` DESC
+ LIMIT 1
+ ) AS newOrder
+ )
+ )'
+ );
+ } else {
+ $statement = $database->prepare(
+ 'UPDATE `sm_servers`
+ SET
+ `description` = :description,
+ `hostname` = :hostname,
+ `ip` = :ip
+ WHERE `id` = :id'
+ );
+ }
+
+ $statement->bindValue('description' , $validData['description']);
+ $statement->bindValue('hostname' , $validData['hostname']);
+ $statement->bindValue('ip' , $validData['ipAddress']);
+
+ if ($values['edit'] != null) {
+ $statement->bindValue('id', $values['edit']);
+ }
+
+ $statement->execute();
+
+ $changes = $statement->rowCount();
+ }
+
+ $result['id'] = (
+ $values['edit'] == null
+ ? $database->lastInsertId()
+ : $values['edit']
+ );
+
+ if (isset($imageFilename) && $changes > -1) {
+ $result['image'] = $imageFilename;
+
+ $statement = $database->prepare(
+ 'UPDATE `sm_servers`
+ SET `image` = :image
+ WHERE `id` = :id'
+ );
+
+ $statement->execute([
+ 'image' => $imageFilename,
+ 'id' => $result['id']
+ ]);
+ }
+
+ reply([
+ 'result' => $result,
+ 'errors' => $errors,
+ 'status' => (
+ $changes > -1 || !$isImageValid
+ ? (
+ count($errors) > 0
+ ? HTTP_STATUS['BAD_REQUEST']
+ : HTTP_STATUS['OK']
+ )
+ : HTTP_STATUS['DATABASE_ERROR']
+ )
+ ]);
+ } else {
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+
+ break;
+ case 'removeServer':
+ if (isset($request->values->id) && is_numeric($request->values->id)) {
+ $statement = $database->prepare(
+ 'UPDATE `sm_servers`
+ SET `enabled` = FALSE
+ WHERE `id` = :id'
+ );
+
+ $statement->execute([ 'id' => $request->values->id ]);
+
+ reply([ 'status' => HTTP_STATUS['OK'] ]);
+ } else {
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+
+ break;
+ default:
+ reply([ 'status' => HTTP_STATUS['BAD_REQUEST'] ]);
+ }
+} else {
+ print json_encode([
+ 'status' => HTTP_STATUS['BAD_REQUEST']
+ ]);
+}
+
+?>
\ No newline at end of file
diff --git a/views/footer.php b/views/footer.php
new file mode 100644
index 0000000..e48b67d
--- /dev/null
+++ b/views/footer.php
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+