summaryrefslogtreecommitdiff
path: root/vendor/miniflux/picofeed/lib/PicoFeed/Client
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/miniflux/picofeed/lib/PicoFeed/Client')
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/Client.php719
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/ClientException.php14
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/Curl.php402
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/ForbiddenException.php10
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/HttpHeaders.php79
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php12
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidUrlException.php12
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxRedirectException.php12
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxSizeException.php12
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/Stream.php205
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/TimeoutException.php12
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/UnauthorizedException.php10
-rw-r--r--vendor/miniflux/picofeed/lib/PicoFeed/Client/Url.php290
13 files changed, 1789 insertions, 0 deletions
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/Client.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Client.php
new file mode 100644
index 00000000..0548d5c6
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Client.php
@@ -0,0 +1,719 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use DateTime;
+use Exception;
+use LogicException;
+use PicoFeed\Logging\Logger;
+use PicoFeed\Config\Config;
+
+/**
+ * Client class.
+ *
+ * @author Frederic Guillot
+ */
+abstract class Client
+{
+ /**
+ * Flag that say if the resource have been modified.
+ *
+ * @var bool
+ */
+ private $is_modified = true;
+
+ /**
+ * HTTP Content-Type.
+ *
+ * @var string
+ */
+ private $content_type = '';
+
+ /**
+ * HTTP encoding.
+ *
+ * @var string
+ */
+ private $encoding = '';
+
+ /**
+ * HTTP request headers.
+ *
+ * @var array
+ */
+ protected $request_headers = array();
+
+ /**
+ * HTTP Etag header.
+ *
+ * @var string
+ */
+ protected $etag = '';
+
+ /**
+ * HTTP Last-Modified header.
+ *
+ * @var string
+ */
+ protected $last_modified = '';
+
+ /**
+ * Expiration DateTime
+ *
+ * @var DateTime
+ */
+ protected $expiration = null;
+
+ /**
+ * Proxy hostname.
+ *
+ * @var string
+ */
+ protected $proxy_hostname = '';
+
+ /**
+ * Proxy port.
+ *
+ * @var int
+ */
+ protected $proxy_port = 3128;
+
+ /**
+ * Proxy username.
+ *
+ * @var string
+ */
+ protected $proxy_username = '';
+
+ /**
+ * Proxy password.
+ *
+ * @var string
+ */
+ protected $proxy_password = '';
+
+ /**
+ * Basic auth username.
+ *
+ * @var string
+ */
+ protected $username = '';
+
+ /**
+ * Basic auth password.
+ *
+ * @var string
+ */
+ protected $password = '';
+
+ /**
+ * Client connection timeout.
+ *
+ * @var int
+ */
+ protected $timeout = 10;
+
+ /**
+ * User-agent.
+ *
+ * @var string
+ */
+ protected $user_agent = 'PicoFeed (https://github.com/miniflux/picoFeed)';
+
+ /**
+ * Real URL used (can be changed after a HTTP redirect).
+ *
+ * @var string
+ */
+ protected $url = '';
+
+ /**
+ * Page/Feed content.
+ *
+ * @var string
+ */
+ protected $content = '';
+
+ /**
+ * Number maximum of HTTP redirections to avoid infinite loops.
+ *
+ * @var int
+ */
+ protected $max_redirects = 5;
+
+ /**
+ * Maximum size of the HTTP body response.
+ *
+ * @var int
+ */
+ protected $max_body_size = 2097152; // 2MB
+
+ /**
+ * HTTP response status code.
+ *
+ * @var int
+ */
+ protected $status_code = 0;
+
+ /**
+ * Enables direct passthrough to requesting client.
+ *
+ * @var bool
+ */
+ protected $passthrough = false;
+
+ /**
+ * Do the HTTP request.
+ *
+ * @abstract
+ *
+ * @return array
+ */
+ abstract public function doRequest();
+
+ /**
+ * Get client instance: curl or stream driver.
+ *
+ * @static
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public static function getInstance()
+ {
+ if (function_exists('curl_init')) {
+ return new Curl();
+ } elseif (ini_get('allow_url_fopen')) {
+ return new Stream();
+ }
+
+ throw new LogicException('You must have "allow_url_fopen=1" or curl extension installed');
+ }
+
+ /**
+ * Add HTTP Header to the request.
+ *
+ * @param array $headers
+ */
+ public function setHeaders($headers)
+ {
+ $this->request_headers = $headers;
+ }
+
+ /**
+ * Perform the HTTP request.
+ *
+ * @param string $url URL
+ *
+ * @return Client
+ */
+ public function execute($url = '')
+ {
+ if ($url !== '') {
+ $this->url = $url;
+ }
+
+ Logger::setMessage(get_called_class().' Fetch URL: '.$this->url);
+ Logger::setMessage(get_called_class().' Etag provided: '.$this->etag);
+ Logger::setMessage(get_called_class().' Last-Modified provided: '.$this->last_modified);
+
+ $response = $this->doRequest();
+
+ $this->status_code = $response['status'];
+ $this->handleNotModifiedResponse($response);
+ $this->handleErrorResponse($response);
+ $this->handleNormalResponse($response);
+
+ $this->expiration = $this->parseExpiration($response['headers']);
+ Logger::setMessage(get_called_class().' Expiration: '.$this->expiration->format(DATE_ISO8601));
+
+ return $this;
+ }
+
+ /**
+ * Handle not modified response.
+ *
+ * @param array $response Client response
+ */
+ protected function handleNotModifiedResponse(array $response)
+ {
+ if ($response['status'] == 304) {
+ $this->is_modified = false;
+ } elseif ($response['status'] == 200) {
+ $this->is_modified = $this->hasBeenModified($response, $this->etag, $this->last_modified);
+ $this->etag = $this->getHeader($response, 'ETag');
+ $this->last_modified = $this->getHeader($response, 'Last-Modified');
+ }
+
+ if ($this->is_modified === false) {
+ Logger::setMessage(get_called_class().' Resource not modified');
+ }
+ }
+
+ /**
+ * Handle Http Error codes
+ *
+ * @param array $response Client response
+ * @throws ForbiddenException
+ * @throws InvalidUrlException
+ * @throws UnauthorizedException
+ */
+ protected function handleErrorResponse(array $response)
+ {
+ $status = $response['status'];
+ if ($status == 401) {
+ throw new UnauthorizedException('Wrong or missing credentials');
+ } else if ($status == 403) {
+ throw new ForbiddenException('Not allowed to access resource');
+ } else if ($status == 404) {
+ throw new InvalidUrlException('Resource not found');
+ }
+ }
+
+ /**
+ * Handle normal response.
+ *
+ * @param array $response Client response
+ */
+ protected function handleNormalResponse(array $response)
+ {
+ if ($response['status'] == 200) {
+ $this->content = $response['body'];
+ $this->content_type = $this->findContentType($response);
+ $this->encoding = $this->findCharset();
+ }
+ }
+
+ /**
+ * Check if a request has been modified according to the parameters.
+ *
+ * @param array $response
+ * @param string $etag
+ * @param string $lastModified
+ *
+ * @return bool
+ */
+ private function hasBeenModified($response, $etag, $lastModified)
+ {
+ $headers = array(
+ 'Etag' => $etag,
+ 'Last-Modified' => $lastModified,
+ );
+
+ // Compare the values for each header that is present
+ $presentCacheHeaderCount = 0;
+ foreach ($headers as $key => $value) {
+ if (isset($response['headers'][$key])) {
+ if ($response['headers'][$key] !== $value) {
+ return true;
+ }
+ ++$presentCacheHeaderCount;
+ }
+ }
+
+ // If at least one header is present and the values match, the response
+ // was not modified
+ if ($presentCacheHeaderCount > 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Find content type from response headers.
+ *
+ * @param array $response Client response
+ *
+ * @return string
+ */
+ public function findContentType(array $response)
+ {
+ return strtolower($this->getHeader($response, 'Content-Type'));
+ }
+
+ /**
+ * Find charset from response headers.
+ *
+ * @return string
+ */
+ public function findCharset()
+ {
+ $result = explode('charset=', $this->content_type);
+
+ return isset($result[1]) ? $result[1] : '';
+ }
+
+ /**
+ * Get header value from a client response.
+ *
+ * @param array $response Client response
+ * @param string $header Header name
+ *
+ * @return string
+ */
+ public function getHeader(array $response, $header)
+ {
+ return isset($response['headers'][$header]) ? $response['headers'][$header] : '';
+ }
+
+ /**
+ * Set the Last-Modified HTTP header.
+ *
+ * @param string $last_modified Header value
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setLastModified($last_modified)
+ {
+ $this->last_modified = $last_modified;
+
+ return $this;
+ }
+
+ /**
+ * Get the value of the Last-Modified HTTP header.
+ *
+ * @return string
+ */
+ public function getLastModified()
+ {
+ return $this->last_modified;
+ }
+
+ /**
+ * Set the value of the Etag HTTP header.
+ *
+ * @param string $etag Etag HTTP header value
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setEtag($etag)
+ {
+ $this->etag = $etag;
+
+ return $this;
+ }
+
+ /**
+ * Get the Etag HTTP header value.
+ *
+ * @return string
+ */
+ public function getEtag()
+ {
+ return $this->etag;
+ }
+
+ /**
+ * Get the final url value.
+ *
+ * @return string
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the url.
+ *
+ * @param $url
+ * @return string
+ */
+ public function setUrl($url)
+ {
+ $this->url = $url;
+ return $this;
+ }
+
+ /**
+ * Get the HTTP response status code.
+ *
+ * @return int
+ */
+ public function getStatusCode()
+ {
+ return $this->status_code;
+ }
+
+ /**
+ * Get the body of the HTTP response.
+ *
+ * @return string
+ */
+ public function getContent()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Get the content type value from HTTP headers.
+ *
+ * @return string
+ */
+ public function getContentType()
+ {
+ return $this->content_type;
+ }
+
+ /**
+ * Get the encoding value from HTTP headers.
+ *
+ * @return string
+ */
+ public function getEncoding()
+ {
+ return $this->encoding;
+ }
+
+ /**
+ * Return true if the remote resource has changed.
+ *
+ * @return bool
+ */
+ public function isModified()
+ {
+ return $this->is_modified;
+ }
+
+ /**
+ * return true if passthrough mode is enabled.
+ *
+ * @return bool
+ */
+ public function isPassthroughEnabled()
+ {
+ return $this->passthrough;
+ }
+
+ /**
+ * Set connection timeout.
+ *
+ * @param int $timeout Connection timeout
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setTimeout($timeout)
+ {
+ $this->timeout = $timeout ?: $this->timeout;
+
+ return $this;
+ }
+
+ /**
+ * Set a custom user agent.
+ *
+ * @param string $user_agent User Agent
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setUserAgent($user_agent)
+ {
+ $this->user_agent = $user_agent ?: $this->user_agent;
+
+ return $this;
+ }
+
+ /**
+ * Set the maximum number of HTTP redirections.
+ *
+ * @param int $max Maximum
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setMaxRedirections($max)
+ {
+ $this->max_redirects = $max ?: $this->max_redirects;
+
+ return $this;
+ }
+
+ /**
+ * Set the maximum size of the HTTP body.
+ *
+ * @param int $max Maximum
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setMaxBodySize($max)
+ {
+ $this->max_body_size = $max ?: $this->max_body_size;
+
+ return $this;
+ }
+
+ /**
+ * Set the proxy hostname.
+ *
+ * @param string $hostname Proxy hostname
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setProxyHostname($hostname)
+ {
+ $this->proxy_hostname = $hostname ?: $this->proxy_hostname;
+
+ return $this;
+ }
+
+ /**
+ * Set the proxy port.
+ *
+ * @param int $port Proxy port
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setProxyPort($port)
+ {
+ $this->proxy_port = $port ?: $this->proxy_port;
+
+ return $this;
+ }
+
+ /**
+ * Set the proxy username.
+ *
+ * @param string $username Proxy username
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setProxyUsername($username)
+ {
+ $this->proxy_username = $username ?: $this->proxy_username;
+
+ return $this;
+ }
+
+ /**
+ * Set the proxy password.
+ *
+ * @param string $password Password
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setProxyPassword($password)
+ {
+ $this->proxy_password = $password ?: $this->proxy_password;
+
+ return $this;
+ }
+
+ /**
+ * Set the username.
+ *
+ * @param string $username Basic Auth username
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setUsername($username)
+ {
+ $this->username = $username ?: $this->username;
+
+ return $this;
+ }
+
+ /**
+ * Set the password.
+ *
+ * @param string $password Basic Auth Password
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setPassword($password)
+ {
+ $this->password = $password ?: $this->password;
+
+ return $this;
+ }
+
+ /**
+ * Enable the passthrough mode.
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function enablePassthroughMode()
+ {
+ $this->passthrough = true;
+
+ return $this;
+ }
+
+ /**
+ * Disable the passthrough mode.
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function disablePassthroughMode()
+ {
+ $this->passthrough = false;
+
+ return $this;
+ }
+
+ /**
+ * Set config object.
+ *
+ * @param \PicoFeed\Config\Config $config Config instance
+ *
+ * @return \PicoFeed\Client\Client
+ */
+ public function setConfig(Config $config)
+ {
+ if ($config !== null) {
+ $this->setTimeout($config->getClientTimeout());
+ $this->setUserAgent($config->getClientUserAgent());
+ $this->setMaxRedirections($config->getMaxRedirections());
+ $this->setMaxBodySize($config->getMaxBodySize());
+ $this->setProxyHostname($config->getProxyHostname());
+ $this->setProxyPort($config->getProxyPort());
+ $this->setProxyUsername($config->getProxyUsername());
+ $this->setProxyPassword($config->getProxyPassword());
+ }
+
+ return $this;
+ }
+
+ /**
+ * Return true if the HTTP status code is a redirection
+ *
+ * @access protected
+ * @param integer $code
+ * @return boolean
+ */
+ public function isRedirection($code)
+ {
+ return $code == 301 || $code == 302 || $code == 303 || $code == 307;
+ }
+
+ public function parseExpiration(HttpHeaders $headers)
+ {
+ try {
+
+ if (isset($headers['Cache-Control'])) {
+ if (preg_match('/s-maxage=(\d+)/', $headers['Cache-Control'], $matches)) {
+ return new DateTime('+' . $matches[1] . ' seconds');
+ } else if (preg_match('/max-age=(\d+)/', $headers['Cache-Control'], $matches)) {
+ return new DateTime('+' . $matches[1] . ' seconds');
+ }
+ }
+
+ if (! empty($headers['Expires'])) {
+ return new DateTime($headers['Expires']);
+ }
+ } catch (Exception $e) {
+ Logger::setMessage('Unable to parse expiration date: '.$e->getMessage());
+ }
+
+ return new DateTime();
+ }
+
+ /**
+ * Get expiration date time from "Expires" or "Cache-Control" headers
+ *
+ * @return DateTime
+ */
+ public function getExpiration()
+ {
+ return $this->expiration ?: new DateTime();
+ }
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/ClientException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/ClientException.php
new file mode 100644
index 00000000..b3a95c9f
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/ClientException.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use PicoFeed\PicoFeedException;
+
+/**
+ * ClientException Exception.
+ *
+ * @author Frederic Guillot
+ */
+abstract class ClientException extends PicoFeedException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/Curl.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Curl.php
new file mode 100644
index 00000000..f4a65782
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Curl.php
@@ -0,0 +1,402 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use PicoFeed\Logging\Logger;
+
+/**
+ * cURL HTTP client.
+ *
+ * @author Frederic Guillot
+ */
+class Curl extends Client
+{
+ protected $nbRedirects = 0;
+
+ /**
+ * HTTP response body.
+ *
+ * @var string
+ */
+ private $body = '';
+
+ /**
+ * Body size.
+ *
+ * @var int
+ */
+ private $body_length = 0;
+
+ /**
+ * HTTP response headers.
+ *
+ * @var array
+ */
+ private $response_headers = array();
+
+ /**
+ * Counter on the number of header received.
+ *
+ * @var int
+ */
+ private $response_headers_count = 0;
+
+ /**
+ * cURL callback to read the HTTP body.
+ *
+ * If the function return -1, curl stop to read the HTTP response
+ *
+ * @param resource $ch cURL handler
+ * @param string $buffer Chunk of data
+ *
+ * @return int Length of the buffer
+ */
+ public function readBody($ch, $buffer)
+ {
+ $length = strlen($buffer);
+ $this->body_length += $length;
+
+ if ($this->body_length > $this->max_body_size) {
+ return -1;
+ }
+
+ $this->body .= $buffer;
+
+ return $length;
+ }
+
+ /**
+ * cURL callback to read HTTP headers.
+ *
+ * @param resource $ch cURL handler
+ * @param string $buffer Header line
+ *
+ * @return int Length of the buffer
+ */
+ public function readHeaders($ch, $buffer)
+ {
+ $length = strlen($buffer);
+
+ if ($buffer === "\r\n" || $buffer === "\n") {
+ ++$this->response_headers_count;
+ } else {
+ if (!isset($this->response_headers[$this->response_headers_count])) {
+ $this->response_headers[$this->response_headers_count] = '';
+ }
+
+ $this->response_headers[$this->response_headers_count] .= $buffer;
+ }
+
+ return $length;
+ }
+
+ /**
+ * cURL callback to passthrough the HTTP body to the client.
+ *
+ * If the function return -1, curl stop to read the HTTP response
+ *
+ * @param resource $ch cURL handler
+ * @param string $buffer Chunk of data
+ *
+ * @return int Length of the buffer
+ */
+ public function passthroughBody($ch, $buffer)
+ {
+ // do it only at the beginning of a transmission
+ if ($this->body_length === 0) {
+ list($status, $headers) = HttpHeaders::parse(explode("\n", $this->response_headers[$this->response_headers_count - 1]));
+
+ if ($this->isRedirection($status)) {
+ return $this->handleRedirection($headers['Location']);
+ }
+
+ // Do not work with PHP-FPM
+ if (strpos(PHP_SAPI, 'cgi') !== false) {
+ header(':', true, $status);
+ }
+
+ if (isset($headers['Content-Type'])) {
+ header('Content-Type:' .$headers['Content-Type']);
+ }
+ }
+
+ $length = strlen($buffer);
+ $this->body_length += $length;
+
+ echo $buffer;
+
+ return $length;
+ }
+
+ /**
+ * Prepare HTTP headers.
+ *
+ * @return string[]
+ */
+ private function prepareHeaders()
+ {
+ $headers = array(
+ 'Connection: close',
+ );
+
+ if ($this->etag) {
+ $headers[] = 'If-None-Match: '.$this->etag;
+ $headers[] = 'A-IM: feed';
+ }
+
+ if ($this->last_modified) {
+ $headers[] = 'If-Modified-Since: '.$this->last_modified;
+ }
+
+ $headers = array_merge($headers, $this->request_headers);
+
+ return $headers;
+ }
+
+ /**
+ * Prepare curl proxy context.
+ *
+ * @param resource $ch
+ *
+ * @return resource $ch
+ */
+ private function prepareProxyContext($ch)
+ {
+ if ($this->proxy_hostname) {
+ Logger::setMessage(get_called_class().' Proxy: '.$this->proxy_hostname.':'.$this->proxy_port);
+
+ curl_setopt($ch, CURLOPT_PROXYPORT, $this->proxy_port);
+ curl_setopt($ch, CURLOPT_PROXYTYPE, 'HTTP');
+ curl_setopt($ch, CURLOPT_PROXY, $this->proxy_hostname);
+
+ if ($this->proxy_username) {
+ Logger::setMessage(get_called_class().' Proxy credentials: Yes');
+ curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->proxy_username.':'.$this->proxy_password);
+ } else {
+ Logger::setMessage(get_called_class().' Proxy credentials: No');
+ }
+ }
+
+ return $ch;
+ }
+
+ /**
+ * Prepare curl auth context.
+ *
+ * @param resource $ch
+ *
+ * @return resource $ch
+ */
+ private function prepareAuthContext($ch)
+ {
+ if ($this->username && $this->password) {
+ curl_setopt($ch, CURLOPT_USERPWD, $this->username.':'.$this->password);
+ }
+
+ return $ch;
+ }
+
+ /**
+ * Set write/header functions.
+ *
+ * @param resource $ch
+ *
+ * @return resource $ch
+ */
+ private function prepareDownloadMode($ch)
+ {
+ $this->body = '';
+ $this->response_headers = array();
+ $this->response_headers_count = 0;
+ $write_function = 'readBody';
+ $header_function = 'readHeaders';
+
+ if ($this->isPassthroughEnabled()) {
+ $write_function = 'passthroughBody';
+ }
+
+ curl_setopt($ch, CURLOPT_WRITEFUNCTION, array($this, $write_function));
+ curl_setopt($ch, CURLOPT_HEADERFUNCTION, array($this, $header_function));
+
+ return $ch;
+ }
+
+ /**
+ * Prepare curl context.
+ *
+ * @return resource
+ */
+ private function prepareContext()
+ {
+ $ch = curl_init();
+
+ curl_setopt($ch, CURLOPT_URL, $this->url);
+ curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
+ curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
+ curl_setopt($ch, CURLOPT_USERAGENT, $this->user_agent);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $this->prepareHeaders());
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+ curl_setopt($ch, CURLOPT_ENCODING, '');
+ curl_setopt($ch, CURLOPT_COOKIEJAR, 'php://memory');
+ curl_setopt($ch, CURLOPT_COOKIEFILE, 'php://memory');
+
+ // Disable SSLv3 by enforcing TLSv1.x for curl >= 7.34.0 and < 7.39.0.
+ // Versions prior to 7.34 and at least when compiled against openssl
+ // interpret this parameter as "limit to TLSv1.0" which fails for sites
+ // which enforce TLS 1.1+.
+ // Starting with curl 7.39.0 SSLv3 is disabled by default.
+ $version = curl_version();
+ if ($version['version_number'] >= 467456 && $version['version_number'] < 468736) {
+ curl_setopt($ch, CURLOPT_SSLVERSION, 1);
+ }
+
+ $ch = $this->prepareDownloadMode($ch);
+ $ch = $this->prepareProxyContext($ch);
+ $ch = $this->prepareAuthContext($ch);
+
+ return $ch;
+ }
+
+ /**
+ * Execute curl context.
+ */
+ private function executeContext()
+ {
+ $ch = $this->prepareContext();
+ curl_exec($ch);
+
+ Logger::setMessage(get_called_class().' cURL total time: '.curl_getinfo($ch, CURLINFO_TOTAL_TIME));
+ Logger::setMessage(get_called_class().' cURL dns lookup time: '.curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME));
+ Logger::setMessage(get_called_class().' cURL connect time: '.curl_getinfo($ch, CURLINFO_CONNECT_TIME));
+ Logger::setMessage(get_called_class().' cURL speed download: '.curl_getinfo($ch, CURLINFO_SPEED_DOWNLOAD));
+ Logger::setMessage(get_called_class().' cURL effective url: '.curl_getinfo($ch, CURLINFO_EFFECTIVE_URL));
+
+ $curl_errno = curl_errno($ch);
+
+ if ($curl_errno) {
+ Logger::setMessage(get_called_class().' cURL error: '.curl_error($ch));
+ curl_close($ch);
+
+ $this->handleError($curl_errno);
+ }
+
+ // Update the url if there where redirects
+ $this->url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
+
+ curl_close($ch);
+ }
+
+ /**
+ * Do the HTTP request.
+ *
+ * @return array HTTP response ['body' => ..., 'status' => ..., 'headers' => ...]
+ */
+ public function doRequest()
+ {
+ $this->executeContext();
+
+ list($status, $headers) = HttpHeaders::parse(explode("\n", $this->response_headers[$this->response_headers_count - 1]));
+
+ if ($this->isRedirection($status)) {
+ if (empty($headers['Location'])) {
+ $status = 200;
+ } else {
+ return $this->handleRedirection($headers['Location']);
+ }
+ }
+
+ return array(
+ 'status' => $status,
+ 'body' => $this->body,
+ 'headers' => $headers,
+ );
+ }
+
+ /**
+ * Handle HTTP redirects
+ *
+ * @param string $location Redirected URL
+ * @return array
+ * @throws MaxRedirectException
+ */
+ private function handleRedirection($location)
+ {
+ $result = array();
+ $this->url = Url::resolve($location, $this->url);
+ $this->body = '';
+ $this->body_length = 0;
+ $this->response_headers = array();
+ $this->response_headers_count = 0;
+
+ while (true) {
+ $this->nbRedirects++;
+
+ if ($this->nbRedirects >= $this->max_redirects) {
+ throw new MaxRedirectException('Maximum number of redirections reached');
+ }
+
+ $result = $this->doRequest();
+
+ if ($this->isRedirection($result['status'])) {
+ $this->url = Url::resolve($result['headers']['Location'], $this->url);
+ $this->body = '';
+ $this->body_length = 0;
+ $this->response_headers = array();
+ $this->response_headers_count = 0;
+ } else {
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Handle cURL errors (throw individual exceptions).
+ *
+ * We don't use constants because they are not necessary always available
+ * (depends of the version of libcurl linked to php)
+ *
+ * @see http://curl.haxx.se/libcurl/c/libcurl-errors.html
+ *
+ * @param int $errno cURL error code
+ * @throws InvalidCertificateException
+ * @throws InvalidUrlException
+ * @throws MaxRedirectException
+ * @throws MaxSizeException
+ * @throws TimeoutException
+ */
+ private function handleError($errno)
+ {
+ switch ($errno) {
+ case 78: // CURLE_REMOTE_FILE_NOT_FOUND
+ throw new InvalidUrlException('Resource not found', $errno);
+ case 6: // CURLE_COULDNT_RESOLVE_HOST
+ throw new InvalidUrlException('Unable to resolve hostname', $errno);
+ case 7: // CURLE_COULDNT_CONNECT
+ throw new InvalidUrlException('Unable to connect to the remote host', $errno);
+ case 23: // CURLE_WRITE_ERROR
+ throw new MaxSizeException('Maximum response size exceeded', $errno);
+ case 28: // CURLE_OPERATION_TIMEDOUT
+ throw new TimeoutException('Operation timeout', $errno);
+ case 35: // CURLE_SSL_CONNECT_ERROR
+ case 51: // CURLE_PEER_FAILED_VERIFICATION
+ case 58: // CURLE_SSL_CERTPROBLEM
+ case 60: // CURLE_SSL_CACERT
+ case 59: // CURLE_SSL_CIPHER
+ case 64: // CURLE_USE_SSL_FAILED
+ case 66: // CURLE_SSL_ENGINE_INITFAILED
+ case 77: // CURLE_SSL_CACERT_BADFILE
+ case 83: // CURLE_SSL_ISSUER_ERROR
+ $msg = 'Invalid SSL certificate caused by CURL error number ' . $errno;
+ throw new InvalidCertificateException($msg, $errno);
+ case 47: // CURLE_TOO_MANY_REDIRECTS
+ throw new MaxRedirectException('Maximum number of redirections reached', $errno);
+ case 63: // CURLE_FILESIZE_EXCEEDED
+ throw new MaxSizeException('Maximum response size exceeded', $errno);
+ default:
+ throw new InvalidUrlException('Unable to fetch the URL', $errno);
+ }
+ }
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/ForbiddenException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/ForbiddenException.php
new file mode 100644
index 00000000..c226e95a
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/ForbiddenException.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * @author Bernhard Posselt
+ */
+class ForbiddenException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/HttpHeaders.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/HttpHeaders.php
new file mode 100644
index 00000000..34b81399
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/HttpHeaders.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use ArrayAccess;
+use PicoFeed\Logging\Logger;
+
+/**
+ * Class to handle HTTP headers case insensitivity.
+ *
+ * @author Bernhard Posselt
+ * @author Frederic Guillot
+ */
+class HttpHeaders implements ArrayAccess
+{
+ private $headers = array();
+
+ public function __construct(array $headers)
+ {
+ foreach ($headers as $key => $value) {
+ $this->headers[strtolower($key)] = $value;
+ }
+ }
+
+ public function offsetGet($offset)
+ {
+ return $this->offsetExists($offset) ? $this->headers[strtolower($offset)] : '';
+ }
+
+ public function offsetSet($offset, $value)
+ {
+ $this->headers[strtolower($offset)] = $value;
+ }
+
+ public function offsetExists($offset)
+ {
+ return isset($this->headers[strtolower($offset)]);
+ }
+
+ public function offsetUnset($offset)
+ {
+ unset($this->headers[strtolower($offset)]);
+ }
+
+ /**
+ * Parse HTTP headers.
+ *
+ * @static
+ *
+ * @param array $lines List of headers
+ *
+ * @return array
+ */
+ public static function parse(array $lines)
+ {
+ $status = 0;
+ $headers = array();
+
+ foreach ($lines as $line) {
+ if (strpos($line, 'HTTP/1') === 0) {
+ $headers = array();
+ $status = (int) substr($line, 9, 3);
+ } elseif (strpos($line, ': ') !== false) {
+ list($name, $value) = explode(': ', $line);
+ if ($value) {
+ $headers[trim($name)] = trim($value);
+ }
+ }
+ }
+
+ Logger::setMessage(get_called_class().' HTTP status code: '.$status);
+
+ foreach ($headers as $name => $value) {
+ Logger::setMessage(get_called_class().' HTTP header: '.$name.' => '.$value);
+ }
+
+ return array($status, new self($headers));
+ }
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php
new file mode 100644
index 00000000..8d25d7e4
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * InvalidCertificateException Exception.
+ *
+ * @author Frederic Guillot
+ */
+class InvalidCertificateException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidUrlException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidUrlException.php
new file mode 100644
index 00000000..15534d98
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/InvalidUrlException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * InvalidUrlException Exception.
+ *
+ * @author Frederic Guillot
+ */
+class InvalidUrlException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxRedirectException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxRedirectException.php
new file mode 100644
index 00000000..0a221af6
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxRedirectException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * MaxRedirectException Exception.
+ *
+ * @author Frederic Guillot
+ */
+class MaxRedirectException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxSizeException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxSizeException.php
new file mode 100644
index 00000000..201b22a6
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/MaxSizeException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * MaxSizeException Exception.
+ *
+ * @author Frederic Guillot
+ */
+class MaxSizeException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/Stream.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Stream.php
new file mode 100644
index 00000000..2e91d472
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Stream.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use PicoFeed\Logging\Logger;
+
+/**
+ * Stream context HTTP client.
+ *
+ * @author Frederic Guillot
+ */
+class Stream extends Client
+{
+ /**
+ * Prepare HTTP headers.
+ *
+ * @return string[]
+ */
+ private function prepareHeaders()
+ {
+ $headers = array(
+ 'Connection: close',
+ 'User-Agent: '.$this->user_agent,
+ );
+
+ // disable compression in passthrough mode. It could result in double
+ // compressed content which isn't decodeable by browsers
+ if (function_exists('gzdecode') && !$this->isPassthroughEnabled()) {
+ $headers[] = 'Accept-Encoding: gzip';
+ }
+
+ if ($this->etag) {
+ $headers[] = 'If-None-Match: '.$this->etag;
+ $headers[] = 'A-IM: feed';
+ }
+
+ if ($this->last_modified) {
+ $headers[] = 'If-Modified-Since: '.$this->last_modified;
+ }
+
+ if ($this->proxy_username) {
+ $headers[] = 'Proxy-Authorization: Basic '.base64_encode($this->proxy_username.':'.$this->proxy_password);
+ }
+
+ if ($this->username && $this->password) {
+ $headers[] = 'Authorization: Basic '.base64_encode($this->username.':'.$this->password);
+ }
+
+ $headers = array_merge($headers, $this->request_headers);
+
+ return $headers;
+ }
+
+ /**
+ * Construct the final URL from location headers.
+ *
+ * @param array $headers List of HTTP response header
+ */
+ private function setEffectiveUrl($headers)
+ {
+ foreach ($headers as $header) {
+ if (stripos($header, 'Location') === 0) {
+ list(, $value) = explode(': ', $header);
+
+ $this->url = Url::resolve($value, $this->url);
+ }
+ }
+ }
+
+ /**
+ * Prepare stream context.
+ *
+ * @return array
+ */
+ private function prepareContext()
+ {
+ $context = array(
+ 'http' => array(
+ 'method' => 'GET',
+ 'protocol_version' => 1.1,
+ 'timeout' => $this->timeout,
+ 'max_redirects' => $this->max_redirects,
+ ),
+ );
+
+ if ($this->proxy_hostname) {
+ Logger::setMessage(get_called_class().' Proxy: '.$this->proxy_hostname.':'.$this->proxy_port);
+
+ $context['http']['proxy'] = 'tcp://'.$this->proxy_hostname.':'.$this->proxy_port;
+ $context['http']['request_fulluri'] = true;
+
+ if ($this->proxy_username) {
+ Logger::setMessage(get_called_class().' Proxy credentials: Yes');
+ } else {
+ Logger::setMessage(get_called_class().' Proxy credentials: No');
+ }
+ }
+
+ $context['http']['header'] = implode("\r\n", $this->prepareHeaders());
+
+ return $context;
+ }
+
+ /**
+ * Do the HTTP request.
+ *
+ * @return array HTTP response ['body' => ..., 'status' => ..., 'headers' => ...]
+ * @throws InvalidUrlException
+ * @throws MaxSizeException
+ * @throws TimeoutException
+ */
+ public function doRequest()
+ {
+ $body = '';
+
+ // Create context
+ $context = stream_context_create($this->prepareContext());
+
+ // Make HTTP request
+ $stream = @fopen($this->url, 'r', false, $context);
+ if (!is_resource($stream)) {
+ throw new InvalidUrlException('Unable to establish a connection');
+ }
+
+ // Get HTTP headers response
+ $metadata = stream_get_meta_data($stream);
+ list($status, $headers) = HttpHeaders::parse($metadata['wrapper_data']);
+
+ if ($this->isPassthroughEnabled()) {
+ header(':', true, $status);
+
+ if (isset($headers['Content-Type'])) {
+ header('Content-Type: '.$headers['Content-Type']);
+ }
+
+ fpassthru($stream);
+ } else {
+ // Get the entire body until the max size
+ $body = stream_get_contents($stream, $this->max_body_size + 1);
+
+ // If the body size is too large abort everything
+ if (strlen($body) > $this->max_body_size) {
+ throw new MaxSizeException('Content size too large');
+ }
+
+ if ($metadata['timed_out']) {
+ throw new TimeoutException('Operation timeout');
+ }
+ }
+
+ fclose($stream);
+
+ $this->setEffectiveUrl($metadata['wrapper_data']);
+
+ return array(
+ 'status' => $status,
+ 'body' => $this->decodeBody($body, $headers),
+ 'headers' => $headers,
+ );
+ }
+
+ /**
+ * Decode body response according to the HTTP headers.
+ *
+ * @param string $body Raw body
+ * @param HttpHeaders $headers HTTP headers
+ *
+ * @return string
+ */
+ public function decodeBody($body, HttpHeaders $headers)
+ {
+ if (isset($headers['Transfer-Encoding']) && $headers['Transfer-Encoding'] === 'chunked') {
+ $body = $this->decodeChunked($body);
+ }
+
+ if (isset($headers['Content-Encoding']) && $headers['Content-Encoding'] === 'gzip') {
+ $body = gzdecode($body);
+ }
+
+ return $body;
+ }
+
+ /**
+ * Decode a chunked body.
+ *
+ * @param string $str Raw body
+ *
+ * @return string Decoded body
+ */
+ public function decodeChunked($str)
+ {
+ for ($result = ''; !empty($str); $str = trim($str)) {
+
+ // Get the chunk length
+ $pos = strpos($str, "\r\n");
+ $len = hexdec(substr($str, 0, $pos));
+
+ // Append the chunk to the result
+ $result .= substr($str, $pos + 2, $len);
+ $str = substr($str, $pos + 2 + $len);
+ }
+
+ return $result;
+ }
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/TimeoutException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/TimeoutException.php
new file mode 100644
index 00000000..da98da12
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/TimeoutException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * TimeoutException Exception.
+ *
+ * @author Frederic Guillot
+ */
+class TimeoutException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/UnauthorizedException.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/UnauthorizedException.php
new file mode 100644
index 00000000..81898b99
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/UnauthorizedException.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * @author Bernhard Posselt
+ */
+class UnauthorizedException extends ClientException
+{
+}
diff --git a/vendor/miniflux/picofeed/lib/PicoFeed/Client/Url.php b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Url.php
new file mode 100644
index 00000000..a9337988
--- /dev/null
+++ b/vendor/miniflux/picofeed/lib/PicoFeed/Client/Url.php
@@ -0,0 +1,290 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * URL class.
+ *
+ * @author Frederic Guillot
+ */
+class Url
+{
+ /**
+ * URL.
+ *
+ * @var string
+ */
+ private $url = '';
+
+ /**
+ * URL components.
+ *
+ * @var array
+ */
+ private $components = array();
+
+ /**
+ * Constructor.
+ *
+ * @param string $url URL
+ */
+ public function __construct($url)
+ {
+ $this->url = $url;
+ $this->components = parse_url($url) ?: array();
+
+ // Issue with PHP < 5.4.7 and protocol relative url
+ if (version_compare(PHP_VERSION, '5.4.7', '<') && $this->isProtocolRelative()) {
+ $pos = strpos($this->components['path'], '/', 2);
+
+ if ($pos === false) {
+ $pos = strlen($this->components['path']);
+ }
+
+ $this->components['host'] = substr($this->components['path'], 2, $pos - 2);
+ $this->components['path'] = substr($this->components['path'], $pos);
+ }
+ }
+
+ /**
+ * Shortcut method to get an absolute url from relative url.
+ *
+ * @static
+ *
+ * @param mixed $item_url Unknown url (can be relative or not)
+ * @param mixed $website_url Website url
+ *
+ * @return string
+ */
+ public static function resolve($item_url, $website_url)
+ {
+ $link = is_string($item_url) ? new self($item_url) : $item_url;
+ $website = is_string($website_url) ? new self($website_url) : $website_url;
+
+ if ($link->isRelativeUrl()) {
+ if ($link->isRelativePath()) {
+ return $link->getAbsoluteUrl($website->getBaseUrl($website->getBasePath()));
+ }
+
+ return $link->getAbsoluteUrl($website->getBaseUrl());
+ } elseif ($link->isProtocolRelative()) {
+ $link->setScheme($website->getScheme());
+ }
+
+ return $link->getAbsoluteUrl();
+ }
+
+ /**
+ * Shortcut method to get a base url.
+ *
+ * @static
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ public static function base($url)
+ {
+ $link = new self($url);
+
+ return $link->getBaseUrl();
+ }
+
+ /**
+ * Get the base URL.
+ *
+ * @param string $suffix Add a suffix to the url
+ *
+ * @return string
+ */
+ public function getBaseUrl($suffix = '')
+ {
+ return $this->hasHost() ? $this->getScheme('://').$this->getHost().$this->getPort(':').$suffix : '';
+ }
+
+ /**
+ * Get the absolute URL.
+ *
+ * @param string $base_url Use this url as base url
+ *
+ * @return string
+ */
+ public function getAbsoluteUrl($base_url = '')
+ {
+ if ($base_url) {
+ $base = new self($base_url);
+ $url = $base->getAbsoluteUrl().substr($this->getFullPath(), 1);
+ } else {
+ $url = $this->hasHost() ? $this->getBaseUrl().$this->getFullPath() : '';
+ }
+
+ return $url;
+ }
+
+ /**
+ * Return true if the url is relative.
+ *
+ * @return bool
+ */
+ public function isRelativeUrl()
+ {
+ return !$this->hasScheme() && !$this->isProtocolRelative();
+ }
+
+ /**
+ * Return true if the path is relative.
+ *
+ * @return bool
+ */
+ public function isRelativePath()
+ {
+ $path = $this->getPath();
+
+ return empty($path) || $path{0}
+ !== '/';
+ }
+
+ /**
+ * Filters the path of a URI.
+ *
+ * Imported from Guzzle library: https://github.com/guzzle/psr7/blob/master/src/Uri.php#L568-L582
+ *
+ * @param $path
+ *
+ * @return string
+ */
+ public function filterPath($path, $charUnreserved = 'a-zA-Z0-9_\-\.~', $charSubDelims = '!\$&\'\(\)\*\+,;=')
+ {
+ return preg_replace_callback(
+ '/(?:[^'.$charUnreserved.$charSubDelims.':@\/%]+|%(?![A-Fa-f0-9]{2}))/',
+ function (array $matches) { return rawurlencode($matches[0]); },
+ $path
+ );
+ }
+
+ /**
+ * Get the path.
+ *
+ * @return string
+ */
+ public function getPath()
+ {
+ return $this->filterPath(empty($this->components['path']) ? '' : $this->components['path']);
+ }
+
+ /**
+ * Get the base path.
+ *
+ * @return string
+ */
+ public function getBasePath()
+ {
+ $current_path = $this->getPath();
+
+ $path = $this->isRelativePath() ? '/' : '';
+ $path .= substr($current_path, -1) === '/' ? $current_path : dirname($current_path);
+
+ return preg_replace('/\\\\\/|\/\//', '/', $path.'/');
+ }
+
+ /**
+ * Get the full path (path + querystring + fragment).
+ *
+ * @return string
+ */
+ public function getFullPath()
+ {
+ $path = $this->isRelativePath() ? '/' : '';
+ $path .= $this->getPath();
+ $path .= empty($this->components['query']) ? '' : '?'.$this->components['query'];
+ $path .= empty($this->components['fragment']) ? '' : '#'.$this->components['fragment'];
+
+ return $path;
+ }
+
+ /**
+ * Get the hostname.
+ *
+ * @return string
+ */
+ public function getHost()
+ {
+ return empty($this->components['host']) ? '' : $this->components['host'];
+ }
+
+ /**
+ * Return true if the url has a hostname.
+ *
+ * @return bool
+ */
+ public function hasHost()
+ {
+ return !empty($this->components['host']);
+ }
+
+ /**
+ * Get the scheme.
+ *
+ * @param string $suffix Suffix to add when there is a scheme
+ *
+ * @return string
+ */
+ public function getScheme($suffix = '')
+ {
+ return ($this->hasScheme() ? $this->components['scheme'] : 'http').$suffix;
+ }
+
+ /**
+ * Set the scheme.
+ *
+ * @param string $scheme Set a scheme
+ *
+ * @return string
+ */
+ public function setScheme($scheme)
+ {
+ $this->components['scheme'] = $scheme;
+ }
+
+ /**
+ * Return true if the url has a scheme.
+ *
+ * @return bool
+ */
+ public function hasScheme()
+ {
+ return !empty($this->components['scheme']);
+ }
+
+ /**
+ * Get the port.
+ *
+ * @param string $prefix Prefix to add when there is a port
+ *
+ * @return string
+ */
+ public function getPort($prefix = '')
+ {
+ return $this->hasPort() ? $prefix.$this->components['port'] : '';
+ }
+
+ /**
+ * Return true if the url has a port.
+ *
+ * @return bool
+ */
+ public function hasPort()
+ {
+ return !empty($this->components['port']);
+ }
+
+ /**
+ * Return true if the url is protocol relative (start with //).
+ *
+ * @return bool
+ */
+ public function isProtocolRelative()
+ {
+ return strpos($this->url, '//') === 0;
+ }
+}