action: Add action to check bridge connectivity (#1147)

* action: Add action to check bridge connectivity

It is currently not simply possible to check if the remote
server for a bridge is reachable or not, which means some
of the bridges might no longer work because the server is
no longer on the internet.

In order to find those bridges we can either check each
bridge individually (which takes a lot of effort), or use
an automated script to do this for us.

If a server is no longer reachable it could mean that it is
temporarily unavailable, or shutdown permanently. The results
of this script will at least help identifying such servers.

* [Connectivity] Use Bootstrap container to properly display contents

* [Connectivity] Limit connectivity checks to debug mode

Connectivity checks take a long time to execute and can require a lot
of bandwidth. Therefore, administrators should be able to determine
when and who is able to utilize this action. The best way to prevent
regular users from accessing this action is by making it available in
debug mode only (public servers should never run in debug mode anyway).

* [Connectivity] Split implemenation into multiple files

* [Connectivity] Make web page responsive to user input

* [Connectivity] Make status message sticky

* [Connectivity] Add icon to the status message

* [contents] Add the ability for getContents to return header information

* [Connectivity] Add header information to the reply Json data

* [Connectivity] Add new status (blue) for redirected sites

Also adds titles to status icons (Successful, Redirected, Inactive, Failed)

* [Connectivity] Fix show doesn't work for inactive bridges

* [Connectivity] Fix typo

* [Connectivity] Catch errors in promise chains

* [Connectivity] Allow search by status and update dynamically

* [Connectivity] Add a progress bar

* [Connectivity] Use bridge factory

* [Connectivity] Import Bootstrap v4.3.1 CSS
This commit is contained in:
LogMANOriginal 2019-10-31 22:02:38 +01:00 committed by GitHub
parent 6bc83310b9
commit cdc1d9c9ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 424 additions and 4 deletions

View File

@ -0,0 +1,136 @@
<?php
/**
* This file is part of RSS-Bridge, a PHP project capable of generating RSS and
* Atom feeds for websites that don't have one.
*
* For the full license information, please view the UNLICENSE file distributed
* with this source code.
*
* @package Core
* @license http://unlicense.org/ UNLICENSE
* @link https://github.com/rss-bridge/rss-bridge
*/
/**
* Checks if the website for a given bridge is reachable.
*
* **Remarks**
* - This action is only available in debug mode.
* - Returns the bridge status as Json-formatted string.
* - Returns an error if the bridge is not whitelisted.
* - Returns a responsive web page that automatically checks all whitelisted
* bridges (using JavaScript) if no bridge is specified.
*/
class ConnectivityAction extends ActionAbstract {
public function execute() {
if(!Debug::isEnabled()) {
returnError('This action is only available in debug mode!');
}
if(!isset($this->userData['bridge'])) {
$this->returnEntryPage();
return;
}
$bridgeName = $this->userData['bridge'];
$this->reportBridgeConnectivity($bridgeName);
}
/**
* Generates a report about the bridge connectivity status and sends it back
* to the user.
*
* The report is generated as Json-formatted string in the format
* {
* "bridge": "<bridge-name>",
* "successful": true/false
* }
*
* @param string $bridgeName Name of the bridge to generate the report for
* @return void
*/
private function reportBridgeConnectivity($bridgeName) {
$bridgeFac = new \BridgeFactory();
$bridgeFac->setWorkingDir(PATH_LIB_BRIDGES);
if(!$bridgeFac->isWhitelisted($bridgeName)) {
header('Content-Type: text/html');
returnServerError('Bridge is not whitelisted!');
}
header('Content-Type: text/json');
$retVal = array(
'bridge' => $bridgeName,
'successful' => false,
'http_code' => 200,
);
$bridge = $bridgeFac->create($bridgeName);
if($bridge === false) {
echo json_encode($retVal);
return;
}
$curl_opts = array(
CURLOPT_CONNECTTIMEOUT => 5
);
try {
$reply = getContents($bridge::URI, array(), $curl_opts, true);
if($reply) {
$retVal['successful'] = true;
if (isset($reply['header'])) {
if (strpos($reply['header'], 'HTTP/1.1 301 Moved Permanently') !== false) {
$retVal['http_code'] = 301;
}
}
}
} catch(Exception $e) {
$retVal['successful'] = false;
}
echo json_encode($retVal);
}
private function returnEntryPage() {
echo <<<EOD
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="static/bootstrap.min.css">
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.6.3/css/all.css"
integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/"
crossorigin="anonymous">
<link rel="stylesheet" href="static/connectivity.css">
<script src="static/connectivity.js" type="text/javascript"></script>
</head>
<body>
<div id="main-content" class="container">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div id="status-message" class="sticky-top alert alert-primary alert-dismissible fade show" role="alert">
<i id="status-icon" class="fas fa-sync"></i>
<span>...</span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close" onclick="stopConnectivityChecks()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<input type="text" class="form-control" id="search" onkeyup="search()" placeholder="Search for bridge..">
</div>
</body>
</html>
EOD;
}
}

View File

@ -37,11 +37,13 @@
* @param array $opts (optional) A list of cURL options as associative array in
* the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
* option and `$value` the corresponding value.
* @param bool $returnHeader Returns an array of two elements 'header' and
* 'content' if enabled.
*
* For more information see http://php.net/manual/en/function.curl-setopt.php
* @return string The contents.
*/
function getContents($url, $header = array(), $opts = array()){
function getContents($url, $header = array(), $opts = array(), $returnHeader = false){
Debug::log('Reading contents from "' . $url . '"');
// Initialize cache
@ -54,6 +56,11 @@ function getContents($url, $header = array(), $opts = array()){
$params = [$url];
$cache->setKey($params);
$retVal = array(
'header' => '',
'content' => '',
);
// Use file_get_contents if in CLI mode with no root certificates defined
if(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo'))) {
@ -141,6 +148,7 @@ function getContents($url, $header = array(), $opts = array()){
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$header = substr($data, 0, $headerSize);
$retVal['header'] = $header;
Debug::log('Response header: ' . $header);
@ -164,15 +172,18 @@ function getContents($url, $header = array(), $opts = array()){
if(in_array('no-cache', $directives)
|| in_array('no-store', $directives)) { // Skip caching
Debug::log('Skip server side caching');
return $data;
$retVal['content'] = $data;
break;
}
}
Debug::log('Store response to cache');
$cache->saveData($data);
return $data;
$retVal['content'] = $data;
break;
case 304: // Not modified, use cached data
Debug::log('Contents not modified on host, returning cached data');
return $cache->loadData();
$retVal['content'] = $cache->loadData();
break;
default:
if(array_key_exists('Server', $finalHeader) && strpos($finalHeader['Server'], 'cloudflare') !== false) {
returnServerError(<<< EOD
@ -193,6 +204,8 @@ PHP error: $lastError
EOD
, $errorCode);
}
return ($returnHeader === true) ? $retVal : $retVal['content'];
}
/**

7
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

8
static/connectivity.css Normal file
View File

@ -0,0 +1,8 @@
input:focus::-webkit-input-placeholder { opacity: 0; }
input:focus::-moz-placeholder { opacity: 0; }
input:focus::placeholder { opacity: 0; }
input:focus:-moz-placeholder { opacity: 0; }
input:focus:-ms-input-placeholder { opacity: 0; }
.progress { height: 2px; }
.progressbar { width: 0%; }

256
static/connectivity.js Normal file
View File

@ -0,0 +1,256 @@
var remote = location.href.substring(0, location.href.lastIndexOf("/"));
var bridges = [];
var abort = false;
window.onload = function() {
fetch(remote + '/index.php?action=list').then(function(response) {
return response.text()
}).then(function(data){
processBridgeList(data);
}).catch(console.log.bind(console)
);
}
function processBridgeList(data) {
var list = JSON.parse(data);
buildTable(list);
buildBridgeQueue(list);
checkNextBridgeAsync();
}
function buildTable(bridgeList) {
var table = document.createElement('table');
table.classList.add('table');
var thead = document.createElement('thead');
thead.innerHTML = `
<tr>
<th scope="col">Bridge</th>
<th scope="col">Result</th>
</tr>`;
var tbody = document.createElement('tbody');
for (var bridge in bridgeList.bridges) {
var tr = document.createElement('tr');
tr.classList.add('bg-secondary');
tr.id = bridge;
var td_bridge = document.createElement('td');
td_bridge.innerText = bridgeList.bridges[bridge].name;
// Link to the actual bridge on index.php
var a = document.createElement('a');
a.href = remote + "/index.php?show_inactive=1#bridge-" + bridge;
a.target = '_blank';
a.innerText = '[Show]';
a.style.marginLeft = '5px';
a.style.color = 'black';
td_bridge.appendChild(a);
tr.appendChild(td_bridge);
var td_result = document.createElement('td');
if (bridgeList.bridges[bridge].status === 'active') {
td_result.innerHTML = '<i title="Scheduled" class="fas fa-hourglass-start"></i>';
} else {
td_result.innerHTML = '<i title="Inactive" class="fas fa-times-circle"></i>';
}
tr.appendChild(td_result);
tbody.appendChild(tr);
}
table.appendChild(thead);
table.appendChild(tbody);
var content = document.getElementById('main-content');
content.appendChild(table);
}
function buildBridgeQueue(bridgeList) {
for (var bridge in bridgeList.bridges) {
if (bridgeList.bridges[bridge].status !== 'active')
continue;
bridges.push(bridge);
}
}
function checkNextBridgeAsync() {
return new Promise((resolve) => {
var msg = document.getElementById('status-message');
var icon = document.getElementById('status-icon');
if (bridges.length === 0) {
msg.classList.remove('alert-primary');
msg.classList.add('alert-success');
msg.getElementsByTagName('span')[0].textContent = 'Done';
icon.classList.remove('fa-sync');
icon.classList.add('fa-check');
} else {
var bridge = bridges.shift();
msg.getElementsByTagName('span')[0].textContent = 'Processing ' + bridge + '...';
fetch(remote + '/index.php?action=Connectivity&bridge=' + bridge)
.then(function(response) { return response.text() })
.then(JSON.parse)
.then(processBridgeResultAsync)
.then(markBridgeSuccessful, markBridgeFailed)
.then(checkAbortAsync)
.then(checkNextBridgeAsync, abortChecks)
.catch(console.log.bind(console));
search(); // Dynamically update search results
updateProgressBar();
}
resolve();
});
}
function abortChecks() {
return new Promise((resolve) => {
var msg = document.getElementById('status-message');
msg.classList.remove('alert-primary');
msg.classList.add('alert-warning');
msg.getElementsByTagName('span')[0].textContent = 'Aborted';
var icon = document.getElementById('status-icon');
icon.classList.remove('fa-sync');
icon.classList.add('fa-ban');
bridges.forEach((bridge) => {
markBridgeAborted(bridge);
})
resolve();
});
}
function processBridgeResultAsync(result) {
return new Promise((resolve, reject) => {
if (result.successful) {
resolve(result);
} else {
reject(result);
}
});
}
function markBridgeSuccessful(result) {
return new Promise((resolve) => {
var tr = document.getElementById(result.bridge);
tr.classList.remove('bg-secondary');
if (result.http_code == 200) {
tr.classList.add('bg-success');
tr.children[1].innerHTML = '<i title="Successful" class="fas fa-check"></i>';
} else {
tr.classList.add('bg-primary');
tr.children[1].innerHTML = '<i title="Redirected" class="fas fa-directions"></i>';
}
resolve();
});
}
function markBridgeFailed(result) {
return new Promise((resolve) => {
var tr = document.getElementById(result.bridge);
tr.classList.remove('bg-secondary');
tr.classList.add('bg-danger');
tr.children[1].innerHTML = '<i title="Failed" class="fas fa-exclamation-triangle"></i>';
resolve();
});
}
function markBridgeAborted(bridge) {
return new Promise((resolve) => {
var tr = document.getElementById(bridge);
tr.classList.remove('bg-secondary');
tr.classList.add('bg-warning');
tr.children[1].innerHTML = '<i title="Aborted" class="fas fa-ban"></i>';
resolve();
});
}
function checkAbortAsync() {
return new Promise((resolve, reject) => {
if (abort) {
reject();
return;
}
resolve();
});
}
function updateProgressBar() {
// This will break if the table changes
var total = document.getElementsByTagName('tr').length - 1;
var current = bridges.length;
var progress = (total - current) * 100 / total;
var progressBar = document.getElementsByClassName('progress-bar')[0];
if(progressBar){
progressBar.setAttribute('aria-valuenow', progress.toFixed(0));
progressBar.style.width = progress.toFixed(0) + '%';
}
}
function stopConnectivityChecks() {
abort = true;
}
function search() {
var input = document.getElementById('search');
var filter = input.value.toUpperCase();
var table = document.getElementsByTagName('table')[0];
var tr = table.getElementsByTagName('tr');
for (var i = 0; i < tr.length; i++) {
var td1 = tr[i].getElementsByTagName('td')[0];
var td2 = tr[i].getElementsByTagName('td')[1];
if (td1) {
var txtValue = td1.textContent || td1.innerText;
var title = '';
if(td2.getElementsByTagName('i')[0]) {
title = td2.getElementsByTagName('i')[0].title;
}
if (txtValue.toUpperCase().indexOf(filter) > -1
|| title.toUpperCase().indexOf(filter) > -1) {
tr[i].style.display = '';
} else {
tr[i].style.display = 'none';
}
}
}
}