refactor: general code base refactor (#2950)

* refactor

* fix: bug in previous refactor

* chore: exclude phpcompat sniff due to bug in phpcompat

* fix: do not leak absolute paths

* refactor/fix: batch extensions checking, fix DOS issue
This commit is contained in:
Dag 2022-08-06 22:46:28 +02:00 committed by GitHub
parent b042412416
commit 2bbce8ebef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 679 additions and 827 deletions

View file

@ -28,23 +28,21 @@ class ConnectivityAction implements ActionInterface
public function __construct() public function __construct()
{ {
$this->bridgeFactory = new \BridgeFactory(); $this->bridgeFactory = new BridgeFactory();
} }
public function execute(array $request) public function execute(array $request)
{ {
if (!Debug::isEnabled()) { if (!Debug::isEnabled()) {
returnError('This action is only available in debug mode!', 400); throw new \Exception('This action is only available in debug mode!');
} }
if (!isset($request['bridge'])) { if (!isset($request['bridge'])) {
$this->returnEntryPage(); print render_template('connectivity.html.php');
return; return;
} }
$bridgeName = $request['bridge']; $bridgeClassName = $this->bridgeFactory->sanitizeBridgeName($request['bridge']);
$bridgeClassName = $this->bridgeFactory->sanitizeBridgeName($bridgeName);
if ($bridgeClassName === null) { if ($bridgeClassName === null) {
throw new \InvalidArgumentException('Bridge name invalid!'); throw new \InvalidArgumentException('Bridge name invalid!');
@ -53,28 +51,12 @@ class ConnectivityAction implements ActionInterface
$this->reportBridgeConnectivity($bridgeClassName); $this->reportBridgeConnectivity($bridgeClassName);
} }
/**
* 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 class-string<BridgeInterface> $bridgeClassName Name of the bridge to generate the report for
* @return void
*/
private function reportBridgeConnectivity($bridgeClassName) private function reportBridgeConnectivity($bridgeClassName)
{ {
if (!$this->bridgeFactory->isWhitelisted($bridgeClassName)) { if (!$this->bridgeFactory->isWhitelisted($bridgeClassName)) {
header('Content-Type: text/html'); throw new \Exception('Bridge is not whitelisted!');
returnServerError('Bridge is not whitelisted!');
} }
header('Content-Type: text/json');
$retVal = [ $retVal = [
'bridge' => $bridgeClassName, 'bridge' => $bridgeClassName,
'successful' => false, 'successful' => false,
@ -82,16 +64,9 @@ class ConnectivityAction implements ActionInterface
]; ];
$bridge = $this->bridgeFactory->create($bridgeClassName); $bridge = $this->bridgeFactory->create($bridgeClassName);
if ($bridge === false) {
echo json_encode($retVal);
return;
}
$curl_opts = [ $curl_opts = [
CURLOPT_CONNECTTIMEOUT => 5 CURLOPT_CONNECTTIMEOUT => 5
]; ];
try { try {
$reply = getContents($bridge::URI, [], $curl_opts, true); $reply = getContents($bridge::URI, [], $curl_opts, true);
@ -101,45 +76,11 @@ class ConnectivityAction implements ActionInterface
$retVal['http_code'] = 301; $retVal['http_code'] = 301;
} }
} }
} catch (Exception $e) { } catch (\Exception $e) {
$retVal['successful'] = false; $retVal['successful'] = false;
} }
echo json_encode($retVal); header('Content-Type: text/json');
} print 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

@ -16,13 +16,17 @@ class DetectAction implements ActionInterface
{ {
public function execute(array $request) public function execute(array $request)
{ {
$targetURL = $request['url'] $targetURL = $request['url'] ?? null;
or returnClientError('You must specify a url!'); $format = $request['format'] ?? null;
$format = $request['format'] if (!$targetURL) {
or returnClientError('You must specify a format!'); throw new \Exception('You must specify a url!');
}
if (!$format) {
throw new \Exception('You must specify a format!');
}
$bridgeFactory = new \BridgeFactory(); $bridgeFactory = new BridgeFactory();
foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) {
if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { if (!$bridgeFactory->isWhitelisted($bridgeClassName)) {
@ -31,10 +35,6 @@ class DetectAction implements ActionInterface
$bridge = $bridgeFactory->create($bridgeClassName); $bridge = $bridgeFactory->create($bridgeClassName);
if ($bridge === false) {
continue;
}
$bridgeParams = $bridge->detectParameters($targetURL); $bridgeParams = $bridge->detectParameters($targetURL);
if (is_null($bridgeParams)) { if (is_null($bridgeParams)) {
@ -45,9 +45,9 @@ class DetectAction implements ActionInterface
$bridgeParams['format'] = $format; $bridgeParams['format'] = $format;
header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301); header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301);
exit; return;
} }
returnClientError('No bridge found for given URL: ' . $targetURL); throw new \Exception('No bridge found for given URL: ' . $targetURL);
} }
} }

View file

@ -16,7 +16,7 @@ class DisplayAction implements ActionInterface
{ {
public function execute(array $request) public function execute(array $request)
{ {
$bridgeFactory = new \BridgeFactory(); $bridgeFactory = new BridgeFactory();
$bridgeClassName = null; $bridgeClassName = null;
if (isset($request['bridge'])) { if (isset($request['bridge'])) {
@ -27,16 +27,14 @@ class DisplayAction implements ActionInterface
throw new \InvalidArgumentException('Bridge name invalid!'); throw new \InvalidArgumentException('Bridge name invalid!');
} }
$format = $request['format'] $format = $request['format'] ?? null;
or returnClientError('You must specify a format!'); if (!$format) {
throw new \Exception('You must specify a format!');
// whitelist control }
if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { if (!$bridgeFactory->isWhitelisted($bridgeClassName)) {
throw new \Exception('This bridge is not whitelisted', 401); throw new \Exception('This bridge is not whitelisted');
die;
} }
// Data retrieval
$bridge = $bridgeFactory->create($bridgeClassName); $bridge = $bridgeFactory->create($bridgeClassName);
$bridge->loadConfiguration(); $bridge->loadConfiguration();
@ -47,14 +45,12 @@ class DisplayAction implements ActionInterface
define('NOPROXY', true); define('NOPROXY', true);
} }
// Cache timeout
$cache_timeout = -1;
if (array_key_exists('_cache_timeout', $request)) { if (array_key_exists('_cache_timeout', $request)) {
if (!CUSTOM_CACHE_TIMEOUT) { if (!CUSTOM_CACHE_TIMEOUT) {
unset($request['_cache_timeout']); unset($request['_cache_timeout']);
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($request); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($request);
header('Location: ' . $uri, true, 301); header('Location: ' . $uri, true, 301);
exit; return;
} }
$cache_timeout = filter_var($request['_cache_timeout'], FILTER_VALIDATE_INT); $cache_timeout = filter_var($request['_cache_timeout'], FILTER_VALIDATE_INT);
@ -93,7 +89,6 @@ class DisplayAction implements ActionInterface
) )
); );
// Initialize cache
$cacheFactory = new CacheFactory(); $cacheFactory = new CacheFactory();
$cache = $cacheFactory->create(); $cache = $cacheFactory->create();
@ -109,15 +104,17 @@ class DisplayAction implements ActionInterface
$mtime !== false $mtime !== false
&& (time() - $cache_timeout < $mtime) && (time() - $cache_timeout < $mtime)
&& !Debug::isEnabled() && !Debug::isEnabled()
) { // Load cached data ) {
// Load cached data
// Send "Not Modified" response if client supports it // Send "Not Modified" response if client supports it
// Implementation based on https://stackoverflow.com/a/10847262 // Implementation based on https://stackoverflow.com/a/10847262
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($mtime <= $stime) { // Cached data is older or same if ($mtime <= $stime) {
// Cached data is older or same
header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304); header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304);
exit; return;
} }
} }
@ -125,27 +122,24 @@ class DisplayAction implements ActionInterface
if (isset($cached['items']) && isset($cached['extraInfos'])) { if (isset($cached['items']) && isset($cached['extraInfos'])) {
foreach ($cached['items'] as $item) { foreach ($cached['items'] as $item) {
$items[] = new \FeedItem($item); $items[] = new FeedItem($item);
} }
$infos = $cached['extraInfos']; $infos = $cached['extraInfos'];
} }
} else { // Collect new data } else {
// Collect new data
try { try {
$bridge->setDatas($bridge_params); $bridge->setDatas($bridge_params);
$bridge->collectData(); $bridge->collectData();
$items = $bridge->getItems(); $items = $bridge->getItems();
// Transform "legacy" items to FeedItems if necessary.
// Remove this code when support for "legacy" items ends!
if (isset($items[0]) && is_array($items[0])) { if (isset($items[0]) && is_array($items[0])) {
$feedItems = []; $feedItems = [];
foreach ($items as $item) { foreach ($items as $item) {
$feedItems[] = new \FeedItem($item); $feedItems[] = new FeedItem($item);
} }
$items = $feedItems; $items = $feedItems;
} }
@ -158,18 +152,16 @@ class DisplayAction implements ActionInterface
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log($e); error_log($e);
if (logBridgeError($bridge::NAME, $e->getCode()) >= Configuration::getConfig('error', 'report_limit')) { $errorCount = logBridgeError($bridge::NAME, $e->getCode());
if ($errorCount >= Configuration::getConfig('error', 'report_limit')) {
if (Configuration::getConfig('error', 'output') === 'feed') { if (Configuration::getConfig('error', 'output') === 'feed') {
$item = new \FeedItem(); $item = new FeedItem();
// Create "new" error message every 24 hours // Create "new" error message every 24 hours
$request['_error_time'] = urlencode((int)(time() / 86400)); $request['_error_time'] = urlencode((int)(time() / 86400));
$message = sprintf( $message = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $request['_error_time']);
'Bridge returned error %s! (%s)',
$e->getCode(),
$request['_error_time']
);
$item->setTitle($message); $item->setTitle($message);
$item->setURI( $item->setURI(
@ -205,8 +197,8 @@ class DisplayAction implements ActionInterface
} }
$cache->saveData([ $cache->saveData([
'items' => array_map(function ($i) { 'items' => array_map(function (FeedItem $item) {
return $i->toArray(); return $item->toArray();
}, $items), }, $items),
'extraInfos' => $infos 'extraInfos' => $infos
]); ]);

View file

@ -16,27 +16,17 @@ class ListAction implements ActionInterface
{ {
public function execute(array $request) public function execute(array $request)
{ {
$list = new StdClass(); $list = new \stdClass();
$list->bridges = []; $list->bridges = [];
$list->total = 0; $list->total = 0;
$bridgeFactory = new \BridgeFactory(); $bridgeFactory = new BridgeFactory();
foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) {
$bridge = $bridgeFactory->create($bridgeClassName); $bridge = $bridgeFactory->create($bridgeClassName);
if ($bridge === false) { // Broken bridge, show as inactive
$list->bridges[$bridgeClassName] = [
'status' => 'inactive'
];
continue;
}
$status = $bridgeFactory->isWhitelisted($bridgeClassName) ? 'active' : 'inactive';
$list->bridges[$bridgeClassName] = [ $list->bridges[$bridgeClassName] = [
'status' => $status, 'status' => $bridgeFactory->isWhitelisted($bridgeClassName) ? 'active' : 'inactive',
'uri' => $bridge->getURI(), 'uri' => $bridge->getURI(),
'donationUri' => $bridge->getDonationURI(), 'donationUri' => $bridge->getDonationURI(),
'name' => $bridge->getName(), 'name' => $bridge->getName(),
@ -50,6 +40,6 @@ class ListAction implements ActionInterface
$list->total = count($list->bridges); $list->total = count($list->bridges);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($list, JSON_PRETTY_PRINT); print Json::encode($list);
} }
} }

View file

@ -43,6 +43,25 @@ class FDroidRepoBridge extends BridgeAbstract
// Stores repo information // Stores repo information
private $repo; private $repo;
public function collectData()
{
if (!extension_loaded('zip')) {
throw new \Exception('FDroidRepoBridge requires the php-zip extension');
}
$this->repo = $this->getRepo();
switch ($this->queriedContext) {
case 'Latest Updates':
$this->getAllUpdates();
break;
case 'Follow Package':
$this->getPackage($this->getInput('package'));
break;
default:
returnServerError('Unimplemented Context (collectData)');
}
}
public function getURI() public function getURI()
{ {
if (empty($this->queriedContext)) { if (empty($this->queriedContext)) {
@ -70,21 +89,6 @@ class FDroidRepoBridge extends BridgeAbstract
} }
} }
public function collectData()
{
$this->repo = $this->getRepo();
switch ($this->queriedContext) {
case 'Latest Updates':
$this->getAllUpdates();
break;
case 'Follow Package':
$this->getPackage($this->getInput('package'));
break;
default:
returnServerError('Unimplemented Context (collectData)');
}
}
private function getRepo() private function getRepo()
{ {
$url = $this->getURI(); $url = $this->getURI();
@ -95,9 +99,10 @@ class FDroidRepoBridge extends BridgeAbstract
file_put_contents($jar_loc, $jar); file_put_contents($jar_loc, $jar);
// JAR files are specially formatted ZIP files // JAR files are specially formatted ZIP files
$jar = new ZipArchive(); $jar = new \ZipArchive();
if ($jar->open($jar_loc) !== true) { if ($jar->open($jar_loc) !== true) {
returnServerError('Failed to extract archive'); unlink($jar_loc);
throw new \Exception('Failed to extract archive');
} }
// Get file pointer to the relevant JSON inside // Get file pointer to the relevant JSON inside
@ -109,6 +114,7 @@ class FDroidRepoBridge extends BridgeAbstract
$data = json_decode(stream_get_contents($fp), true); $data = json_decode(stream_get_contents($fp), true);
fclose($fp); fclose($fp);
$jar->close(); $jar->close();
unlink($jar_loc);
return $data; return $data;
} }

View file

@ -22,7 +22,9 @@ class PirateCommunityBridge extends BridgeAbstract
{ {
$parsed_url = parse_url($url); $parsed_url = parse_url($url);
if ($parsed_url['host'] !== 'raymanpc.com') { $host = $parsed_url['host'] ?? null;
if ($host !== 'raymanpc.com') {
return null; return null;
} }

View file

@ -71,7 +71,9 @@ class RedditBridge extends BridgeAbstract
{ {
$parsed_url = parse_url($url); $parsed_url = parse_url($url);
if ($parsed_url['host'] != 'www.reddit.com' && $parsed_url['host'] != 'old.reddit.com') { $host = $parsed_url['host'] ?? null;
if ($host != 'www.reddit.com' && $host != 'old.reddit.com') {
return null; return null;
} }

View file

@ -72,7 +72,7 @@ class WordPressBridge extends FeedExpander
} else { } else {
$article_image = $article_image->getAttribute('data-lazy-src'); $article_image = $article_image->getAttribute('data-lazy-src');
} }
$mime_type = getMimeType($article_image); $mime_type = parse_mime_type($article_image);
if (strpos($mime_type, 'image') === false) { if (strpos($mime_type, 'image') === false) {
$article_image .= '#.image'; // force image $article_image .= '#.image'; // force image
} }

View file

@ -1,8 +1,5 @@
<?php <?php
/**
* Cache with file system
*/
class FileCache implements CacheInterface class FileCache implements CacheInterface
{ {
protected $path; protected $path;
@ -11,10 +8,7 @@ class FileCache implements CacheInterface
public function __construct() public function __construct()
{ {
if (!is_writable(PATH_CACHE)) { if (!is_writable(PATH_CACHE)) {
returnServerError( throw new \Exception('The cache folder is not writeable');
'RSS-Bridge does not have write permissions for '
. PATH_CACHE . '!'
);
} }
} }
@ -23,20 +17,15 @@ class FileCache implements CacheInterface
if (file_exists($this->getCacheFile())) { if (file_exists($this->getCacheFile())) {
return unserialize(file_get_contents($this->getCacheFile())); return unserialize(file_get_contents($this->getCacheFile()));
} }
return null; return null;
} }
public function saveData($data) public function saveData($data)
{ {
// Notice: We use plain serialize() here to reduce memory footprint on
// large input data.
$writeStream = file_put_contents($this->getCacheFile(), serialize($data)); $writeStream = file_put_contents($this->getCacheFile(), serialize($data));
if ($writeStream === false) { if ($writeStream === false) {
throw new \Exception('Cannot write the cache... Do you have the right permissions ?'); throw new \Exception('Cannot write the cache... Do you have the right permissions ?');
} }
return $this; return $this;
} }
@ -46,7 +35,10 @@ class FileCache implements CacheInterface
clearstatcache(false, $cacheFile); clearstatcache(false, $cacheFile);
if (file_exists($cacheFile)) { if (file_exists($cacheFile)) {
$time = filemtime($cacheFile); $time = filemtime($cacheFile);
return ($time !== false) ? $time : null; if ($time !== false) {
return $time;
}
return null;
} }
return null; return null;
@ -55,28 +47,25 @@ class FileCache implements CacheInterface
public function purgeCache($seconds) public function purgeCache($seconds)
{ {
$cachePath = $this->getPath(); $cachePath = $this->getPath();
if (file_exists($cachePath)) { if (!file_exists($cachePath)) {
$cacheIterator = new RecursiveIteratorIterator( return;
new RecursiveDirectoryIterator($cachePath), }
RecursiveIteratorIterator::CHILD_FIRST $cacheIterator = new \RecursiveIteratorIterator(
); new \RecursiveDirectoryIterator($cachePath),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($cacheIterator as $cacheFile) { foreach ($cacheIterator as $cacheFile) {
if (in_array($cacheFile->getBasename(), ['.', '..', '.gitkeep'])) { if (in_array($cacheFile->getBasename(), ['.', '..', '.gitkeep'])) {
continue; continue;
} elseif ($cacheFile->isFile()) { } elseif ($cacheFile->isFile()) {
if (filemtime($cacheFile->getPathname()) < time() - $seconds) { if (filemtime($cacheFile->getPathname()) < time() - $seconds) {
unlink($cacheFile->getPathname()); unlink($cacheFile->getPathname());
}
} }
} }
} }
} }
/**
* Set scope
* @return self
*/
public function setScope($scope) public function setScope($scope)
{ {
if (is_null($scope) || !is_string($scope)) { if (is_null($scope) || !is_string($scope)) {
@ -88,10 +77,6 @@ class FileCache implements CacheInterface
return $this; return $this;
} }
/**
* Set key
* @return self
*/
public function setKey($key) public function setKey($key)
{ {
if (!empty($key) && is_array($key)) { if (!empty($key) && is_array($key)) {
@ -107,10 +92,6 @@ class FileCache implements CacheInterface
return $this; return $this;
} }
/**
* Return cache path (and create if not exist)
* @return string Cache path
*/
private function getPath() private function getPath()
{ {
if (is_null($this->path)) { if (is_null($this->path)) {
@ -119,26 +100,18 @@ class FileCache implements CacheInterface
if (!is_dir($this->path)) { if (!is_dir($this->path)) {
if (mkdir($this->path, 0755, true) !== true) { if (mkdir($this->path, 0755, true) !== true) {
throw new \Exception('Unable to create ' . $this->path); throw new \Exception('mkdir: Unable to create file cache folder');
} }
} }
return $this->path; return $this->path;
} }
/**
* Get the file name use for cache store
* @return string Path to the file cache
*/
private function getCacheFile() private function getCacheFile()
{ {
return $this->getPath() . $this->getCacheName(); return $this->getPath() . $this->getCacheName();
} }
/**
* Determines file name for store the cache
* return string
*/
private function getCacheName() private function getCacheName()
{ {
if (is_null($this->key)) { if (is_null($this->key)) {

View file

@ -12,29 +12,33 @@ class MemcachedCache implements CacheInterface
public function __construct() public function __construct()
{ {
if (!extension_loaded('memcached')) { if (!extension_loaded('memcached')) {
returnServerError('"memcached" extension not loaded. Please check "php.ini"'); throw new \Exception('"memcached" extension not loaded. Please check "php.ini"');
} }
$section = 'MemcachedCache'; $section = 'MemcachedCache';
$host = Configuration::getConfig($section, 'host'); $host = Configuration::getConfig($section, 'host');
$port = Configuration::getConfig($section, 'port'); $port = Configuration::getConfig($section, 'port');
if (empty($host) && empty($port)) { if (empty($host) && empty($port)) {
returnServerError('Configuration for ' . $section . ' missing. Please check your ' . FILE_CONFIG); throw new \Exception('Configuration for ' . $section . ' missing. Please check your ' . FILE_CONFIG);
} elseif (empty($host)) { }
returnServerError('"host" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); if (empty($host)) {
} elseif (empty($port)) { throw new \Exception('"host" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG);
returnServerError('"port" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); }
} elseif (!ctype_digit($port)) { if (empty($port)) {
returnServerError('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); throw new \Exception('"port" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG);
}
if (!ctype_digit($port)) {
throw new \Exception('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG);
} }
$port = intval($port); $port = intval($port);
if ($port < 1 || $port > 65535) { if ($port < 1 || $port > 65535) {
returnServerError('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); throw new \Exception('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG);
} }
$conn = new Memcached(); $conn = new \Memcached();
$conn->addServer($host, $port) or returnServerError('Could not connect to memcached server'); $conn->addServer($host, $port) or returnServerError('Could not connect to memcached server');
$this->conn = $conn; $this->conn = $conn;
} }
@ -64,7 +68,7 @@ class MemcachedCache implements CacheInterface
$result = $this->conn->set($this->getCacheKey(), $object_to_save, $this->expiration); $result = $this->conn->set($this->getCacheKey(), $object_to_save, $this->expiration);
if ($result === false) { if ($result === false) {
returnServerError('Cannot write the cache to memcached server'); throw new \Exception('Cannot write the cache to memcached server');
} }
$this->time = $time; $this->time = $time;
@ -87,20 +91,12 @@ class MemcachedCache implements CacheInterface
$this->expiration = $duration; $this->expiration = $duration;
} }
/**
* Set scope
* @return self
*/
public function setScope($scope) public function setScope($scope)
{ {
$this->scope = $scope; $this->scope = $scope;
return $this; return $this;
} }
/**
* Set key
* @return self
*/
public function setKey($key) public function setKey($key)
{ {
if (!empty($key) && is_array($key)) { if (!empty($key) && is_array($key)) {
@ -119,7 +115,7 @@ class MemcachedCache implements CacheInterface
private function getCacheKey() private function getCacheKey()
{ {
if (is_null($this->key)) { if (is_null($this->key)) {
returnServerError('Call "setKey" first!'); throw new \Exception('Call "setKey" first!');
} }
return 'rss_bridge_cache_' . hash('md5', $this->scope . $this->key . 'A'); return 'rss_bridge_cache_' . hash('md5', $this->scope . $this->key . 'A');

View file

@ -13,38 +13,32 @@ class SQLiteCache implements CacheInterface
public function __construct() public function __construct()
{ {
if (!extension_loaded('sqlite3')) { if (!extension_loaded('sqlite3')) {
print render('error.html.php', ['message' => '"sqlite3" extension not loaded. Please check "php.ini"']); throw new \Exception('"sqlite3" extension not loaded. Please check "php.ini"');
exit;
} }
if (!is_writable(PATH_CACHE)) { if (!is_writable(PATH_CACHE)) {
returnServerError( throw new \Exception('The cache folder is not writable');
'RSS-Bridge does not have write permissions for '
. PATH_CACHE . '!'
);
} }
$section = 'SQLiteCache'; $section = 'SQLiteCache';
$file = Configuration::getConfig($section, 'file'); $file = Configuration::getConfig($section, 'file');
if (empty($file)) { if (empty($file)) {
$message = sprintf('Configuration for %s missing. Please check your %s', $section, FILE_CONFIG); throw new \Exception(sprintf('Configuration for %s missing.', $section));
print render('error.html.php', ['message' => $message]);
exit;
} }
if (dirname($file) == '.') { if (dirname($file) == '.') {
$file = PATH_CACHE . $file; $file = PATH_CACHE . $file;
} elseif (!is_dir(dirname($file))) { } elseif (!is_dir(dirname($file))) {
$message = sprintf('Invalid configuration for %s. Please check your %s', $section, FILE_CONFIG); throw new \Exception(sprintf('Invalid configuration for %s', $section));
print render('error.html.php', ['message' => $message]);
exit;
} }
if (!is_file($file)) { if (!is_file($file)) {
$this->db = new SQLite3($file); // The instantiation creates the file
$this->db = new \SQLite3($file);
$this->db->enableExceptions(true); $this->db->enableExceptions(true);
$this->db->exec("CREATE TABLE storage ('key' BLOB PRIMARY KEY, 'value' BLOB, 'updated' INTEGER)"); $this->db->exec("CREATE TABLE storage ('key' BLOB PRIMARY KEY, 'value' BLOB, 'updated' INTEGER)");
} else { } else {
$this->db = new SQLite3($file); $this->db = new \SQLite3($file);
$this->db->enableExceptions(true); $this->db->enableExceptions(true);
} }
$this->db->busyTimeout(5000); $this->db->busyTimeout(5000);
@ -55,8 +49,8 @@ class SQLiteCache implements CacheInterface
$Qselect = $this->db->prepare('SELECT value FROM storage WHERE key = :key'); $Qselect = $this->db->prepare('SELECT value FROM storage WHERE key = :key');
$Qselect->bindValue(':key', $this->getCacheKey()); $Qselect->bindValue(':key', $this->getCacheKey());
$result = $Qselect->execute(); $result = $Qselect->execute();
if ($result instanceof SQLite3Result) { if ($result instanceof \SQLite3Result) {
$data = $result->fetchArray(SQLITE3_ASSOC); $data = $result->fetchArray(\SQLITE3_ASSOC);
if (isset($data['value'])) { if (isset($data['value'])) {
return unserialize($data['value']); return unserialize($data['value']);
} }
@ -81,7 +75,7 @@ class SQLiteCache implements CacheInterface
$Qselect = $this->db->prepare('SELECT updated FROM storage WHERE key = :key'); $Qselect = $this->db->prepare('SELECT updated FROM storage WHERE key = :key');
$Qselect->bindValue(':key', $this->getCacheKey()); $Qselect->bindValue(':key', $this->getCacheKey());
$result = $Qselect->execute(); $result = $Qselect->execute();
if ($result instanceof SQLite3Result) { if ($result instanceof \SQLite3Result) {
$data = $result->fetchArray(SQLITE3_ASSOC); $data = $result->fetchArray(SQLITE3_ASSOC);
if (isset($data['updated'])) { if (isset($data['updated'])) {
return $data['updated']; return $data['updated'];
@ -98,10 +92,6 @@ class SQLiteCache implements CacheInterface
$Qdelete->execute(); $Qdelete->execute();
} }
/**
* Set scope
* @return self
*/
public function setScope($scope) public function setScope($scope)
{ {
if (is_null($scope) || !is_string($scope)) { if (is_null($scope) || !is_string($scope)) {
@ -112,10 +102,6 @@ class SQLiteCache implements CacheInterface
return $this; return $this;
} }
/**
* Set key
* @return self
*/
public function setKey($key) public function setKey($key)
{ {
if (!empty($key) && is_array($key)) { if (!empty($key) && is_array($key)) {
@ -131,8 +117,6 @@ class SQLiteCache implements CacheInterface
return $this; return $this;
} }
////////////////////////////////////////////////////////////////////////////
private function getCacheKey() private function getCacheKey()
{ {
if (is_null($this->key)) { if (is_null($this->key)) {

View file

@ -37,6 +37,7 @@
"suggest": { "suggest": {
"ext-memcached": "Allows to use memcached as cache type", "ext-memcached": "Allows to use memcached as cache type",
"ext-sqlite3": "Allows to use an SQLite database for caching", "ext-sqlite3": "Allows to use an SQLite database for caching",
"ext-zip": "Required for FDroidRepoBridge",
"ext-dom": "Allows to use some bridges based on XPath expressions" "ext-dom": "Allows to use some bridges based on XPath expressions"
}, },
"autoload-dev": { "autoload-dev": {

View file

@ -65,12 +65,10 @@ by_bridge = false
; false = disabled (default) ; false = disabled (default)
enable = false enable = false
; The username for authentication. Insert this name when prompted for login. username = "admin"
username = ""
; The password for authentication. Insert this password when prompted for login. ; This default password is public knowledge. Replace it.
; Use a strong password to prevent others from guessing your login! password = "7afbf648a369b261"
password = ""
[error] [error]

View file

@ -82,7 +82,7 @@ getExtraInfos(): array
The `getMimeType` function returns the expected [MIME type](https://en.wikipedia.org/wiki/Media_type#Common_examples) of the format's output. The `getMimeType` function returns the expected [MIME type](https://en.wikipedia.org/wiki/Media_type#Common_examples) of the format's output.
```PHP ```PHP
getMimeType(): string parse_mime_type(): string
``` ```
# Template # Template

View file

@ -9,5 +9,5 @@ The `FormatAbstract` class implements the [`FormatInterface`](../08_Format_API/0
The `sanitizeHtml` function receives an HTML formatted string and returns the string with disabled `<script>`, `<iframe>` and `<link>` tags. The `sanitizeHtml` function receives an HTML formatted string and returns the string with disabled `<script>`, `<iframe>` and `<link>` tags.
```PHP ```PHP
sanitizeHtml(string $html): string sanitize_html(string $html): string
``` ```

View file

@ -18,17 +18,21 @@ class AtomFormat extends FormatAbstract
public function stringify() public function stringify()
{ {
$urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; $https = $_SERVER['HTTPS'] ?? null;
$urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; $urlPrefix = $https === 'on' ? 'https://' : 'http://';
$urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; $urlHost = $_SERVER['HTTP_HOST'] ?? '';
$urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; $urlRequest = $_SERVER['REQUEST_URI'] ?? '';
$feedUrl = $urlPrefix . $urlHost . $urlRequest; $feedUrl = $urlPrefix . $urlHost . $urlRequest;
$extraInfos = $this->getExtraInfos(); $extraInfos = $this->getExtraInfos();
$uri = !empty($extraInfos['uri']) ? $extraInfos['uri'] : REPOSITORY; if (empty($extraInfos['uri'])) {
$uri = REPOSITORY;
} else {
$uri = $extraInfos['uri'];
}
$document = new DomDocument('1.0', $this->getCharset()); $document = new \DomDocument('1.0', $this->getCharset());
$document->formatOutput = true; $document->formatOutput = true;
$feed = $document->createElementNS(self::ATOM_NS, 'feed'); $feed = $document->createElementNS(self::ATOM_NS, 'feed');
$document->appendChild($feed); $document->appendChild($feed);
@ -44,10 +48,10 @@ class AtomFormat extends FormatAbstract
$id->appendChild($document->createTextNode($feedUrl)); $id->appendChild($document->createTextNode($feedUrl));
$uriparts = parse_url($uri); $uriparts = parse_url($uri);
if (!empty($extraInfos['icon'])) { if (empty($extraInfos['icon'])) {
$iconUrl = $extraInfos['icon'];
} else {
$iconUrl = $uriparts['scheme'] . '://' . $uriparts['host'] . '/favicon.ico'; $iconUrl = $uriparts['scheme'] . '://' . $uriparts['host'] . '/favicon.ico';
} else {
$iconUrl = $extraInfos['icon'];
} }
$icon = $document->createElement('icon'); $icon = $document->createElement('icon');
$feed->appendChild($icon); $feed->appendChild($icon);
@ -94,11 +98,13 @@ class AtomFormat extends FormatAbstract
$entryID = 'urn:sha1:' . $item->getUid(); $entryID = 'urn:sha1:' . $item->getUid();
} }
if (empty($entryID)) { // Fallback to provided URI if (empty($entryID)) {
// Fallback to provided URI
$entryID = $entryUri; $entryID = $entryUri;
} }
if (empty($entryID)) { // Fallback to title and content if (empty($entryID)) {
// Fallback to title and content
$entryID = 'urn:sha1:' . hash('sha1', $entryTitle . $entryContent); $entryID = 'urn:sha1:' . hash('sha1', $entryTitle . $entryContent);
} }
@ -126,7 +132,7 @@ class AtomFormat extends FormatAbstract
$title->setAttribute('type', 'html'); $title->setAttribute('type', 'html');
$title->appendChild($document->createTextNode($entryTitle)); $title->appendChild($document->createTextNode($entryTitle));
$entryTimestamp = gmdate(DATE_ATOM, $entryTimestamp); $entryTimestamp = gmdate(\DATE_ATOM, $entryTimestamp);
$published = $document->createElement('published'); $published = $document->createElement('published');
$entry->appendChild($published); $entry->appendChild($published);
$published->appendChild($document->createTextNode($entryTimestamp)); $published->appendChild($document->createTextNode($entryTimestamp));
@ -157,14 +163,14 @@ class AtomFormat extends FormatAbstract
$content = $document->createElement('content'); $content = $document->createElement('content');
$content->setAttribute('type', 'html'); $content->setAttribute('type', 'html');
$content->appendChild($document->createTextNode($this->sanitizeHtml($entryContent))); $content->appendChild($document->createTextNode(sanitize_html($entryContent)));
$entry->appendChild($content); $entry->appendChild($content);
foreach ($item->getEnclosures() as $enclosure) { foreach ($item->getEnclosures() as $enclosure) {
$entryEnclosure = $document->createElement('link'); $entryEnclosure = $document->createElement('link');
$entry->appendChild($entryEnclosure); $entry->appendChild($entryEnclosure);
$entryEnclosure->setAttribute('rel', 'enclosure'); $entryEnclosure->setAttribute('rel', 'enclosure');
$entryEnclosure->setAttribute('type', getMimeType($enclosure)); $entryEnclosure->setAttribute('type', parse_mime_type($enclosure));
$entryEnclosure->setAttribute('href', $enclosure); $entryEnclosure->setAttribute('href', $enclosure);
} }

View file

@ -7,9 +7,9 @@ class HtmlFormat extends FormatAbstract
public function stringify() public function stringify()
{ {
$extraInfos = $this->getExtraInfos(); $extraInfos = $this->getExtraInfos();
$title = htmlspecialchars($extraInfos['name']); $title = e($extraInfos['name']);
$uri = htmlspecialchars($extraInfos['uri']); $uri = e($extraInfos['uri']);
$donationUri = htmlspecialchars($extraInfos['donationUri']); $donationUri = e($extraInfos['donationUri']);
$donationsAllowed = Configuration::getConfig('admin', 'donations'); $donationsAllowed = Configuration::getConfig('admin', 'donations');
// Dynamically build buttons for all formats (except HTML) // Dynamically build buttons for all formats (except HTML)
@ -19,32 +19,39 @@ class HtmlFormat extends FormatAbstract
$links = ''; $links = '';
foreach ($formatFactory->getFormatNames() as $format) { foreach ($formatFactory->getFormatNames() as $format) {
if (strcasecmp($format, 'HTML') === 0) { if ($format === 'Html') {
continue; continue;
} }
$query = str_ireplace('format=Html', 'format=' . $format, htmlentities($_SERVER['QUERY_STRING'])); $queryString = $_SERVER['QUERY_STRING'];
$buttons .= $this->buildButton($format, $query) . PHP_EOL; $query = str_ireplace('format=Html', 'format=' . $format, htmlentities($queryString));
$buttons .= sprintf('<a href="./?%s"><button class="rss-feed">%s</button></a>', $query, $format) . "\n";
$mime = $formatFactory->create($format)->getMimeType(); $mime = $formatFactory->create($format)->getMimeType();
$links .= $this->buildLink($format, $query, $mime) . PHP_EOL; $links .= sprintf('<link href="./?%s" title="%s" rel="alternate" type="%s">', $query, $format, $mime) . "\n";
} }
if ($donationUri !== '' && $donationsAllowed) { if ($donationUri !== '' && $donationsAllowed) {
$buttons .= '<a href="' $str = sprintf(
. $donationUri '<a href="%s" target="_blank"><button class="highlight">Donate to maintainer</button></a>',
. '" target="_blank"><button class="highlight">Donate to maintainer</button></a>' $donationUri
. PHP_EOL; );
$links .= '<link href="' $buttons .= $str;
. $donationUri $str1 = sprintf(
. ' target="_blank"" title="Donate to Maintainer" rel="alternate">' '<link href="%s target="_blank"" title="Donate to Maintainer" rel="alternate">',
. PHP_EOL; $donationUri
);
$links .= $str1;
} }
$entries = ''; $entries = '';
foreach ($this->getItems() as $item) { foreach ($this->getItems() as $item) {
$entryAuthor = $item->getAuthor() ? '<br /><p class="author">by: ' . $item->getAuthor() . '</p>' : ''; if ($item->getAuthor()) {
$entryTitle = $this->sanitizeHtml(strip_tags($item->getTitle())); $entryAuthor = sprintf('<br /><p class="author">by: %s</p>', $item->getAuthor());
} else {
$entryAuthor = '';
}
$entryTitle = sanitize_html(strip_tags($item->getTitle()));
$entryUri = $item->getURI() ?: $uri; $entryUri = $item->getURI() ?: $uri;
$entryDate = ''; $entryDate = '';
@ -58,9 +65,8 @@ class HtmlFormat extends FormatAbstract
$entryContent = ''; $entryContent = '';
if ($item->getContent()) { if ($item->getContent()) {
$entryContent = '<div class="content">' $str2 = sprintf('<div class="content">%s</div>', sanitize_html($item->getContent()));
. $this->sanitizeHtml($item->getContent()) $entryContent = $str2;
. '</div>';
} }
$entryEnclosures = ''; $entryEnclosures = '';
@ -69,7 +75,7 @@ class HtmlFormat extends FormatAbstract
foreach ($item->getEnclosures() as $enclosure) { foreach ($item->getEnclosures() as $enclosure) {
$template = '<li class="enclosure"><a href="%s" rel="noopener noreferrer nofollow">%s</a></li>'; $template = '<li class="enclosure"><a href="%s" rel="noopener noreferrer nofollow">%s</a></li>';
$url = $this->sanitizeHtml($enclosure); $url = sanitize_html($enclosure);
$anchorText = substr($url, strrpos($url, '/') + 1); $anchorText = substr($url, strrpos($url, '/') + 1);
$entryEnclosures .= sprintf($template, $url, $anchorText); $entryEnclosures .= sprintf($template, $url, $anchorText);
@ -84,7 +90,7 @@ class HtmlFormat extends FormatAbstract
foreach ($item->getCategories() as $category) { foreach ($item->getCategories() as $category) {
$entryCategories .= '<li class="category">' $entryCategories .= '<li class="category">'
. $this->sanitizeHtml($category) . sanitize_html($category)
. '</li>'; . '</li>';
} }
@ -106,8 +112,6 @@ EOD;
} }
$charset = $this->getCharset(); $charset = $this->getCharset();
/* Data are prepared, now let's begin the "MAGIE !!!" */
$toReturn = <<<EOD $toReturn = <<<EOD
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -136,19 +140,4 @@ EOD;
$toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8'); $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
return $toReturn; return $toReturn;
} }
private function buildButton($format, $query)
{
return <<<EOD
<a href="./?{$query}"><button class="rss-feed">{$format}</button></a>
EOD;
}
private function buildLink($format, $query, $mime)
{
return <<<EOD
<link href="./?{$query}" title="{$format}" rel="alternate" type="{$mime}">
EOD;
}
} }

View file

@ -25,10 +25,10 @@ class JsonFormat extends FormatAbstract
public function stringify() public function stringify()
{ {
$urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; $https = $_SERVER['HTTPS'] ?? null;
$urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; $urlPrefix = $https === 'on' ? 'https://' : 'http://';
$urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; $urlHost = $_SERVER['HTTP_HOST'] ?? '';
$urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; $urlRequest = $_SERVER['REQUEST_URI'] ?? '';
$extraInfos = $this->getExtraInfos(); $extraInfos = $this->getExtraInfos();
@ -52,7 +52,7 @@ class JsonFormat extends FormatAbstract
$entryTitle = $item->getTitle(); $entryTitle = $item->getTitle();
$entryUri = $item->getURI(); $entryUri = $item->getURI();
$entryTimestamp = $item->getTimestamp(); $entryTimestamp = $item->getTimestamp();
$entryContent = $item->getContent() ? $this->sanitizeHtml($item->getContent()) : ''; $entryContent = $item->getContent() ? sanitize_html($item->getContent()) : '';
$entryEnclosures = $item->getEnclosures(); $entryEnclosures = $item->getEnclosures();
$entryCategories = $item->getCategories(); $entryCategories = $item->getCategories();
@ -76,13 +76,13 @@ class JsonFormat extends FormatAbstract
]; ];
} }
if (!empty($entryTimestamp)) { if (!empty($entryTimestamp)) {
$entry['date_modified'] = gmdate(DATE_ATOM, $entryTimestamp); $entry['date_modified'] = gmdate(\DATE_ATOM, $entryTimestamp);
} }
if (!empty($entryUri)) { if (!empty($entryUri)) {
$entry['url'] = $entryUri; $entry['url'] = $entryUri;
} }
if (!empty($entryContent)) { if (!empty($entryContent)) {
if ($this->isHTML($entryContent)) { if (is_html($entryContent)) {
$entry['content_html'] = $entryContent; $entry['content_html'] = $entryContent;
} else { } else {
$entry['content_text'] = $entryContent; $entry['content_text'] = $entryContent;
@ -93,7 +93,7 @@ class JsonFormat extends FormatAbstract
foreach ($entryEnclosures as $enclosure) { foreach ($entryEnclosures as $enclosure) {
$entry['attachments'][] = [ $entry['attachments'][] = [
'url' => $enclosure, 'url' => $enclosure,
'mime_type' => getMimeType($enclosure) 'mime_type' => parse_mime_type($enclosure)
]; ];
} }
} }
@ -121,13 +121,8 @@ class JsonFormat extends FormatAbstract
* So consider this a hack. * So consider this a hack.
* Switch to JSON_INVALID_UTF8_IGNORE when PHP 7.2 is the latest platform requirement. * Switch to JSON_INVALID_UTF8_IGNORE when PHP 7.2 is the latest platform requirement.
*/ */
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_PARTIAL_OUTPUT_ON_ERROR); $json = json_encode($data, \JSON_PRETTY_PRINT | \JSON_PARTIAL_OUTPUT_ON_ERROR);
return $json; return $json;
} }
private function isHTML($text)
{
return (strlen(strip_tags($text)) != strlen($text));
}
} }

View file

@ -33,22 +33,28 @@ class MrssFormat extends FormatAbstract
protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; protected const MRSS_NS = 'http://search.yahoo.com/mrss/';
const ALLOWED_IMAGE_EXT = [ const ALLOWED_IMAGE_EXT = [
'.gif', '.jpg', '.png' '.gif',
'.jpg',
'.png',
]; ];
public function stringify() public function stringify()
{ {
$urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; $https = $_SERVER['HTTPS'] ?? null;
$urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; $urlPrefix = $https == 'on' ? 'https://' : 'http://';
$urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; $urlHost = $_SERVER['HTTP_HOST'] ?? '';
$urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; $urlRequest = $_SERVER['REQUEST_URI'] ?? '';
$feedUrl = $urlPrefix . $urlHost . $urlRequest; $feedUrl = $urlPrefix . $urlHost . $urlRequest;
$extraInfos = $this->getExtraInfos(); $extraInfos = $this->getExtraInfos();
$uri = !empty($extraInfos['uri']) ? $extraInfos['uri'] : REPOSITORY; if (empty($extraInfos['uri'])) {
$uri = REPOSITORY;
} else {
$uri = $extraInfos['uri'];
}
$document = new DomDocument('1.0', $this->getCharset()); $document = new \DomDocument('1.0', $this->getCharset());
$document->formatOutput = true; $document->formatOutput = true;
$feed = $document->createElement('rss'); $feed = $document->createElement('rss');
$document->appendChild($feed); $document->appendChild($feed);
@ -103,16 +109,18 @@ class MrssFormat extends FormatAbstract
$itemTimestamp = $item->getTimestamp(); $itemTimestamp = $item->getTimestamp();
$itemTitle = $item->getTitle(); $itemTitle = $item->getTitle();
$itemUri = $item->getURI(); $itemUri = $item->getURI();
$itemContent = $item->getContent() ? $this->sanitizeHtml($item->getContent()) : ''; $itemContent = $item->getContent() ? sanitize_html($item->getContent()) : '';
$entryID = $item->getUid(); $entryID = $item->getUid();
$isPermaLink = 'false'; $isPermaLink = 'false';
if (empty($entryID) && !empty($itemUri)) { // Fallback to provided URI if (empty($entryID) && !empty($itemUri)) {
// Fallback to provided URI
$entryID = $itemUri; $entryID = $itemUri;
$isPermaLink = 'true'; $isPermaLink = 'true';
} }
if (empty($entryID)) { // Fallback to title and content if (empty($entryID)) {
// Fallback to title and content
$entryID = hash('sha1', $itemTitle . $itemContent); $entryID = hash('sha1', $itemTitle . $itemContent);
} }
@ -139,7 +147,7 @@ class MrssFormat extends FormatAbstract
if (!empty($itemTimestamp)) { if (!empty($itemTimestamp)) {
$entryPublished = $document->createElement('pubDate'); $entryPublished = $document->createElement('pubDate');
$entry->appendChild($entryPublished); $entry->appendChild($entryPublished);
$entryPublished->appendChild($document->createTextNode(gmdate(DATE_RFC2822, $itemTimestamp))); $entryPublished->appendChild($document->createTextNode(gmdate(\DATE_RFC2822, $itemTimestamp)));
} }
if (!empty($itemContent)) { if (!empty($itemContent)) {
@ -152,10 +160,9 @@ class MrssFormat extends FormatAbstract
$entryEnclosure = $document->createElementNS(self::MRSS_NS, 'content'); $entryEnclosure = $document->createElementNS(self::MRSS_NS, 'content');
$entry->appendChild($entryEnclosure); $entry->appendChild($entryEnclosure);
$entryEnclosure->setAttribute('url', $enclosure); $entryEnclosure->setAttribute('url', $enclosure);
$entryEnclosure->setAttribute('type', getMimeType($enclosure)); $entryEnclosure->setAttribute('type', parse_mime_type($enclosure));
} }
$entryCategories = '';
foreach ($item->getCategories() as $category) { foreach ($item->getCategories() as $category) {
$entryCategory = $document->createElement('category'); $entryCategory = $document->createElement('category');
$entry->appendChild($entryCategory); $entry->appendChild($entryCategory);

View file

@ -1,9 +1,5 @@
<?php <?php
/**
* Plaintext
* Returns $this->items as raw php data.
*/
class PlaintextFormat extends FormatAbstract class PlaintextFormat extends FormatAbstract
{ {
const MIME_TYPE = 'text/plain'; const MIME_TYPE = 'text/plain';

View file

@ -2,16 +2,19 @@
require_once __DIR__ . '/lib/rssbridge.php'; require_once __DIR__ . '/lib/rssbridge.php';
Configuration::verifyInstallation();
Configuration::loadConfiguration();
date_default_timezone_set(Configuration::getConfig('system', 'timezone'));
define('CUSTOM_CACHE_TIMEOUT', Configuration::getConfig('cache', 'custom_timeout'));
Authentication::showPromptIfNeeded();
try { try {
Configuration::verifyInstallation();
Configuration::loadConfiguration();
date_default_timezone_set(Configuration::getConfig('system', 'timezone'));
define('CUSTOM_CACHE_TIMEOUT', Configuration::getConfig('cache', 'custom_timeout'));
$authenticationMiddleware = new AuthenticationMiddleware();
if (Configuration::getConfig('authentication', 'enable')) {
$authenticationMiddleware();
}
if (isset($argv)) { if (isset($argv)) {
parse_str(implode('&', array_slice($argv, 1)), $cliArgs); parse_str(implode('&', array_slice($argv, 1)), $cliArgs);
$request = $cliArgs; $request = $cliArgs;
@ -20,12 +23,7 @@ try {
} }
foreach ($request as $key => $value) { foreach ($request as $key => $value) {
if (! is_string($value)) { if (! is_string($value)) {
http_response_code(400); throw new \Exception("Query parameter \"$key\" is not a string.");
print render('error.html.php', [
'title' => '400 Bad Request',
'message' => "Query parameter \"$key\" is not a string.",
]);
exit(1);
} }
} }

View file

@ -1,89 +0,0 @@
<?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
*/
/**
* Authentication module for RSS-Bridge.
*
* This class implements an authentication module for RSS-Bridge, utilizing the
* HTTP authentication capabilities of PHP.
*
* _Notice_: Authentication via HTTP does not prevent users from accessing files
* on your server. If your server supports `.htaccess`, you should globally restrict
* access to files instead.
*
* @link https://php.net/manual/en/features.http-auth.php HTTP authentication with PHP
* @link https://httpd.apache.org/docs/2.4/howto/htaccess.html Apache HTTP Server
* Tutorial: .htaccess files
*
* @todo Configuration parameters should be stored internally instead of accessing
* the configuration class directly.
* @todo Add functions to detect if a user is authenticated or not. This can be
* utilized for limiting access to authorized users only.
*/
class Authentication
{
/**
* Throw an exception when trying to create a new instance of this class.
* Use {@see Authentication::showPromptIfNeeded()} instead!
*
* @throws \LogicException if called.
*/
public function __construct()
{
throw new \LogicException('Use ' . __CLASS__ . '::showPromptIfNeeded()!');
}
/**
* Requests the user for login credentials if necessary.
*
* Responds to an authentication request or returns the `WWW-Authenticate`
* header if authentication is enabled in the configuration of RSS-Bridge
* (`[authentication] enable = true`).
*
* @return void
*/
public static function showPromptIfNeeded()
{
if (Configuration::getConfig('authentication', 'enable') === true) {
if (!Authentication::verifyPrompt()) {
header('WWW-Authenticate: Basic realm="RSS-Bridge"', true, 401);
$message = 'Please authenticate in order to access this instance !';
print $message;
exit;
}
}
}
/**
* Verifies if an authentication request was received and compares the
* provided username and password to the configuration of RSS-Bridge
* (`[authentication] username` and `[authentication] password`).
*
* @return bool True if authentication succeeded.
*/
public static function verifyPrompt()
{
if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
if (
Configuration::getConfig('authentication', 'username') === $_SERVER['PHP_AUTH_USER']
&& Configuration::getConfig('authentication', 'password') === $_SERVER['PHP_AUTH_PW']
) {
return true;
} else {
error_log('[RSS-Bridge] Failed authentication attempt from ' . $_SERVER['REMOTE_ADDR']);
}
}
return false;
}
}

View file

@ -0,0 +1,42 @@
<?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
*/
final class AuthenticationMiddleware
{
public function __invoke(): void
{
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
$password = $_SERVER['PHP_AUTH_PW'] ?? null;
if ($user === null || $password === null) {
$this->renderAuthenticationDialog();
exit;
}
if (
Configuration::getConfig('authentication', 'username') === $user
&& Configuration::getConfig('authentication', 'password') === $password
) {
return;
}
$this->renderAuthenticationDialog();
exit;
}
private function renderAuthenticationDialog(): void
{
http_response_code(401);
header('WWW-Authenticate: Basic realm="RSS-Bridge"');
print render('error.html.php', ['message' => 'Please authenticate in order to access this instance !']);
}
}

View file

@ -12,19 +12,6 @@
* @link https://github.com/rss-bridge/rss-bridge * @link https://github.com/rss-bridge/rss-bridge
*/ */
/**
* An abstract class for bridges
*
* This class implements {@see BridgeInterface} with most common functions in
* order to reduce code duplication. Bridges should inherit from this class
* instead of implementing the interface manually.
*
* @todo Move constants to the interface (this is supported by PHP)
* @todo Change visibility of constants to protected
* @todo Return `self` on more functions to allow chaining
* @todo Add specification for PARAMETERS ()
* @todo Add specification for $items
*/
abstract class BridgeAbstract implements BridgeInterface abstract class BridgeAbstract implements BridgeInterface
{ {
/** /**
@ -107,7 +94,7 @@ abstract class BridgeAbstract implements BridgeInterface
* *
* @var array * @var array
*/ */
protected $items = []; protected array $items = [];
/** /**
* Holds the list of input parameters used by the bridge * Holds the list of input parameters used by the bridge
@ -117,7 +104,7 @@ abstract class BridgeAbstract implements BridgeInterface
* *
* @var array * @var array
*/ */
protected $inputs = []; protected array $inputs = [];
/** /**
* Holds the name of the queried context * Holds the name of the queried context
@ -233,7 +220,7 @@ abstract class BridgeAbstract implements BridgeInterface
if (empty(static::PARAMETERS)) { if (empty(static::PARAMETERS)) {
if (!empty($inputs)) { if (!empty($inputs)) {
returnClientError('Invalid parameters value(s)'); throw new \Exception('Invalid parameters value(s)');
} }
return; return;
@ -249,10 +236,7 @@ abstract class BridgeAbstract implements BridgeInterface
$validator->getInvalidParameters() $validator->getInvalidParameters()
); );
returnClientError( throw new \Exception(sprintf('Invalid parameters value(s): %s', implode(', ', $parameters)));
'Invalid parameters value(s): '
. implode(', ', $parameters)
);
} }
// Guess the context from input data // Guess the context from input data
@ -261,9 +245,9 @@ abstract class BridgeAbstract implements BridgeInterface
} }
if (is_null($this->queriedContext)) { if (is_null($this->queriedContext)) {
returnClientError('Required parameter(s) missing'); throw new \Exception('Required parameter(s) missing');
} elseif ($this->queriedContext === false) { } elseif ($this->queriedContext === false) {
returnClientError('Mixed context parameters'); throw new \Exception('Mixed context parameters');
} }
$this->setInputs($inputs, $this->queriedContext); $this->setInputs($inputs, $this->queriedContext);
@ -289,10 +273,7 @@ abstract class BridgeAbstract implements BridgeInterface
} }
if (isset($optionValue['required']) && $optionValue['required'] === true) { if (isset($optionValue['required']) && $optionValue['required'] === true) {
returnServerError( throw new \Exception(sprintf('Missing configuration option: %s', $optionName));
'Missing configuration option: '
. $optionName
);
} elseif (isset($optionValue['defaultValue'])) { } elseif (isset($optionValue['defaultValue'])) {
$this->configuration[$optionName] = $optionValue['defaultValue']; $this->configuration[$optionName] = $optionValue['defaultValue'];
} }
@ -314,17 +295,11 @@ abstract class BridgeAbstract implements BridgeInterface
} }
/** /**
* Returns the value for the selected configuration * Get bridge configuration value
*
* @param string $input The option name
* @return mixed|null The option value or null if the input is not defined
*/ */
public function getOption($name) public function getOption($name)
{ {
if (!isset($this->configuration[$name])) { return $this->configuration[$name] ?? null;
return null;
}
return $this->configuration[$name];
} }
/** {@inheritdoc} */ /** {@inheritdoc} */
@ -392,9 +367,8 @@ abstract class BridgeAbstract implements BridgeInterface
&& $urlMatches[3] === $bridgeUriMatches[3] && $urlMatches[3] === $bridgeUriMatches[3]
) { ) {
return []; return [];
} else {
return null;
} }
return null;
} }
/** /**
@ -404,13 +378,13 @@ abstract class BridgeAbstract implements BridgeInterface
* @param int $duration Cache duration (optional, default: 24 hours) * @param int $duration Cache duration (optional, default: 24 hours)
* @return mixed Cached value or null if the key doesn't exist or has expired * @return mixed Cached value or null if the key doesn't exist or has expired
*/ */
protected function loadCacheValue($key, $duration = 86400) protected function loadCacheValue($key, int $duration = 86400)
{ {
$cacheFactory = new CacheFactory(); $cacheFactory = new CacheFactory();
$cache = $cacheFactory->create(); $cache = $cacheFactory->create();
// Create class name without the namespace part // Create class name without the namespace part
$scope = (new ReflectionClass($this))->getShortName(); $scope = (new \ReflectionClass($this))->getShortName();
$cache->setScope($scope); $cache->setScope($scope);
$cache->setKey($key); $cache->setKey($key);
if ($cache->getTime() < time() - $duration) { if ($cache->getTime() < time() - $duration) {
@ -430,7 +404,7 @@ abstract class BridgeAbstract implements BridgeInterface
$cacheFactory = new CacheFactory(); $cacheFactory = new CacheFactory();
$cache = $cacheFactory->create(); $cache = $cacheFactory->create();
$scope = (new ReflectionClass($this))->getShortName(); $scope = (new \ReflectionClass($this))->getShortName();
$cache->setScope($scope); $cache->setScope($scope);
$cache->setKey($key); $cache->setKey($key);
$cache->saveData($value); $cache->saveData($value);

View file

@ -22,6 +22,90 @@
*/ */
final class BridgeCard final class BridgeCard
{ {
/**
* Gets a single bridge card
*
* @param class-string<BridgeInterface> $bridgeClassName The bridge name
* @param array $formats A list of formats
* @param bool $isActive Indicates if the bridge is active or not
* @return string The bridge card
*/
public static function displayBridgeCard($bridgeClassName, $formats, $isActive = true)
{
$bridgeFactory = new BridgeFactory();
$bridge = $bridgeFactory->create($bridgeClassName);
$isHttps = strpos($bridge->getURI(), 'https') === 0;
$uri = $bridge->getURI();
$name = $bridge->getName();
$icon = $bridge->getIcon();
$description = $bridge->getDescription();
$parameters = $bridge->getParameters();
if (Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge')) {
$parameters['global']['_noproxy'] = [
'name' => 'Disable proxy (' . (Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url')) . ')',
'type' => 'checkbox'
];
}
if (CUSTOM_CACHE_TIMEOUT) {
$parameters['global']['_cache_timeout'] = [
'name' => 'Cache timeout in seconds',
'type' => 'number',
'defaultValue' => $bridge->getCacheTimeout()
];
}
$card = <<<CARD
<section id="bridge-{$bridgeClassName}" data-ref="{$name}">
<h2><a href="{$uri}">{$name}</a></h2>
<p class="description">{$description}</p>
<input type="checkbox" class="showmore-box" id="showmore-{$bridgeClassName}" />
<label class="showmore" for="showmore-{$bridgeClassName}">Show more</label>
CARD;
// If we don't have any parameter for the bridge, we print a generic form to load it.
if (count($parameters) === 0) {
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps);
// Display form with cache timeout and/or noproxy options (if enabled) when bridge has no parameters
} elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) {
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, '', $parameters['global']);
} else {
foreach ($parameters as $parameterName => $parameter) {
if (!is_numeric($parameterName) && $parameterName === 'global') {
continue;
}
if (array_key_exists('global', $parameters)) {
$parameter = array_merge($parameter, $parameters['global']);
}
if (!is_numeric($parameterName)) {
$card .= '<h5>' . $parameterName . '</h5>' . PHP_EOL;
}
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, $parameterName, $parameter);
}
}
$card .= sprintf('<label class="showless" for="showmore-%s">Show less</label>', $bridgeClassName);
if ($bridge->getDonationURI() !== '' && Configuration::getConfig('admin', 'donations')) {
$card .= sprintf(
'<p class="maintainer">%s ~ <a href="%s">Donate</a></p>',
$bridge->getMaintainer(),
$bridge->getDonationURI()
);
} else {
$card .= sprintf('<p class="maintainer">%s</p>', $bridge->getMaintainer());
}
$card .= '</section>';
return $card;
}
/** /**
* Get the form header for a bridge card * Get the form header for a bridge card
* *
@ -38,9 +122,7 @@ final class BridgeCard
EOD; EOD;
if (!empty($parameterName)) { if (!empty($parameterName)) {
$form .= <<<EOD $form .= sprintf('<input type="hidden" name="context" value="%s" />', $parameterName);
<input type="hidden" name="context" value="{$parameterName}" />
EOD;
} }
if (!$isHttps) { if (!$isHttps) {
@ -293,93 +375,4 @@ This bridge is not fetching its content through a secure connection</div>';
. ' />' . ' />'
. PHP_EOL; . PHP_EOL;
} }
/**
* Gets a single bridge card
*
* @param class-string<BridgeInterface> $bridgeClassName The bridge name
* @param array $formats A list of formats
* @param bool $isActive Indicates if the bridge is active or not
* @return string The bridge card
*/
public static function displayBridgeCard($bridgeClassName, $formats, $isActive = true)
{
$bridgeFactory = new \BridgeFactory();
$bridge = $bridgeFactory->create($bridgeClassName);
if ($bridge == false) {
return '';
}
$isHttps = strpos($bridge->getURI(), 'https') === 0;
$uri = $bridge->getURI();
$name = $bridge->getName();
$icon = $bridge->getIcon();
$description = $bridge->getDescription();
$parameters = $bridge->getParameters();
$donationUri = $bridge->getDonationURI();
$maintainer = $bridge->getMaintainer();
$donationsAllowed = Configuration::getConfig('admin', 'donations');
if (Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge')) {
$parameters['global']['_noproxy'] = [
'name' => 'Disable proxy (' . (Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url')) . ')',
'type' => 'checkbox'
];
}
if (CUSTOM_CACHE_TIMEOUT) {
$parameters['global']['_cache_timeout'] = [
'name' => 'Cache timeout in seconds',
'type' => 'number',
'defaultValue' => $bridge->getCacheTimeout()
];
}
$card = <<<CARD
<section id="bridge-{$bridgeClassName}" data-ref="{$name}">
<h2><a href="{$uri}">{$name}</a></h2>
<p class="description">{$description}</p>
<input type="checkbox" class="showmore-box" id="showmore-{$bridgeClassName}" />
<label class="showmore" for="showmore-{$bridgeClassName}">Show more</label>
CARD;
// If we don't have any parameter for the bridge, we print a generic form to load it.
if (count($parameters) === 0) {
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps);
// Display form with cache timeout and/or noproxy options (if enabled) when bridge has no parameters
} elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) {
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, '', $parameters['global']);
} else {
foreach ($parameters as $parameterName => $parameter) {
if (!is_numeric($parameterName) && $parameterName === 'global') {
continue;
}
if (array_key_exists('global', $parameters)) {
$parameter = array_merge($parameter, $parameters['global']);
}
if (!is_numeric($parameterName)) {
$card .= '<h5>' . $parameterName . '</h5>' . PHP_EOL;
}
$card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, $parameterName, $parameter);
}
}
$card .= '<label class="showless" for="showmore-' . $bridgeClassName . '">Show less</label>';
if ($donationUri !== '' && $donationsAllowed) {
$card .= '<p class="maintainer">' . $maintainer . ' ~ <a href="' . $donationUri . '">Donate</a></p>';
} else {
$card .= '<p class="maintainer">' . $maintainer . '</p>';
}
$card .= '</section>';
return $card;
}
} }

View file

@ -27,7 +27,8 @@ final class BridgeFactory
} else { } else {
$contents = ''; $contents = '';
} }
if ($contents === '*') { // Whitelist all bridges if ($contents === '*') {
// Whitelist all bridges
$this->whitelist = $this->getBridgeClassNames(); $this->whitelist = $this->getBridgeClassNames();
} else { } else {
foreach (explode("\n", $contents) as $bridgeName) { foreach (explode("\n", $contents) as $bridgeName) {
@ -97,7 +98,6 @@ final class BridgeFactory
return $this->getBridgeClassNames()[$index]; return $this->getBridgeClassNames()[$index];
} }
Debug::log('Invalid bridge name specified: "' . $name . '"!');
return null; return null;
} }
} }

View file

@ -22,6 +22,28 @@
*/ */
final class BridgeList final class BridgeList
{ {
/**
* Create the entire home page
*
* @param bool $showInactive Inactive bridges are displayed on the home page,
* if enabled.
* @return string The home page
*/
public static function create($showInactive = true)
{
$totalBridges = 0;
$totalActiveBridges = 0;
return '<!DOCTYPE html><html lang="en">'
. BridgeList::getHead()
. '<body onload="search()">'
. BridgeList::getHeader()
. BridgeList::getSearchbar()
. BridgeList::getBridges($showInactive, $totalBridges, $totalActiveBridges)
. BridgeList::getFooter($totalBridges, $totalActiveBridges, $showInactive)
. '</body></html>';
}
/** /**
* Get the document head * Get the document head
* *
@ -65,7 +87,7 @@ EOD;
$totalActiveBridges = 0; $totalActiveBridges = 0;
$inactiveBridges = ''; $inactiveBridges = '';
$bridgeFactory = new \BridgeFactory(); $bridgeFactory = new BridgeFactory();
$bridgeClassNames = $bridgeFactory->getBridgeClassNames(); $bridgeClassNames = $bridgeFactory->getBridgeClassNames();
$formatFactory = new FormatFactory(); $formatFactory = new FormatFactory();
@ -126,7 +148,7 @@ EOD;
*/ */
private static function getSearchbar() private static function getSearchbar()
{ {
$query = filter_input(INPUT_GET, 'q', FILTER_SANITIZE_SPECIAL_CHARS); $query = filter_input(INPUT_GET, 'q', \FILTER_SANITIZE_SPECIAL_CHARS);
return <<<EOD return <<<EOD
<section class="searchbar"> <section class="searchbar">
@ -167,10 +189,10 @@ EOD;
$inactive = ''; $inactive = '';
if ($totalActiveBridges !== $totalBridges) { if ($totalActiveBridges !== $totalBridges) {
if (!$showInactive) { if ($showInactive) {
$inactive = '<a href="?show_inactive=1"><button class="small">Show inactive bridges</button></a><br>';
} else {
$inactive = '<a href="?show_inactive=0"><button class="small">Hide inactive bridges</button></a><br>'; $inactive = '<a href="?show_inactive=0"><button class="small">Hide inactive bridges</button></a><br>';
} else {
$inactive = '<a href="?show_inactive=1"><button class="small">Show inactive bridges</button></a><br>';
} }
} }
@ -184,26 +206,4 @@ EOD;
</section> </section>
EOD; EOD;
} }
/**
* Create the entire home page
*
* @param bool $showInactive Inactive bridges are displayed on the home page,
* if enabled.
* @return string The home page
*/
public static function create($showInactive = true)
{
$totalBridges = 0;
$totalActiveBridges = 0;
return '<!DOCTYPE html><html lang="en">'
. BridgeList::getHead()
. '<body onload="search()">'
. BridgeList::getHeader()
. BridgeList::getSearchbar()
. BridgeList::getBridges($showInactive, $totalBridges, $totalActiveBridges)
. BridgeList::getFooter($totalBridges, $totalActiveBridges, $showInactive)
. '</body></html>';
}
} }

View file

@ -55,7 +55,7 @@ interface CacheInterface
/** /**
* Returns the timestamp for the curent cache data * Returns the timestamp for the curent cache data
* *
* @return int Timestamp or null * @return ?int Timestamp
*/ */
public function getTime(); public function getTime();

View file

@ -41,14 +41,8 @@ final class Configuration
*/ */
private static $config = null; private static $config = null;
/** private function __construct()
* Throw an exception when trying to create a new instance of this class.
*
* @throws \LogicException if called.
*/
public function __construct()
{ {
throw new \LogicException('Can\'t create object of this class!');
} }
/** /**
@ -61,43 +55,45 @@ final class Configuration
*/ */
public static function verifyInstallation() public static function verifyInstallation()
{ {
// Check PHP version if (version_compare(\PHP_VERSION, '7.4.0') === -1) {
// PHP Supported Versions: https://www.php.net/supported-versions.php
if (version_compare(PHP_VERSION, '7.4.0') === -1) {
self::reportError('RSS-Bridge requires at least PHP version 7.4.0!'); self::reportError('RSS-Bridge requires at least PHP version 7.4.0!');
} }
// Extensions check $errors = [];
// OpenSSL: https://www.php.net/manual/en/book.openssl.php // OpenSSL: https://www.php.net/manual/en/book.openssl.php
if (!extension_loaded('openssl')) { if (!extension_loaded('openssl')) {
self::reportError('"openssl" extension not loaded. Please check "php.ini"'); $errors[] = 'openssl extension not loaded';
} }
// libxml: https://www.php.net/manual/en/book.libxml.php // libxml: https://www.php.net/manual/en/book.libxml.php
if (!extension_loaded('libxml')) { if (!extension_loaded('libxml')) {
self::reportError('"libxml" extension not loaded. Please check "php.ini"'); $errors[] = 'libxml extension not loaded';
} }
// Multibyte String (mbstring): https://www.php.net/manual/en/book.mbstring.php // Multibyte String (mbstring): https://www.php.net/manual/en/book.mbstring.php
if (!extension_loaded('mbstring')) { if (!extension_loaded('mbstring')) {
self::reportError('"mbstring" extension not loaded. Please check "php.ini"'); $errors[] = 'mbstring extension not loaded';
} }
// SimpleXML: https://www.php.net/manual/en/book.simplexml.php // SimpleXML: https://www.php.net/manual/en/book.simplexml.php
if (!extension_loaded('simplexml')) { if (!extension_loaded('simplexml')) {
self::reportError('"simplexml" extension not loaded. Please check "php.ini"'); $errors[] = 'simplexml extension not loaded';
} }
// Client URL Library (curl): https://www.php.net/manual/en/book.curl.php // Client URL Library (curl): https://www.php.net/manual/en/book.curl.php
// Allow RSS-Bridge to run without curl module in CLI mode without root certificates // Allow RSS-Bridge to run without curl module in CLI mode without root certificates
if (!extension_loaded('curl') && !(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo')))) { if (!extension_loaded('curl') && !(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo')))) {
self::reportError('"curl" extension not loaded. Please check "php.ini"'); $errors[] = 'curl extension not loaded';
} }
// JavaScript Object Notation (json): https://www.php.net/manual/en/book.json.php // JavaScript Object Notation (json): https://www.php.net/manual/en/book.json.php
if (!extension_loaded('json')) { if (!extension_loaded('json')) {
self::reportError('"json" extension not loaded. Please check "php.ini"'); $errors[] = 'json extension not loaded';
}
if ($errors) {
throw new \Exception(sprintf('Configuration error: %s', implode(', ', $errors)));
} }
} }
@ -192,11 +188,11 @@ final class Configuration
self::reportConfigurationError('authentication', 'enable', 'Is not a valid Boolean'); self::reportConfigurationError('authentication', 'enable', 'Is not a valid Boolean');
} }
if (!is_string(self::getConfig('authentication', 'username'))) { if (!self::getConfig('authentication', 'username')) {
self::reportConfigurationError('authentication', 'username', 'Is not a valid string'); self::reportConfigurationError('authentication', 'username', 'Is not a valid string');
} }
if (!is_string(self::getConfig('authentication', 'password'))) { if (! self::getConfig('authentication', 'password')) {
self::reportConfigurationError('authentication', 'password', 'Is not a valid string'); self::reportConfigurationError('authentication', 'password', 'Is not a valid string');
} }
@ -250,7 +246,7 @@ final class Configuration
*/ */
public static function getVersion() public static function getVersion()
{ {
$headFile = PATH_ROOT . '.git/HEAD'; $headFile = __DIR__ . '/../.git/HEAD';
// '@' is used to mute open_basedir warning // '@' is used to mute open_basedir warning
if (@is_readable($headFile)) { if (@is_readable($headFile)) {
@ -295,19 +291,8 @@ final class Configuration
self::reportError($report); self::reportError($report);
} }
/**
* Reports an error message to the user and ends execution
*
* @param string $message The error message
*
* @return void
*/
private static function reportError($message) private static function reportError($message)
{ {
http_response_code(500); throw new \Exception(sprintf('Configuration error: %s', $message));
print render('error.html.php', [
'message' => "Configuration error: $message",
]);
exit;
} }
} }

View file

@ -64,8 +64,8 @@ class Debug
{ {
static $firstCall = true; // Initialized on first call static $firstCall = true; // Initialized on first call
if ($firstCall && file_exists(PATH_ROOT . 'DEBUG')) { if ($firstCall && file_exists(__DIR__ . '/../DEBUG')) {
$debug_whitelist = trim(file_get_contents(PATH_ROOT . 'DEBUG')); $debug_whitelist = trim(file_get_contents(__DIR__ . '/../DEBUG'));
self::$enabled = empty($debug_whitelist) || in_array( self::$enabled = empty($debug_whitelist) || in_array(
$_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_ADDR'],

View file

@ -85,10 +85,10 @@ abstract class FeedExpander extends BridgeAbstract
public function collectExpandableDatas($url, $maxItems = -1) public function collectExpandableDatas($url, $maxItems = -1)
{ {
if (empty($url)) { if (empty($url)) {
returnServerError('There is no $url for this RSS expander'); throw new \Exception('There is no $url for this RSS expander');
} }
Debug::log('Loading from ' . $url); Debug::log(sprintf('Loading from %s', $url));
/* Notice we do not use cache here on purpose: /* Notice we do not use cache here on purpose:
* we want a fresh view of the RSS stream each time * we want a fresh view of the RSS stream each time
@ -100,8 +100,7 @@ abstract class FeedExpander extends BridgeAbstract
'*/*', '*/*',
]; ];
$httpHeaders = ['Accept: ' . implode(', ', $mimeTypes)]; $httpHeaders = ['Accept: ' . implode(', ', $mimeTypes)];
$content = getContents($url, $httpHeaders) $content = getContents($url, $httpHeaders);
or returnServerError('Could not request ' . $url);
$rssContent = simplexml_load_string(trim($content)); $rssContent = simplexml_load_string(trim($content));
if ($rssContent === false) { if ($rssContent === false) {
@ -127,8 +126,7 @@ abstract class FeedExpander extends BridgeAbstract
break; break;
default: default:
Debug::log('Unknown feed format/version'); Debug::log('Unknown feed format/version');
returnServerError('The feed format is unknown!'); throw new \Exception('The feed format is unknown!');
break;
} }
return $this; return $this;
@ -151,7 +149,7 @@ abstract class FeedExpander extends BridgeAbstract
{ {
$this->loadRss2Data($rssContent->channel[0]); $this->loadRss2Data($rssContent->channel[0]);
foreach ($rssContent->item as $item) { foreach ($rssContent->item as $item) {
Debug::log('parsing item ' . var_export($item, true)); Debug::log(sprintf('Parsing item %s', var_export($item, true)));
$tmp_item = $this->parseItem($item); $tmp_item = $this->parseItem($item);
if (!empty($tmp_item)) { if (!empty($tmp_item)) {
$this->items[] = $tmp_item; $this->items[] = $tmp_item;
@ -453,33 +451,39 @@ abstract class FeedExpander extends BridgeAbstract
switch ($this->feedType) { switch ($this->feedType) {
case self::FEED_TYPE_RSS_1_0: case self::FEED_TYPE_RSS_1_0:
return $this->parseRss1Item($item); return $this->parseRss1Item($item);
break;
case self::FEED_TYPE_RSS_2_0: case self::FEED_TYPE_RSS_2_0:
return $this->parseRss2Item($item); return $this->parseRss2Item($item);
break;
case self::FEED_TYPE_ATOM_1_0: case self::FEED_TYPE_ATOM_1_0:
return $this->parseATOMItem($item); return $this->parseATOMItem($item);
break;
default: default:
returnClientError('Unknown version ' . $this->getInput('version') . '!'); throw new \Exception(sprintf('Unknown version %s!', $this->getInput('version')));
} }
} }
/** {@inheritdoc} */ /** {@inheritdoc} */
public function getURI() public function getURI()
{ {
return !empty($this->uri) ? $this->uri : parent::getURI(); if (!empty($this->uri)) {
return $this->uri;
}
return parent::getURI();
} }
/** {@inheritdoc} */ /** {@inheritdoc} */
public function getName() public function getName()
{ {
return !empty($this->title) ? $this->title : parent::getName(); if (!empty($this->title)) {
return $this->title;
}
return parent::getName();
} }
/** {@inheritdoc} */ /** {@inheritdoc} */
public function getIcon() public function getIcon()
{ {
return !empty($this->icon) ? $this->icon : parent::getIcon(); if (!empty($this->icon)) {
return $this->icon;
}
return parent::getIcon();
} }
} }

View file

@ -329,10 +329,10 @@ class FeedItem
$content = (string)$content; $content = (string)$content;
} }
if (!is_string($content)) { if (is_string($content)) {
Debug::log('Content must be a string!');
} else {
$this->content = $content; $this->content = $content;
} else {
Debug::log('Content must be a string!');
} }
return $this; return $this;
@ -361,11 +361,9 @@ class FeedItem
*/ */
public function setEnclosures($enclosures) public function setEnclosures($enclosures)
{ {
$this->enclosures = []; // Clear previous data $this->enclosures = [];
if (!is_array($enclosures)) { if (is_array($enclosures)) {
Debug::log('Enclosures must be an array!');
} else {
foreach ($enclosures as $enclosure) { foreach ($enclosures as $enclosure) {
if ( if (
!filter_var( !filter_var(
@ -379,6 +377,8 @@ class FeedItem
$this->enclosures[] = $enclosure; $this->enclosures[] = $enclosure;
} }
} }
} else {
Debug::log('Enclosures must be an array!');
} }
return $this; return $this;
@ -407,11 +407,9 @@ class FeedItem
*/ */
public function setCategories($categories) public function setCategories($categories)
{ {
$this->categories = []; // Clear previous data $this->categories = [];
if (!is_array($categories)) { if (is_array($categories)) {
Debug::log('Categories must be an array!');
} else {
foreach ($categories as $category) { foreach ($categories as $category) {
if (!is_string($category)) { if (!is_string($category)) {
Debug::log('Category must be a string!'); Debug::log('Category must be a string!');
@ -419,6 +417,8 @@ class FeedItem
$this->categories[] = $category; $this->categories[] = $category;
} }
} }
} else {
Debug::log('Categories must be an array!');
} }
return $this; return $this;

View file

@ -63,7 +63,10 @@ abstract class FormatAbstract implements FormatInterface
{ {
$charset = $this->charset; $charset = $this->charset;
return is_null($charset) ? static::DEFAULT_CHARSET : $charset; if (is_null($charset)) {
return static::DEFAULT_CHARSET;
}
return $charset;
} }
/** /**
@ -93,7 +96,7 @@ abstract class FormatAbstract implements FormatInterface
public function getItems() public function getItems()
{ {
if (!is_array($this->items)) { if (!is_array($this->items)) {
throw new \LogicException('Feed the ' . get_class($this) . ' with "setItems" method before !'); throw new \LogicException(sprintf('Feed the %s with "setItems" method before !', get_class($this)));
} }
return $this->items; return $this->items;
@ -126,26 +129,4 @@ abstract class FormatAbstract implements FormatInterface
return $this->extraInfos; return $this->extraInfos;
} }
/**
* Sanitize HTML while leaving it functional.
*
* Keeps HTML as-is (with clickable hyperlinks) while reducing annoying and
* potentially dangerous things.
*
* @param string $html The HTML content
* @return string The sanitized HTML content
*
* @todo This belongs into `html.php`
* @todo Maybe switch to http://htmlpurifier.org/
* @todo Maybe switch to http://www.bioinformatics.org/phplabware/internal_utilities/htmLawed/index.php
*/
protected function sanitizeHtml(string $html): string
{
$html = str_replace('<script', '<&zwnj;script', $html); // Disable scripts, but leave them visible.
$html = str_replace('<iframe', '<&zwnj;iframe', $html);
$html = str_replace('<link', '<&zwnj;link', $html);
// We leave alone object and embed so that videos can play in RSS readers.
return $html;
}
} }

View file

@ -35,7 +35,7 @@ class ParameterValidator
{ {
$this->invalid[] = [ $this->invalid[] = [
'name' => $name, 'name' => $name,
'reason' => $reason 'reason' => $reason,
]; ];
} }
@ -216,7 +216,7 @@ class ParameterValidator
if (array_key_exists('global', $parameters)) { if (array_key_exists('global', $parameters)) {
$notInContext = array_diff_key($notInContext, $parameters['global']); $notInContext = array_diff_key($notInContext, $parameters['global']);
} }
if (sizeof($notInContext) > 0) { if (count($notInContext) > 0) {
continue; continue;
} }
@ -246,7 +246,8 @@ class ParameterValidator
unset($queriedContexts['global']); unset($queriedContexts['global']);
switch (array_sum($queriedContexts)) { switch (array_sum($queriedContexts)) {
case 0: // Found no match, is there a context without parameters? case 0:
// Found no match, is there a context without parameters?
if (isset($data['context'])) { if (isset($data['context'])) {
return $data['context']; return $data['context'];
} }
@ -256,7 +257,8 @@ class ParameterValidator
} }
} }
return null; return null;
case 1: // Found unique match case 1:
// Found unique match
return array_search(true, $queriedContexts); return array_search(true, $queriedContexts);
default: default:
return false; return false;

View file

@ -341,10 +341,10 @@ abstract class XPathAbstract extends BridgeAbstract
/** /**
* Should provide the feeds title * Should provide the feeds title
* *
* @param DOMXPath $xpath * @param \DOMXPath $xpath
* @return string * @return string
*/ */
protected function provideFeedTitle(DOMXPath $xpath) protected function provideFeedTitle(\DOMXPath $xpath)
{ {
$title = $xpath->query($this->getParam('feed_title')); $title = $xpath->query($this->getParam('feed_title'));
if (count($title) === 1) { if (count($title) === 1) {
@ -355,10 +355,10 @@ abstract class XPathAbstract extends BridgeAbstract
/** /**
* Should provide the URL of the feed's favicon * Should provide the URL of the feed's favicon
* *
* @param DOMXPath $xpath * @param \DOMXPath $xpath
* @return string * @return string
*/ */
protected function provideFeedIcon(DOMXPath $xpath) protected function provideFeedIcon(\DOMXPath $xpath)
{ {
$icon = $xpath->query($this->getParam('feed_icon')); $icon = $xpath->query($this->getParam('feed_icon'));
if (count($icon) === 1) { if (count($icon) === 1) {
@ -369,10 +369,10 @@ abstract class XPathAbstract extends BridgeAbstract
/** /**
* Should provide the feed's items. * Should provide the feed's items.
* *
* @param DOMXPath $xpath * @param \DOMXPath $xpath
* @return DOMNodeList * @return \DOMNodeList
*/ */
protected function provideFeedItems(DOMXPath $xpath) protected function provideFeedItems(\DOMXPath $xpath)
{ {
return @$xpath->query($this->getParam('item')); return @$xpath->query($this->getParam('item'));
} }
@ -381,13 +381,13 @@ abstract class XPathAbstract extends BridgeAbstract
{ {
$this->feedUri = $this->getParam('url'); $this->feedUri = $this->getParam('url');
$webPageHtml = new DOMDocument(); $webPageHtml = new \DOMDocument();
libxml_use_internal_errors(true); libxml_use_internal_errors(true);
$webPageHtml->loadHTML($this->provideWebsiteContent()); $webPageHtml->loadHTML($this->provideWebsiteContent());
libxml_clear_errors(); libxml_clear_errors();
libxml_use_internal_errors(false); libxml_use_internal_errors(false);
$xpath = new DOMXPath($webPageHtml); $xpath = new \DOMXPath($webPageHtml);
$this->feedName = $this->provideFeedTitle($xpath); $this->feedName = $this->provideFeedTitle($xpath);
$this->feedIcon = $this->provideFeedIcon($xpath); $this->feedIcon = $this->provideFeedIcon($xpath);
@ -398,7 +398,7 @@ abstract class XPathAbstract extends BridgeAbstract
} }
foreach ($entries as $entry) { foreach ($entries as $entry) {
$item = new \FeedItem(); $item = new FeedItem();
foreach (['title', 'content', 'uri', 'author', 'timestamp', 'enclosures', 'categories'] as $param) { foreach (['title', 'content', 'uri', 'author', 'timestamp', 'enclosures', 'categories'] as $param) {
$expression = $this->getParam($param); $expression = $this->getParam($param);
if ('' === $expression) { if ('' === $expression) {
@ -408,7 +408,7 @@ abstract class XPathAbstract extends BridgeAbstract
//can be a string or DOMNodeList, depending on the expression result //can be a string or DOMNodeList, depending on the expression result
$typedResult = @$xpath->evaluate($expression, $entry); $typedResult = @$xpath->evaluate($expression, $entry);
if ( if (
$typedResult === false || ($typedResult instanceof DOMNodeList && count($typedResult) === 0) $typedResult === false || ($typedResult instanceof \DOMNodeList && count($typedResult) === 0)
|| (is_string($typedResult) && strlen(trim($typedResult)) === 0) || (is_string($typedResult) && strlen(trim($typedResult)) === 0)
) { ) {
continue; continue;
@ -571,19 +571,19 @@ abstract class XPathAbstract extends BridgeAbstract
*/ */
protected function getItemValueOrNodeValue($typedResult) protected function getItemValueOrNodeValue($typedResult)
{ {
if ($typedResult instanceof DOMNodeList) { if ($typedResult instanceof \DOMNodeList) {
$item = $typedResult->item(0); $item = $typedResult->item(0);
if ($item instanceof DOMElement) { if ($item instanceof \DOMElement) {
return trim($item->nodeValue); return trim($item->nodeValue);
} elseif ($item instanceof DOMAttr) { } elseif ($item instanceof \DOMAttr) {
return trim($item->value); return trim($item->value);
} elseif ($item instanceof DOMText) { } elseif ($item instanceof \DOMText) {
return trim($item->wholeText); return trim($item->wholeText);
} }
} elseif (is_string($typedResult) && strlen($typedResult) > 0) { } elseif (is_string($typedResult) && strlen($typedResult) > 0) {
return trim($typedResult); return trim($typedResult);
} }
returnServerError('Unknown type of XPath expression result.'); throw new \Exception('Unknown type of XPath expression result.');
} }
/** /**
@ -605,8 +605,8 @@ abstract class XPathAbstract extends BridgeAbstract
* @param FeedItem $item * @param FeedItem $item
* @return string|null * @return string|null
*/ */
protected function generateItemId(\FeedItem $item) protected function generateItemId(FeedItem $item)
{ {
return null; //auto generation return null;
} }
} }

View file

@ -1,9 +1,5 @@
<?php <?php
final class HttpException extends \Exception
{
}
// todo: move this somewhere useful, possibly into a function // todo: move this somewhere useful, possibly into a function
const RSSBRIDGE_HTTP_STATUS_CODES = [ const RSSBRIDGE_HTTP_STATUS_CODES = [
'100' => 'Continue', '100' => 'Continue',
@ -128,7 +124,8 @@ function getContents(
} }
$cache->saveData($result['body']); $cache->saveData($result['body']);
break; break;
case 304: // Not Modified case 304:
// Not Modified
$response['content'] = $cache->loadData(); $response['content'] = $cache->loadData();
break; break;
default: default:
@ -379,68 +376,3 @@ function getSimpleHTMLDOMCached(
$defaultSpanText $defaultSpanText
); );
} }
/**
* Determines the MIME type from a URL/Path file extension.
*
* _Remarks_:
*
* * The built-in functions `mime_content_type` and `fileinfo` require fetching
* remote contents.
* * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`).
*
* Based on https://stackoverflow.com/a/1147952
*
* @param string $url The URL or path to the file.
* @return string The MIME type of the file.
*/
function getMimeType($url)
{
static $mime = null;
if (is_null($mime)) {
// Default values, overriden by /etc/mime.types when present
$mime = [
'jpg' => 'image/jpeg',
'gif' => 'image/gif',
'png' => 'image/png',
'image' => 'image/*',
'mp3' => 'audio/mpeg',
];
// '@' is used to mute open_basedir warning, see issue #818
if (@is_readable('/etc/mime.types')) {
$file = fopen('/etc/mime.types', 'r');
while (($line = fgets($file)) !== false) {
$line = trim(preg_replace('/#.*/', '', $line));
if (!$line) {
continue;
}
$parts = preg_split('/\s+/', $line);
if (count($parts) == 1) {
continue;
}
$type = array_shift($parts);
foreach ($parts as $part) {
$mime[$part] = $type;
}
}
fclose($file);
}
}
if (strpos($url, '?') !== false) {
$url_temp = substr($url, 0, strpos($url, '?'));
if (strpos($url, '#') !== false) {
$anchor = substr($url, strpos($url, '#'));
$url_temp .= $anchor;
}
$url = $url_temp;
}
$ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));
if (!empty($mime[$ext])) {
return $mime[$ext];
}
return 'application/octet-stream';
}

View file

@ -64,7 +64,7 @@ function logBridgeError($bridgeName, $code)
$cache->purgeCache(86400); // 24 hours $cache->purgeCache(86400); // 24 hours
if ($report = $cache->loadData()) { if ($report = $cache->loadData()) {
$report = json_decode($report, true); $report = Json::decode($report);
$report['time'] = time(); $report['time'] = time();
$report['count']++; $report['count']++;
} else { } else {
@ -75,38 +75,7 @@ function logBridgeError($bridgeName, $code)
]; ];
} }
$cache->saveData(json_encode($report)); $cache->saveData(Json::encode($report));
return $report['count']; return $report['count'];
} }
function create_sane_stacktrace(\Throwable $e): array
{
$frames = array_reverse($e->getTrace());
$frames[] = [
'file' => $e->getFile(),
'line' => $e->getLine(),
];
$stackTrace = [];
foreach ($frames as $i => $frame) {
$file = $frame['file'] ?? '(no file)';
$line = $frame['line'] ?? '(no line)';
$stackTrace[] = sprintf(
'#%s %s:%s',
$i,
trim_path_prefix($file),
$line,
);
}
return $stackTrace;
}
/**
* Trim path prefix for privacy/security reasons
*
* Example: "/var/www/rss-bridge/index.php" => "index.php"
*/
function trim_path_prefix(string $filePath): string
{
return mb_substr($filePath, mb_strlen(dirname(__DIR__)) + 1);
}

View file

@ -98,6 +98,15 @@ function sanitize(
return $htmlContent; return $htmlContent;
} }
function sanitize_html(string $html): string
{
$html = str_replace('<script', '<&zwnj;script', $html); // Disable scripts, but leave them visible.
$html = str_replace('<iframe', '<&zwnj;iframe', $html);
$html = str_replace('<link', '<&zwnj;link', $html);
// We leave alone object and embed so that videos can play in RSS readers.
return $html;
}
/** /**
* Replace background by image * Replace background by image
* *

View file

@ -13,55 +13,56 @@
*/ */
/** Path to the root folder of RSS-Bridge (where index.php is located) */ /** Path to the root folder of RSS-Bridge (where index.php is located) */
define('PATH_ROOT', __DIR__ . '/../'); const PATH_ROOT = __DIR__ . '/../';
/** Path to the core library */
define('PATH_LIB', PATH_ROOT . 'lib/');
/** Path to the vendor library */
define('PATH_LIB_VENDOR', PATH_ROOT . 'vendor/');
/** Path to the bridges library */ /** Path to the bridges library */
define('PATH_LIB_BRIDGES', PATH_ROOT . 'bridges/'); const PATH_LIB_BRIDGES = __DIR__ . '/../bridges/';
/** Path to the formats library */ /** Path to the formats library */
define('PATH_LIB_FORMATS', PATH_ROOT . 'formats/'); const PATH_LIB_FORMATS = __DIR__ . '/../formats/';
/** Path to the caches library */ /** Path to the caches library */
define('PATH_LIB_CACHES', PATH_ROOT . 'caches/'); const PATH_LIB_CACHES = __DIR__ . '/../caches/';
/** Path to the actions library */ /** Path to the actions library */
define('PATH_LIB_ACTIONS', PATH_ROOT . 'actions/'); const PATH_LIB_ACTIONS = __DIR__ . '/../actions/';
/** Path to the cache folder */ /** Path to the cache folder */
define('PATH_CACHE', PATH_ROOT . 'cache/'); const PATH_CACHE = __DIR__ . '/../cache/';
/** Path to the whitelist file */ /** Path to the whitelist file */
define('WHITELIST', PATH_ROOT . 'whitelist.txt'); const WHITELIST = __DIR__ . '/../whitelist.txt';
/** Path to the default whitelist file */ /** Path to the default whitelist file */
define('WHITELIST_DEFAULT', PATH_ROOT . 'whitelist.default.txt'); const WHITELIST_DEFAULT = __DIR__ . '/../whitelist.default.txt';
/** Path to the configuration file */ /** Path to the configuration file */
define('FILE_CONFIG', PATH_ROOT . 'config.ini.php'); const FILE_CONFIG = __DIR__ . '/../config.ini.php';
/** Path to the default configuration file */ /** Path to the default configuration file */
define('FILE_CONFIG_DEFAULT', PATH_ROOT . 'config.default.ini.php'); const FILE_CONFIG_DEFAULT = __DIR__ . '/../config.default.ini.php';
/** URL to the RSS-Bridge repository */ /** URL to the RSS-Bridge repository */
define('REPOSITORY', 'https://github.com/RSS-Bridge/rss-bridge/'); const REPOSITORY = 'https://github.com/RSS-Bridge/rss-bridge/';
// Allow larger files for simple_html_dom
const MAX_FILE_SIZE = 10000000;
// Files // Files
require_once PATH_LIB . 'html.php'; $files = [
require_once PATH_LIB . 'error.php'; __DIR__ . '/../lib/html.php',
require_once PATH_LIB . 'contents.php'; __DIR__ . '/../lib/error.php',
require_once PATH_LIB . 'php8backports.php'; __DIR__ . '/../lib/contents.php',
__DIR__ . '/../lib/php8backports.php',
// Vendor __DIR__ . '/../lib/utils.php',
define('MAX_FILE_SIZE', 10000000); /* Allow larger files for simple_html_dom */ // Vendor
require_once PATH_LIB_VENDOR . 'parsedown/Parsedown.php'; __DIR__ . '/../vendor/parsedown/Parsedown.php',
require_once PATH_LIB_VENDOR . 'php-urljoin/src/urljoin.php'; __DIR__ . '/../vendor/php-urljoin/src/urljoin.php',
require_once PATH_LIB_VENDOR . 'simplehtmldom/simple_html_dom.php'; __DIR__ . '/../vendor/simplehtmldom/simple_html_dom.php',
];
foreach ($files as $file) {
require_once $file;
}
spl_autoload_register(function ($className) { spl_autoload_register(function ($className) {
$folders = [ $folders = [

123
lib/utils.php Normal file
View file

@ -0,0 +1,123 @@
<?php
final class HttpException extends \Exception
{
}
final class Json
{
public static function encode($value): string
{
$flags = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
return \json_encode($value, $flags);
}
public static function decode(string $json, bool $assoc = true)
{
return \json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR);
}
}
function create_sane_stacktrace(\Throwable $e): array
{
$frames = array_reverse($e->getTrace());
$frames[] = [
'file' => $e->getFile(),
'line' => $e->getLine(),
];
$stackTrace = [];
foreach ($frames as $i => $frame) {
$file = $frame['file'] ?? '(no file)';
$line = $frame['line'] ?? '(no line)';
$stackTrace[] = sprintf(
'#%s %s:%s',
$i,
trim_path_prefix($file),
$line,
);
}
return $stackTrace;
}
/**
* Trim path prefix for privacy/security reasons
*
* Example: "/var/www/rss-bridge/index.php" => "index.php"
*/
function trim_path_prefix(string $filePath): string
{
return mb_substr($filePath, mb_strlen(dirname(__DIR__)) + 1);
}
/**
* This is buggy because strip tags removes a lot that isn't html
*/
function is_html(string $text): bool
{
return strlen(strip_tags($text)) !== strlen($text);
}
/**
* Determines the MIME type from a URL/Path file extension.
*
* _Remarks_:
*
* * The built-in functions `mime_content_type` and `fileinfo` require fetching
* remote contents.
* * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`).
*
* Based on https://stackoverflow.com/a/1147952
*
* @param string $url The URL or path to the file.
* @return string The MIME type of the file.
*/
function parse_mime_type($url)
{
static $mime = null;
if (is_null($mime)) {
// Default values, overriden by /etc/mime.types when present
$mime = [
'jpg' => 'image/jpeg',
'gif' => 'image/gif',
'png' => 'image/png',
'image' => 'image/*',
'mp3' => 'audio/mpeg',
];
// '@' is used to mute open_basedir warning, see issue #818
if (@is_readable('/etc/mime.types')) {
$file = fopen('/etc/mime.types', 'r');
while (($line = fgets($file)) !== false) {
$line = trim(preg_replace('/#.*/', '', $line));
if (!$line) {
continue;
}
$parts = preg_split('/\s+/', $line);
if (count($parts) == 1) {
continue;
}
$type = array_shift($parts);
foreach ($parts as $part) {
$mime[$part] = $type;
}
}
fclose($file);
}
}
if (strpos($url, '?') !== false) {
$url_temp = substr($url, 0, strpos($url, '?'));
if (strpos($url, '#') !== false) {
$anchor = substr($url, strpos($url, '#'));
$url_temp .= $anchor;
}
$url = $url_temp;
}
$ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));
if (!empty($mime[$ext])) {
return $mime[$ext];
}
return 'application/octet-stream';
}

View file

@ -10,6 +10,8 @@
--> -->
<config name="testVersion" value="7.4-"/> <config name="testVersion" value="7.4-"/>
<rule ref="PHPCompatibility"> <rule ref="PHPCompatibility">
<!-- This sniff is very overzealous and inaccurate, so we'll disable it -->
<exclude name="PHPCompatibility.Extensions.RemovedExtensions"/>
</rule> </rule>
</ruleset> </ruleset>

View file

@ -9,6 +9,7 @@
<rule ref="PSR12"> <rule ref="PSR12">
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/> <exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<exclude name="PSR1.Classes.ClassDeclaration.MultipleClasses"/>
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/> <exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
<exclude name="PSR12.Properties.ConstantVisibility.NotFound"/> <exclude name="PSR12.Properties.ConstantVisibility.NotFound"/>
</rule> </rule>

View file

@ -0,0 +1,29 @@
<!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>

View file

@ -10,6 +10,9 @@
<br> <br>
<?php if (isset($stacktrace)): ?> <?php if (isset($stacktrace)): ?>
<h2>Stacktrace</h2>
<br>
<?php foreach ($stacktrace as $frame) : ?> <?php foreach ($stacktrace as $frame) : ?>
<code> <code>
<?= e($frame) ?> <?= e($frame) ?>

View file

@ -16,4 +16,19 @@ final class UtilsTest extends TestCase
$this->assertSame('foo', truncate('foo', 4)); $this->assertSame('foo', truncate('foo', 4));
$this->assertSame('fo[...]', truncate('foo', 2, '[...]')); $this->assertSame('fo[...]', truncate('foo', 2, '[...]'));
} }
public function testFileCache()
{
$sut = new \FileCache();
$sut->setScope('scope');
$sut->purgeCache(-1);
$sut->setKey(['key']);
$this->assertNull($sut->loadData());
$sut->saveData('data');
$this->assertSame('data', $sut->loadData());
$this->assertIsNumeric($sut->getTime());
$sut->purgeCache(-1);
}
} }