mirror of
https://github.com/billz/raspap-webgui.git
synced 2025-03-01 10:31:47 +00:00
Merge pull request #1462 from RaspAP/feat/check-update
Feature: Check for update
This commit is contained in:
commit
7ac8d8b9f3
26
ajax/system/sys_chk_update.php
Normal file
26
ajax/system/sys_chk_update.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
require '../../includes/csrf.php';
|
||||
require_once '../../includes/config.php';
|
||||
require_once '../../includes/defaults.php';
|
||||
|
||||
if (isset($_POST['csrf_token'])) {
|
||||
if (csrfValidateRequest() && !CSRFValidate()) {
|
||||
handleInvalidCSRFToken();
|
||||
}
|
||||
$uri = RASPI_API_ENDPOINT;
|
||||
preg_match('/(\d+(\.\d+)+)/', RASPI_VERSION, $matches);
|
||||
$thisRelease = $matches[0];
|
||||
|
||||
$json = shell_exec("wget --timeout=5 --tries=1 $uri -qO -");
|
||||
$data = json_decode($json, true);
|
||||
$tagName = $data['tag_name'];
|
||||
$updateAvailable = checkReleaseVersion($thisRelease, $tagName);
|
||||
|
||||
$response['tag'] = $tagName;
|
||||
$response['update'] = $updateAvailable;
|
||||
echo json_encode($response);
|
||||
|
||||
} else {
|
||||
handleInvalidCSRFToken();
|
||||
}
|
21
ajax/system/sys_perform_update.php
Normal file
21
ajax/system/sys_perform_update.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
require '../../includes/csrf.php';
|
||||
|
||||
if (isset($_POST['csrf_token'])) {
|
||||
if (csrfValidateRequest() && !CSRFValidate()) {
|
||||
handleInvalidCSRFToken();
|
||||
}
|
||||
// set installer path + options
|
||||
$path = getenv("DOCUMENT_ROOT");
|
||||
$opts = " --update --yes --path $path";
|
||||
$installer = "sudo /etc/raspap/system/raspbian.sh";
|
||||
$execUpdate = $installer.$opts;
|
||||
|
||||
$response = shell_exec($execUpdate);
|
||||
echo json_encode($response);
|
||||
|
||||
} else {
|
||||
handleInvalidCSRFToken();
|
||||
}
|
||||
|
43
ajax/system/sys_read_logfile.php
Normal file
43
ajax/system/sys_read_logfile.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
$logFile = '/tmp/raspap_install.log';
|
||||
$searchStrings = [
|
||||
'Configure update' => 1,
|
||||
'Updating sources' => 2,
|
||||
'Installing required packages' => 3,
|
||||
'Cloning latest files' => 4,
|
||||
'Installing application' => 5,
|
||||
'Installation completed' => 6,
|
||||
'error' => 7
|
||||
];
|
||||
usleep(500);
|
||||
|
||||
if (file_exists($logFile)) {
|
||||
$handle = fopen($logFile, 'r');
|
||||
|
||||
if ($handle) {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
foreach ($searchStrings as $searchString => $value) {
|
||||
if (strpos($line, $searchString) !== false) {
|
||||
echo $value .PHP_EOL;
|
||||
flush();
|
||||
ob_flush();
|
||||
if ($value === 6) {
|
||||
fclose($handle);
|
||||
exit();
|
||||
} elseif ($value === 7) {
|
||||
echo $line .PHP_EOL;
|
||||
fclose($handle);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
} else {
|
||||
echo json_encode("Unable to open file: $logFile");
|
||||
}
|
||||
} else {
|
||||
echo json_encode("File does not exist: $logFile");
|
||||
}
|
||||
|
@ -276,6 +276,98 @@ $('#debugModal').on('shown.bs.modal', function (e) {
|
||||
});
|
||||
});
|
||||
|
||||
$('#chkupdateModal').on('shown.bs.modal', function (e) {
|
||||
var csrfToken = $('meta[name=csrf_token]').attr('content');
|
||||
$.post('ajax/system/sys_chk_update.php',{'csrf_token': csrfToken},function(data){
|
||||
var response = JSON.parse(data);
|
||||
var tag = response.tag;
|
||||
var update = response.update;
|
||||
var msg;
|
||||
var msgUpdate = $('#msgUpdate').data('message');
|
||||
var msgLatest = $('#msgLatest').data('message');
|
||||
var msgInstall = $('#msgInstall').data('message');
|
||||
var msgDismiss = $('#js-check-dismiss').data('message');
|
||||
var faCheck = '<i class="fas fa-check ml-2"></i><br />';
|
||||
$("#updateSync").removeClass("fa-spin");
|
||||
if (update === true) {
|
||||
msg = msgUpdate +' '+tag;
|
||||
$("#msg-check-update").html(msg);
|
||||
$("#msg-check-update").append(faCheck);
|
||||
$("#msg-check-update").append("<p>"+msgInstall+"</p>");
|
||||
$("#js-sys-check-update").removeClass("collapse");
|
||||
} else {
|
||||
msg = msgLatest;
|
||||
dismiss = $("#js-check-dismiss");
|
||||
$("#msg-check-update").html(msg);
|
||||
$("#msg-check-update").append(faCheck);
|
||||
$("#js-sys-check-update").remove();
|
||||
dismiss.text(msgDismiss);
|
||||
dismiss.removeClass("btn-outline-secondary");
|
||||
dismiss.addClass("btn-primary");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#performUpdate').on('submit', function(event) {
|
||||
event.preventDefault();
|
||||
var csrfToken = $('meta[name=csrf_token]').attr('content');
|
||||
$.post('ajax/system/sys_perform_update.php',{
|
||||
'csrf_token': csrfToken
|
||||
})
|
||||
$('#chkupdateModal').modal('hide');
|
||||
$('#performupdateModal').modal('show');
|
||||
});
|
||||
|
||||
$('#performupdateModal').on('shown.bs.modal', function (e) {
|
||||
fetchUpdateResponse();
|
||||
});
|
||||
|
||||
function fetchUpdateResponse() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const complete = 6;
|
||||
const error = 7;
|
||||
let phpFile = 'ajax/system/sys_read_logfile.php';
|
||||
$.ajax({
|
||||
url: phpFile,
|
||||
type: 'GET',
|
||||
success: function(response) {
|
||||
let endPolling = false;
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
let divId = '#updateStep' + i;
|
||||
if (response.includes(i.toString())) {
|
||||
$(divId).removeClass('invisible');
|
||||
}
|
||||
if (response.includes(complete)) {
|
||||
var successMsg = $('#successMsg').data('message');
|
||||
$('#updateMsg').after('<span class="small">' + successMsg + '</span>');
|
||||
$('#updateMsg').addClass('fa-check');
|
||||
$('#updateMsg').removeClass('invisible');
|
||||
$('#updateStep6').removeClass('invisible');
|
||||
$('#updateSync2').removeClass("fa-spin");
|
||||
$('#updateOk').removeAttr('disabled');
|
||||
endPolling = true;
|
||||
break;
|
||||
} else if (response.includes(error)) {
|
||||
var errorMsg = $('#errorMsg').data('message');
|
||||
$('#updateMsg').after('<span class="small">' + errorMsg + '</span>');
|
||||
$('#updateMsg').addClass('fa-times');
|
||||
$('#updateMsg').removeClass('invisible');
|
||||
$('#updateSync2').removeClass("fa-spin");
|
||||
$('#updateOk').removeAttr('disabled');
|
||||
endPolling = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!endPolling) {
|
||||
setTimeout(fetchUpdateResponse, 500);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#hostapdModal').on('shown.bs.modal', function (e) {
|
||||
var seconds = 3;
|
||||
var pct = 0;
|
||||
|
@ -31,6 +31,9 @@ define('RASPI_LIGHTTPD_CONFIG', '/etc/lighttpd/lighttpd.conf');
|
||||
define('RASPI_ACCESS_CHECK_IP', '1.1.1.1');
|
||||
define('RASPI_ACCESS_CHECK_DNS', 'one.one.one.one');
|
||||
|
||||
// Constant for the GitHub API latest release endpoint
|
||||
define('RASPI_API_ENDPOINT', 'https://api.github.com/repos/RaspAP/raspap-webgui/releases/latest');
|
||||
|
||||
// Constant for the 5GHz wireless regulatory domain
|
||||
define("RASPI_5GHZ_CHANNEL_MIN", 100);
|
||||
define("RASPI_5GHZ_CHANNEL_MAX", 192);
|
||||
|
@ -899,3 +899,28 @@ function getCountryCodes($locale = 'en', $flag = true) {
|
||||
return $countryData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current release with the latest available release
|
||||
*
|
||||
* @param string $installed
|
||||
* @param string $latest
|
||||
* @return boolean
|
||||
*/
|
||||
function checkReleaseVersion($installed, $latest) {
|
||||
$installedArray = explode('.', $installed);
|
||||
$latestArray = explode('.', $latest);
|
||||
|
||||
// compare segments of the version number
|
||||
for ($i = 0; $i < max(count($installedArray), count($latestArray)); $i++) {
|
||||
$installedSegment = (int)($installedArray[$i] ?? 0);
|
||||
$latestSegment = (int)($latestArray[$i] ?? 0);
|
||||
|
||||
if ($installedSegment < $latestSegment) {
|
||||
return true;
|
||||
} elseif ($installedSegment > $latestSegment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -545,33 +545,39 @@ function _create_openvpn_scripts() {
|
||||
|
||||
# Fetches latest files from github to webroot
|
||||
function _download_latest_files() {
|
||||
if [ -d "$webroot_dir" ] && [ "$update" == 0 ]; then
|
||||
sudo mv $webroot_dir "$webroot_dir.`date +%F-%R`" || _install_status 1 "Unable to remove old webroot directory"
|
||||
elif [ "$upgrade" == 1 ] || [ "$update" == 1 ]; then
|
||||
sudo rm -rf "$webroot_dir"
|
||||
fi
|
||||
|
||||
_install_log "Cloning latest files from GitHub"
|
||||
source_dir="/tmp/raspap-webgui"
|
||||
if [ -d "$source_dir" ]; then
|
||||
echo "Temporary download destination $source_dir exists. Removing..."
|
||||
rm -r "$source_dir"
|
||||
fi
|
||||
if [ "$repo" == "RaspAP/raspap-insiders" ]; then
|
||||
if [ -n "$username" ] && [ -n "$acctoken" ]; then
|
||||
insiders_source_url="https://${username}:${acctoken}@github.com/$repo"
|
||||
git clone --branch $branch --depth 1 -c advice.detachedHead=false $insiders_source_url /tmp/raspap-webgui || clone=false
|
||||
git clone --branch $branch --depth 1 -c advice.detachedHead=false $insiders_source_url $source_dir || clone=false
|
||||
else
|
||||
_install_status 3
|
||||
echo "Insiders please read this: https://docs.raspap.com/insiders/#authentication"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$insiders_source_url" ]; then
|
||||
git clone --branch $branch --depth 1 -c advice.detachedHead=false $git_source_url /tmp/raspap-webgui || clone=false
|
||||
git clone --branch $branch --depth 1 -c advice.detachedHead=false $git_source_url $source_dir || clone=false
|
||||
fi
|
||||
if [ "$clone" = false ]; then
|
||||
_install_status 1 "Unable to download files from github"
|
||||
_install_status 1 "Unable to download files from GitHub"
|
||||
echo "The installer cannot continue." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$webroot_dir" ] && [ "$update" == 0 ]; then
|
||||
sudo mv $webroot_dir "$webroot_dir.`date +%F-%R`" || _install_status 1 "Unable to move existing webroot directory"
|
||||
elif [ "$upgrade" == 1 ] || [ "$update" == 1 ]; then
|
||||
shopt -s extglob
|
||||
sudo find "$webroot_dir" ! -path "${webroot_dir}/ajax/system/sys_read_logfile.php" -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
_install_log "Installing application to $webroot_dir"
|
||||
sudo mv /tmp/raspap-webgui $webroot_dir || _install_status 1 "Unable to move raspap-webgui to $webroot_dir"
|
||||
sudo rsync -av --exclude='ajax/system/sys_read_logfile.php' "$source_dir"/ "$webroot_dir"/ >/dev/null 2>&1 || _install_status 1 "Unable to install files to $webroot_dir"
|
||||
|
||||
if [ "$update" == 1 ]; then
|
||||
_install_log "Applying existing configuration to ${webroot_dir}/includes"
|
||||
@ -582,11 +588,13 @@ function _download_latest_files() {
|
||||
sudo mv /tmp/raspap.auth $raspap_dir || _install_status 1 "Unable to restore authentification credentials file to ${raspap_dir}"
|
||||
fi
|
||||
else
|
||||
echo "Copying primary RaspAP config to includes/config.php"
|
||||
echo "Copying primary RaspAP config to ${webroot_dir}/includes/config.php"
|
||||
if [ ! -f "$webroot_dir/includes/config.php" ]; then
|
||||
sudo cp "$webroot_dir/config/config.php" "$webroot_dir/includes/config.php"
|
||||
fi
|
||||
fi
|
||||
echo "Removing source files at ${source_dir}"
|
||||
sudo rm -rf $source_dir
|
||||
|
||||
_install_status 0
|
||||
}
|
||||
@ -777,9 +785,12 @@ function _patch_system_files() {
|
||||
sudo mkdir $raspap_dir/system || _install_status 1 "Unable to create directory '$raspap_dir/system'"
|
||||
fi
|
||||
|
||||
_install_log "Creating RaspAP debug log control script"
|
||||
_install_log "Copying RaspAP debug log control script"
|
||||
sudo cp "$webroot_dir/installers/"debuglog.sh "$raspap_dir/system" || _install_status 1 "Unable to move debug logging script"
|
||||
|
||||
_install_log "Copying RaspAP install loader"
|
||||
sudo cp "$webroot_dir/installers/"raspbian.sh "$raspap_dir/system" || _install_status 1 "Unable to move application update script"
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -c root:root "$raspap_dir/system/"*.sh || _install_status 1 "Unable change owner and/or group"
|
||||
sudo chmod 750 "$raspap_dir/system/"*.sh || _install_status 1 "Unable to change file permissions"
|
||||
|
@ -42,6 +42,7 @@ www-data ALL=(ALL) NOPASSWD:/etc/raspap/lighttpd/configport.sh
|
||||
www-data ALL=(ALL) NOPASSWD:/etc/raspap/openvpn/configauth.sh
|
||||
www-data ALL=(ALL) NOPASSWD:/etc/raspap/openvpn/openvpnlog.sh
|
||||
www-data ALL=(ALL) NOPASSWD:/etc/raspap/system/debuglog.sh
|
||||
www-data ALL=(ALL) NOPASSWD:/etc/raspap/system/raspbian.sh
|
||||
www-data ALL=(ALL) NOPASSWD:/bin/chmod o+r /tmp/hostapd.log
|
||||
www-data ALL=(ALL) NOPASSWD:/bin/chmod o+r /var/log/dnsmasq.log
|
||||
www-data ALL=(ALL) NOPASSWD:/bin/chmod o+r /tmp/wireguard.log
|
||||
|
@ -187,6 +187,9 @@ function _setup_colors() {
|
||||
|
||||
function _log_output() {
|
||||
readonly LOGFILE_PATH="/tmp"
|
||||
if [ -f "$LOGFILE_PATH/raspap_install.log" ]; then
|
||||
sudo rm "$LOGFILE_PATH/raspap_install.log"
|
||||
fi
|
||||
exec > >(tee -i $LOGFILE_PATH/raspap_install.log)
|
||||
exec 2>&1
|
||||
}
|
||||
|
Binary file not shown.
@ -1457,3 +1457,60 @@ msgstr "Insiders"
|
||||
msgid "Contributing"
|
||||
msgstr "Contributing"
|
||||
|
||||
msgid "Check for update"
|
||||
msgstr "Check for update"
|
||||
|
||||
msgid "New release check in progress..."
|
||||
msgstr "New release check in progress..."
|
||||
|
||||
msgid "A new release is available: Version"
|
||||
msgstr "A new release is available: Version"
|
||||
|
||||
msgid "Installed version is the latest release."
|
||||
msgstr "Installed version is the latest release."
|
||||
|
||||
msgid "GitHub authentication"
|
||||
msgstr "GitHub authentication"
|
||||
|
||||
msgid "Updating Insiders requires GitHub authentication."
|
||||
msgstr "Updating Insiders requires GitHub authentication."
|
||||
|
||||
msgid "Your credentials will be sent to GitHub securely with SSL. However, use caution if your RaspAP install is on a WLAN shared by untrusted users."
|
||||
msgstr "Your credentials will be sent to GitHub securely with SSL. However, use caution if your RaspAP install is on a WLAN shared by untrusted users."
|
||||
|
||||
msgid "Personal Access Token"
|
||||
msgstr "Personal Access Token"
|
||||
|
||||
msgid "Please provide a valid token."
|
||||
msgstr "Please provide a valid token."
|
||||
|
||||
msgid "Perform update"
|
||||
msgstr "Perform update"
|
||||
|
||||
msgid "Update in progress"
|
||||
msgstr "Update in progress"
|
||||
|
||||
msgid "Application is being updated..."
|
||||
msgstr "Application is being updated..."
|
||||
|
||||
msgid "Configuring update"
|
||||
msgstr "Configuring update"
|
||||
|
||||
msgid "Updating sources"
|
||||
msgstr "Updating sources"
|
||||
|
||||
msgid "Installing package updates"
|
||||
msgstr "Installing package updates"
|
||||
|
||||
msgid "Downloading latest files"
|
||||
msgstr "Downloading latest files"
|
||||
|
||||
msgid "Installing application"
|
||||
msgstr "Installing application"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Update complete"
|
||||
|
||||
msgid "An error occurred. Check the log at <code>/tmp/raspap_install.log</code>"
|
||||
msgstr "An error occurred. Check the log at <code>/tmp/raspap_install.log</code>"
|
||||
|
||||
|
@ -36,3 +36,53 @@ require_once 'app/lib/Parsedown.php';
|
||||
</div><!-- /.card -->
|
||||
</div><!-- /.col-lg-12 -->
|
||||
</div><!-- /.row -->
|
||||
|
||||
<!-- modal check-update-->
|
||||
<div class="modal fade" id="chkupdateModal" tabindex="-1" role="dialog" aria-labelledby="ModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<form id="performUpdate" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="ModalLabel"><i class="fas fa-sync-alt fa-spin mr-2" id="updateSync"></i><?php echo _("Check for update"); ?></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="col-md-12 mb-3 mt-1" id="msg-check-update"><?php echo _("New release check in progress..."); ?></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div id="msgUpdate" data-message="<?php echo _("A new release is available: Version"); ?>"></div>
|
||||
<div id="msgLatest" data-message="<?php echo _("Installed version is the latest release."); ?>"></div>
|
||||
<div id="msgInstall" data-message="<?php echo _("Install this update now?"); ?>"></div>
|
||||
<button type="button" data-message="<?php echo _("OK"); ?>" id="js-check-dismiss" class="btn btn-outline-secondary" data-dismiss="modal"><?php echo _("Cancel"); ?></button>
|
||||
<button type="submit" id="js-sys-check-update" class="btn btn-outline btn-primary collapse"><?php echo _("OK"); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- modal update-cmd -->
|
||||
<div class="modal fade" id="performupdateModal" tabindex="-1" role="dialog" aria-labelledby="ModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="ModalLabel"><i class="fas fa-sync-alt fa-spin mr-2" id="updateSync2"></i><?php echo _("Update in progress"); ?></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="col-md-12 mb-3 mt-1" id="msg-check-update"><?php echo _("Application is being updated..."); ?></div>
|
||||
<div class="ml-5"><i class="fas fa-check mr-2 invisible" id="updateStep1"></i><?php echo _("Configuring update"); ?></div>
|
||||
<div class="ml-5"><i class="fas fa-check mr-2 invisible" id="updateStep2"></i><?php echo _("Updating sources"); ?></div>
|
||||
<div class="ml-5"><i class="fas fa-check mr-2 invisible" id="updateStep3"></i><?php echo _("Installing package updates"); ?></div>
|
||||
<div class="ml-5"><i class="fas fa-check mr-2 invisible" id="updateStep4"></i><?php echo _("Downloading latest files"); ?></div>
|
||||
<div class="ml-5"><i class="fas fa-check mr-2 invisible" id="updateStep5"></i><?php echo _("Installing application"); ?></div>
|
||||
<div class="ml-5 mb-1"><i class="fas fa-check mr-2 invisible" id="updateStep6"></i><?php echo _("Update complete"); ?></div>
|
||||
<div class="ml-5 mb-3"><i class="fas mr-2 invisible" id="updateMsg"></i></div>
|
||||
<div id="errorMsg" data-message="<?php echo _("An error occurred. Check the log at <code>/tmp/raspap_install.log</code>"); ?>"></div>
|
||||
<div id="successMsg" data-message="<?php echo _("Success. Refresh this page to confirm the new version."); ?>"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline btn-primary" data-dismiss="modal" disabled id="updateOk" /><?php echo _("OK"); ?></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,9 +1,21 @@
|
||||
<!-- about general tab -->
|
||||
<div class="tab-pane active" id="aboutgeneral">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="mt-3"><?php echo _("RaspAP") ." v".RASPI_VERSION; ?></h2>
|
||||
<div class="ml-5 mt-3"><img class="about-logo" src="app/img/raspAP-logo.php" style="width: 175px; height:175px"></div>
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="ml-5 mt-2"><img class="about-logo" src="app/img/raspAP-logo.php" style="width: 175px; height:175px"></div>
|
||||
<h2 class="mt-3 ml-4"><?php echo _("RaspAP") ." v".RASPI_VERSION; ?></h2>
|
||||
<?php if (!RASPI_MONITOR_ENABLED) : ?>
|
||||
<button type="button" class="btn btn-warning ml-4 mt-2" name="check-update" data-toggle="modal" data-target="#chkupdateModal" />
|
||||
<i class="fas fa-sync-alt ml-1 mr-2"></i><?php echo _("Check for update"); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
|
||||
<div class="mt-3">RaspAP is a co-creation of <a href="https://github.com/billz">billz</a> and <a href="https://github.com/sirlagz">SirLagz</a>
|
||||
with the contributions of our <a href="https://github.com/raspap/raspap-webgui/graphs/contributors">developer community</a>
|
||||
and <a href="https://crowdin.com/project/raspap">language translators</a>.
|
||||
|
Loading…
x
Reference in New Issue
Block a user