diff --git a/actions/DetectAction.php b/actions/DetectAction.php new file mode 100644 index 00000000..2ad79a27 --- /dev/null +++ b/actions/DetectAction.php @@ -0,0 +1,50 @@ +userData['url'] + or returnClientError('You must specify a url!'); + + $format = $this->userData['format'] + or returnClientError('You must specify a format!'); + + foreach(Bridge::getBridgeNames() as $bridgeName) { + + if(!Bridge::isWhitelisted($bridgeName)) { + continue; + } + + $bridge = Bridge::create($bridgeName); + + if($bridge === false) { + continue; + } + + $bridgeParams = $bridge->detectParameters($targetURL); + + if(is_null($bridgeParams)) { + continue; + } + + $bridgeParams['bridge'] = $bridgeName; + $bridgeParams['format'] = $format; + + header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301); + die(); + + } + + returnClientError('No bridge found for given URL: ' . $targetURL); + } +} diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php new file mode 100644 index 00000000..6b599329 --- /dev/null +++ b/actions/DisplayAction.php @@ -0,0 +1,234 @@ +userData) ? $this->userData['bridge'] : null; + + $format = $this->userData['format'] + or returnClientError('You must specify a format!'); + + // DEPRECATED: 'nameFormat' scheme is replaced by 'name' in format parameter values + // this is to keep compatibility until futher complete removal + if(($pos = strpos($format, 'Format')) === (strlen($format) - strlen('Format'))) { + $format = substr($format, 0, $pos); + } + + // whitelist control + if(!Bridge::isWhitelisted($bridge)) { + throw new \Exception('This bridge is not whitelisted', 401); + die; + } + + // Data retrieval + $bridge = Bridge::create($bridge); + + $noproxy = array_key_exists('_noproxy', $this->userData) + && filter_var($this->userData['_noproxy'], FILTER_VALIDATE_BOOLEAN); + + if(defined('PROXY_URL') && PROXY_BYBRIDGE && $noproxy) { + define('NOPROXY', true); + } + + // Cache timeout + $cache_timeout = -1; + if(array_key_exists('_cache_timeout', $this->userData)) { + + if(!CUSTOM_CACHE_TIMEOUT) { + unset($this->userData['_cache_timeout']); + $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($this->userData); + header('Location: ' . $uri, true, 301); + die(); + } + + $cache_timeout = filter_var($this->userData['_cache_timeout'], FILTER_VALIDATE_INT); + + } else { + $cache_timeout = $bridge->getCacheTimeout(); + } + + // Remove parameters that don't concern bridges + $bridge_params = array_diff_key( + $this->userData, + array_fill_keys( + array( + 'action', + 'bridge', + 'format', + '_noproxy', + '_cache_timeout', + '_error_time' + ), '') + ); + + // Remove parameters that don't concern caches + $cache_params = array_diff_key( + $this->userData, + array_fill_keys( + array( + 'action', + 'format', + '_noproxy', + '_cache_timeout', + '_error_time' + ), '') + ); + + // Initialize cache + $cache = Cache::create('FileCache'); + $cache->setPath(PATH_CACHE); + $cache->purgeCache(86400); // 24 hours + $cache->setParameters($cache_params); + + $items = array(); + $infos = array(); + $mtime = $cache->getTime(); + + if($mtime !== false + && (time() - $cache_timeout < $mtime) + && !Debug::isEnabled()) { // Load cached data + + // Send "Not Modified" response if client supports it + // Implementation based on https://stackoverflow.com/a/10847262 + if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); + + if($mtime <= $stime) { // Cached data is older or same + header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304); + die(); + } + } + + $cached = $cache->loadData(); + + if(isset($cached['items']) && isset($cached['extraInfos'])) { + foreach($cached['items'] as $item) { + $items[] = new \FeedItem($item); + } + + $infos = $cached['extraInfos']; + } + + } else { // Collect new data + + try { + $bridge->setDatas($bridge_params); + $bridge->collectData(); + + $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])) { + $feedItems = array(); + + foreach($items as $item) { + $feedItems[] = new \FeedItem($item); + } + + $items = $feedItems; + } + + $infos = array( + 'name' => $bridge->getName(), + 'uri' => $bridge->getURI(), + 'icon' => $bridge->getIcon() + ); + } catch(Error $e) { + error_log($e); + + $item = new \FeedItem(); + + // Create "new" error message every 24 hours + $this->userData['_error_time'] = urlencode((int)(time() / 86400)); + + // Error 0 is a special case (i.e. "trying to get property of non-object") + if($e->getCode() === 0) { + $item->setTitle( + 'Bridge encountered an unexpected situation! (' + . $this->userData['_error_time'] + . ')' + ); + } else { + $item->setTitle( + 'Bridge returned error ' + . $e->getCode() + . '! (' + . $this->userData['_error_time'] + . ')' + ); + } + + $item->setURI( + (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') + . '?' + . http_build_query($this->userData) + ); + + $item->setTimestamp(time()); + $item->setContent(buildBridgeException($e, $bridge)); + + $items[] = $item; + } catch(Exception $e) { + error_log($e); + + $item = new \FeedItem(); + + // Create "new" error message every 24 hours + $this->userData['_error_time'] = urlencode((int)(time() / 86400)); + + $item->setURI( + (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') + . '?' + . http_build_query($this->userData) + ); + + $item->setTitle( + 'Bridge returned error ' + . $e->getCode() + . '! (' + . $this->userData['_error_time'] + . ')' + ); + $item->setTimestamp(time()); + $item->setContent(buildBridgeException($e, $bridge)); + + $items[] = $item; + } + + // Store data in cache + $cache->saveData(array( + 'items' => array_map(function($i){ return $i->toArray(); }, $items), + 'extraInfos' => $infos + )); + + } + + // Data transformation + try { + $format = Format::create($format); + $format->setItems($items); + $format->setExtraInfos($infos); + $format->setLastModified($cache->getTime()); + $format->display(); + } catch(Error $e) { + error_log($e); + header('Content-Type: text/html', true, $e->getCode()); + die(buildTransformException($e, $bridge)); + } catch(Exception $e) { + error_log($e); + header('Content-Type: text/html', true, $e->getCode()); + die(buildTransformException($e, $bridge)); + } + } +} diff --git a/actions/ListAction.php b/actions/ListAction.php new file mode 100644 index 00000000..03e06119 --- /dev/null +++ b/actions/ListAction.php @@ -0,0 +1,53 @@ +bridges = array(); + $list->total = 0; + + foreach(Bridge::getBridgeNames() as $bridgeName) { + + $bridge = Bridge::create($bridgeName); + + if($bridge === false) { // Broken bridge, show as inactive + + $list->bridges[$bridgeName] = array( + 'status' => 'inactive' + ); + + continue; + + } + + $status = Bridge::isWhitelisted($bridgeName) ? 'active' : 'inactive'; + + $list->bridges[$bridgeName] = array( + 'status' => $status, + 'uri' => $bridge->getURI(), + 'name' => $bridge->getName(), + 'icon' => $bridge->getIcon(), + 'parameters' => $bridge->getParameters(), + 'maintainer' => $bridge->getMaintainer(), + 'description' => $bridge->getDescription() + ); + + } + + $list->total = count($list->bridges); + + header('Content-Type: application/json'); + echo json_encode($list, JSON_PRETTY_PRINT); + } +} diff --git a/index.php b/index.php index a95302a0..819b5a52 100644 --- a/index.php +++ b/index.php @@ -51,287 +51,15 @@ $whitelist_default = array( try { Bridge::setWhitelist($whitelist_default); + $actionFac = new \ActionFactory(); + $actionFac->setWorkingDir(PATH_LIB_ACTIONS); - $showInactive = filter_input(INPUT_GET, 'show_inactive', FILTER_VALIDATE_BOOLEAN); - $action = array_key_exists('action', $params) ? $params['action'] : null; - $bridge = array_key_exists('bridge', $params) ? $params['bridge'] : null; - - // Return list of bridges as JSON formatted text - if($action === 'list') { - - $list = new StdClass(); - $list->bridges = array(); - $list->total = 0; - - foreach(Bridge::getBridgeNames() as $bridgeName) { - - $bridge = Bridge::create($bridgeName); - - if($bridge === false) { // Broken bridge, show as inactive - - $list->bridges[$bridgeName] = array( - 'status' => 'inactive' - ); - - continue; - - } - - $status = Bridge::isWhitelisted($bridgeName) ? 'active' : 'inactive'; - - $list->bridges[$bridgeName] = array( - 'status' => $status, - 'uri' => $bridge->getURI(), - 'name' => $bridge->getName(), - 'icon' => $bridge->getIcon(), - 'parameters' => $bridge->getParameters(), - 'maintainer' => $bridge->getMaintainer(), - 'description' => $bridge->getDescription() - ); - - } - - $list->total = count($list->bridges); - - header('Content-Type: application/json'); - echo json_encode($list, JSON_PRETTY_PRINT); - - } elseif($action === 'detect') { - - $targetURL = $params['url'] - or returnClientError('You must specify a url!'); - - $format = $params['format'] - or returnClientError('You must specify a format!'); - - foreach(Bridge::getBridgeNames() as $bridgeName) { - - if(!Bridge::isWhitelisted($bridgeName)) { - continue; - } - - $bridge = Bridge::create($bridgeName); - - if($bridge === false) { - continue; - } - - $bridgeParams = $bridge->detectParameters($targetURL); - - if(is_null($bridgeParams)) { - continue; - } - - $bridgeParams['bridge'] = $bridgeName; - $bridgeParams['format'] = $format; - - header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301); - die(); - - } - - returnClientError('No bridge found for given URL: ' . $targetURL); - - } elseif($action === 'display' && !empty($bridge)) { - - $format = $params['format'] - or returnClientError('You must specify a format!'); - - // DEPRECATED: 'nameFormat' scheme is replaced by 'name' in format parameter values - // this is to keep compatibility until futher complete removal - if(($pos = strpos($format, 'Format')) === (strlen($format) - strlen('Format'))) { - $format = substr($format, 0, $pos); - } - - // whitelist control - if(!Bridge::isWhitelisted($bridge)) { - throw new \Exception('This bridge is not whitelisted', 401); - die; - } - - // Data retrieval - $bridge = Bridge::create($bridge); - - $noproxy = array_key_exists('_noproxy', $params) && filter_var($params['_noproxy'], FILTER_VALIDATE_BOOLEAN); - if(defined('PROXY_URL') && PROXY_BYBRIDGE && $noproxy) { - define('NOPROXY', true); - } - - // Cache timeout - $cache_timeout = -1; - if(array_key_exists('_cache_timeout', $params)) { - - if(!CUSTOM_CACHE_TIMEOUT) { - unset($params['_cache_timeout']); - $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($params); - header('Location: ' . $uri, true, 301); - die(); - } - - $cache_timeout = filter_var($params['_cache_timeout'], FILTER_VALIDATE_INT); - - } else { - $cache_timeout = $bridge->getCacheTimeout(); - } - - // Remove parameters that don't concern bridges - $bridge_params = array_diff_key( - $params, - array_fill_keys( - array( - 'action', - 'bridge', - 'format', - '_noproxy', - '_cache_timeout', - '_error_time' - ), '') - ); - - // Remove parameters that don't concern caches - $cache_params = array_diff_key( - $params, - array_fill_keys( - array( - 'action', - 'format', - '_noproxy', - '_cache_timeout', - '_error_time' - ), '') - ); - - // Initialize cache - $cache = Cache::create('FileCache'); - $cache->setPath(PATH_CACHE); - $cache->purgeCache(86400); // 24 hours - $cache->setParameters($cache_params); - - $items = array(); - $infos = array(); - $mtime = $cache->getTime(); - - if($mtime !== false - && (time() - $cache_timeout < $mtime) - && !Debug::isEnabled()) { // Load cached data - - // Send "Not Modified" response if client supports it - // Implementation based on https://stackoverflow.com/a/10847262 - if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { - $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); - - if($mtime <= $stime) { // Cached data is older or same - header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304); - die(); - } - } - - $cached = $cache->loadData(); - - if(isset($cached['items']) && isset($cached['extraInfos'])) { - foreach($cached['items'] as $item) { - $items[] = new \FeedItem($item); - } - - $infos = $cached['extraInfos']; - } - - } else { // Collect new data - - try { - $bridge->setDatas($bridge_params); - $bridge->collectData(); - - $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])) { - $feedItems = array(); - - foreach($items as $item) { - $feedItems[] = new \FeedItem($item); - } - - $items = $feedItems; - } - - $infos = array( - 'name' => $bridge->getName(), - 'uri' => $bridge->getURI(), - 'icon' => $bridge->getIcon() - ); - } catch(Error $e) { - error_log($e); - - $item = new \FeedItem(); - - // Create "new" error message every 24 hours - $params['_error_time'] = urlencode((int)(time() / 86400)); - - // Error 0 is a special case (i.e. "trying to get property of non-object") - if($e->getCode() === 0) { - $item->setTitle('Bridge encountered an unexpected situation! (' . $params['_error_time'] . ')'); - } else { - $item->setTitle('Bridge returned error ' . $e->getCode() . '! (' . $params['_error_time'] . ')'); - } - - $item->setURI( - (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') - . '?' - . http_build_query($params) - ); - - $item->setTimestamp(time()); - $item->setContent(buildBridgeException($e, $bridge)); - - $items[] = $item; - } catch(Exception $e) { - error_log($e); - - $item = new \FeedItem(); - - // Create "new" error message every 24 hours - $params['_error_time'] = urlencode((int)(time() / 86400)); - - $item->setURI( - (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') - . '?' - . http_build_query($params) - ); - - $item->setTitle('Bridge returned error ' . $e->getCode() . '! (' . $params['_error_time'] . ')'); - $item->setTimestamp(time()); - $item->setContent(buildBridgeException($e, $bridge)); - - $items[] = $item; - } - - // Store data in cache - $cache->saveData(array( - 'items' => array_map(function($i){ return $i->toArray(); }, $items), - 'extraInfos' => $infos - )); - - } - - // Data transformation - try { - $format = Format::create($format); - $format->setItems($items); - $format->setExtraInfos($infos); - $format->setLastModified($cache->getTime()); - $format->display(); - } catch(Error $e) { - error_log($e); - header('Content-Type: text/html', true, $e->getCode()); - die(buildTransformException($e, $bridge)); - } catch(Exception $e) { - error_log($e); - header('Content-Type: text/html', true, $e->getCode()); - die(buildTransformException($e, $bridge)); - } + if(array_key_exists('action', $params)) { + $action = $actionFac->create($params['action']); + $action->setUserData($params); + $action->execute(); } else { + $showInactive = filter_input(INPUT_GET, 'show_inactive', FILTER_VALIDATE_BOOLEAN); echo BridgeList::create($showInactive); } } catch(\Exception $e) { diff --git a/lib/ActionAbstract.php b/lib/ActionAbstract.php new file mode 100644 index 00000000..b925d609 --- /dev/null +++ b/lib/ActionAbstract.php @@ -0,0 +1,33 @@ +userData = $userData; + } +} diff --git a/lib/ActionFactory.php b/lib/ActionFactory.php new file mode 100644 index 00000000..8146e542 --- /dev/null +++ b/lib/ActionFactory.php @@ -0,0 +1,65 @@ +buildFilePath($name); + + if(!file_exists($filePath)) { + throw new \Exception('File ' . $filePath . ' does not exist!'); + } + + require_once $filePath; + + $class = $this->buildClassName($name); + + if((new \ReflectionClass($class))->isInstantiable()) { + return new $class(); + } + + return false; + } + + /** + * Build class name from action name + * + * The class name consists of the action name with prefix "Action". The first + * character of the class name must be uppercase. + * + * Example: 'display' => 'DisplayAction' + * + * @param string $name The action name. + * @return string The class name. + */ + protected function buildClassName($name) { + return ucfirst(strtolower($name)) . 'Action'; + } + + /** + * Build file path to the action class. + * + * @param string $name The action name. + * @return string Path to the action class. + */ + protected function buildFilePath($name) { + return $this->getWorkingDir() . $this->buildClassName($name) . '.php'; + } +} diff --git a/lib/ActionInterface.php b/lib/ActionInterface.php new file mode 100644 index 00000000..c38d057a --- /dev/null +++ b/lib/ActionInterface.php @@ -0,0 +1,34 @@ +workingDir = null; + + if(!is_string($dir)) { + throw new \InvalidArgumentException('Working directory must be a string!'); + } + + if(!file_exists($dir)) { + throw new \Exception('Working directory does not exist!'); + } + + if(!is_dir($dir)) { + throw new \InvalidArgumentException($dir . ' is not a directory!'); + } + + $this->workingDir = realpath($dir) . '/'; + } + + /** + * Get the working directory + * + * @return string The working directory. + */ + public function getWorkingDir() { + if(is_null($this->workingDir)) { + throw new \LogicException('Working directory is not set!'); + } + + return $this->workingDir; + } + + /** + * Creates a new instance for the object specified by name. + * + * @param string $name The name of the object to create. + * @return object The object instance + */ + abstract public function create($name); +} diff --git a/lib/rssbridge.php b/lib/rssbridge.php index bc8c8d04..5a523588 100644 --- a/lib/rssbridge.php +++ b/lib/rssbridge.php @@ -29,6 +29,9 @@ define('PATH_LIB_FORMATS', __DIR__ . '/../formats/'); /** Path to the caches library */ define('PATH_LIB_CACHES', __DIR__ . '/../caches/'); +/** Path to the actions library */ +define('PATH_LIB_ACTIONS', __DIR__ . '/../actions/'); + /** Path to the cache folder */ define('PATH_CACHE', __DIR__ . '/../cache/'); @@ -39,11 +42,13 @@ define('WHITELIST', __DIR__ . '/../whitelist.txt'); define('REPOSITORY', 'https://github.com/RSS-Bridge/rss-bridge/'); // Interfaces +require_once PATH_LIB . 'ActionInterface.php'; require_once PATH_LIB . 'BridgeInterface.php'; require_once PATH_LIB . 'CacheInterface.php'; require_once PATH_LIB . 'FormatInterface.php'; // Classes +require_once PATH_LIB . 'FactoryAbstract.php'; require_once PATH_LIB . 'FeedItem.php'; require_once PATH_LIB . 'Debug.php'; require_once PATH_LIB . 'Exceptions.php'; @@ -58,6 +63,8 @@ require_once PATH_LIB . 'Configuration.php'; require_once PATH_LIB . 'BridgeCard.php'; require_once PATH_LIB . 'BridgeList.php'; require_once PATH_LIB . 'ParameterValidator.php'; +require_once PATH_LIB . 'ActionFactory.php'; +require_once PATH_LIB . 'ActionAbstract.php'; // Functions require_once PATH_LIB . 'html.php'; diff --git a/phpunit.xml b/phpunit.xml index 4fe1ae0e..62937c47 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,9 @@ tests + + tests + diff --git a/tests/ActionImplementationTest.php b/tests/ActionImplementationTest.php new file mode 100644 index 00000000..554432f3 --- /dev/null +++ b/tests/ActionImplementationTest.php @@ -0,0 +1,59 @@ +setAction($path); + $this->assertTrue($this->class === ucfirst($this->class), 'class name must start with uppercase character'); + $this->assertEquals(0, substr_count($this->class, ' '), 'class name must not contain spaces'); + $this->assertStringEndsWith('Action', $this->class, 'class name must end with "Action"'); + } + + /** + * @dataProvider dataActionsProvider + */ + public function testClassType($path) { + $this->setAction($path); + $this->assertInstanceOf(ActionInterface::class, $this->obj); + } + + /** + * @dataProvider dataActionsProvider + */ + public function testVisibleMethods($path) { + $allowedActionAbstract = get_class_methods(ActionAbstract::class); + sort($allowedActionAbstract); + + $this->setAction($path); + + $methods = get_class_methods($this->obj); + sort($methods); + + $this->assertEquals($allowedActionAbstract, $methods); + } + + //////////////////////////////////////////////////////////////////////////// + + public function dataActionsProvider() { + $actions = array(); + foreach (glob(PATH_LIB_ACTIONS . '*.php') as $path) { + $actions[basename($path, '.php')] = array($path); + } + return $actions; + } + + private function setAction($path) { + require_once $path; + $this->class = basename($path, '.php'); + $this->assertTrue(class_exists($this->class), 'class ' . $this->class . ' doesn\'t exist'); + $this->obj = new $this->class(); + } +} diff --git a/tests/ListActionTest.php b/tests/ListActionTest.php new file mode 100644 index 00000000..7f625882 --- /dev/null +++ b/tests/ListActionTest.php @@ -0,0 +1,90 @@ +initAction(); + + $this->assertContains( + 'Content-Type: application/json', + xdebug_get_headers() + ); + } + + /** + * @runInSeparateProcess + */ + public function testOutput() { + $this->initAction(); + + $items = json_decode($this->data, true); + + $this->assertNotNull($items, 'invalid JSON output: ' . json_last_error_msg()); + + $this->assertArrayHasKey('total', $items, 'Missing "total" parameter'); + $this->assertInternalType('int', $items['total'], 'Invalid type'); + + $this->assertArrayHasKey('bridges', $items, 'Missing "bridges" array'); + + $this->assertEquals( + $items['total'], + count($items['bridges']), + 'Item count doesn\'t match' + ); + + $this->assertEquals( + count(Bridge::getBridgeNames()), + count($items['bridges']), + 'Number of bridges doesn\'t match' + ); + + $expectedKeys = array( + 'status', + 'uri', + 'name', + 'icon', + 'parameters', + 'maintainer', + 'description' + ); + + $allowedStatus = array( + 'active', + 'inactive' + ); + + foreach($items['bridges'] as $bridge) { + foreach($expectedKeys as $key) { + $this->assertArrayHasKey($key, $bridge, 'Missing key "' . $key . '"'); + } + + $this->assertContains($bridge['status'], $allowedStatus, 'Invalid status value'); + } + } + + //////////////////////////////////////////////////////////////////////////// + + private function initAction() { + $actionFac = new ActionFactory(); + $actionFac->setWorkingDir(PATH_LIB_ACTIONS); + + $this->action = $actionFac->create('list'); + $this->action->setUserData(array()); /* no user data required */ + + ob_start(); + $this->action->execute(); + $this->data = ob_get_contents(); + ob_clean(); + ob_end_flush(); + } +}