<?php
/**
 * Implementation of the Yadis Specification 1.0 protocol for service
 * discovery from an Identity URI/XRI or other.
 *
 * PHP version 5
 *
 * LICENSE:
 *
 * Copyright (c) 2007 Pádraic Brady <padraic.brady@yahoo.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *    * Redistributions of source code must retain the above copyright
 *      notice, this list of conditions and the following disclaimer.
 *    * Redistributions in binary form must reproduce the above copyright
 *      notice, this list of conditions and the following disclaimer in the
 *      documentation and/or other materials provided with the distribution.
 *    * The name of the author may not be used to endorse or promote products
 *      derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @category Services
 * @package  Services_Yadis
 * @author   Pádraic Brady <padraic.brady@yahoo.com>
 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
 * @link     http://pear.php.net/Services_Yadis
 */

/**
 *  required files
 */
require_once 'Services/Yadis/Xrds/Service.php';
require_once 'Services/Yadis/Exception.php';
require_once 'Services/Yadis/Xrds/Namespace.php';
require_once 'Services/Yadis/Xri.php';
require_once 'HTTP/Request2.php';
require_once 'Validate.php';

/**
 * Services_Yadis class
 *
 * Services_Yadis will provide a method of Service Discovery implemented
 * in accordance with the Yadis Specification 1.0. This describes a protocol
 * for locating an XRD document which details Services available. The XRD is
 * typically specific to a single user, identified by their Yadis ID.
 * Services_Yadis_XRDS will be a wrapper which is responsible for parsing
 * and presenting an iterable list of Services_Yadis_Service objects
 * holding the data for each specific Service discovered.
 *
 * Note that class comments cannot substitute for a full understanding of the
 * rules and nuances required to implement the Yadis protocol. Where doubt
 * exists, refer to the Yadis Specification 1.0 at:
 *      http://yadis.org/papers/yadis-v1.0.pdf
 * Departures from the specification should be regarded as bugs ;).
 *
 * Example usage:
 *
 *      Example 1: OpenID Service Discovery
 *
 *      $openid = 'http://padraic.astrumfutura.com';
 *      $yadis = new Services_Yadis($openid);
 *      $yadis->addNamespace('openid', 'http://openid.net/xmlns/1.0');
 *      $serviceList = $yadis->discover();
 *
 *      foreach ($serviceList as $service) {
 *          $types = $service->getTypes();
 *          echo $types[0], ' at ', implode(', ', $service->getUris()), PHP_EOL;
 *          echo 'Priority is ', $service->->getPriority(), PHP_EOL;
 *      }
 *
 *      Possible Result @index[0] (indicates we may send Auth 2.0 requests for
 *      OpenID):
 *
 *      http://specs.openid.net/auth/2.0/server at http://www.myopenid.com/server
 *      Priority is 0
 *
 * @category Services
 * @package  Services_Yadis
 * @author   Pádraic Brady <padraic.brady@yahoo.com>
 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
 * @link     http://pear.php.net/Services_Yadis
 */
class Services_Yadis
{

    /**
     * Constants referring to Yadis response types
     */
    const XRDS_META_HTTP_EQUIV = 2;
    const XRDS_LOCATION_HEADER = 4;
    const XRDS_CONTENT_TYPE    = 8;

    /**
     * The current Yadis ID; this is the raw form initially submitted prior
     * to any transformation/validation as an URL. This *may* allow IRI support
     * in the future given IRIs map to URIs and adoption of the IRI standard
     * and are entering common use internationally.
     *
     * @var string
     */
    protected $yadisId = '';

    /**
     * The current Yadis URL; this is a URL either validated or transformed
     * from the initial Yadis ID. This URL is used to make the initial HTTP
     * GET request during Service Discovery.
     *
     * @var string
     */
    protected $yadisUrl = '';

    /**
     * Holds the response received during Service Discovery.
     *
     * @var HTTP_Request2_Response
     */
    protected $httpResponse = null;

    /**
     * A URL parsed from a HTML document's <meta> element inserted in
     * accordance with the Yadis Specification and which points to a Yadis
     * XRD document.
     *
     * @var string
     */
    protected $metaHttpEquivUrl = '';

    /**
     * A URI parsed from an X-XRDS-Location response-header. This value must
     * point to a Yadis XRD document otherwise the Yadis discovery process
     * should be considered to have failed.
     *
     * @var string
     */
    protected $xrdsLocationHeaderUrl = '';

    /**
     * Instance of Services_Yadis_Xrds_Namespace for managing namespaces
     * associated with an XRDS document.
     *
     * @var Services_Yadis_Xrds_Namespace
     */
    protected $namespace = null;

    /**
     * Array of valid HTML Content-Types. Required since Yadis states agents
     * must parse a document if received as the first response and with an
     * MIME type indicating HTML or XHTML. Listed in order of priority, with
     * HTML taking priority over XHTML.
     *
     * @link http://www.w3.org/International/articles/serving-xhtml/Overview.en.php
     * @var array
     */
    protected $validHtmlContentTypes = array(
        'text/html',
        'application/xhtml+xml',
        'application/xml',
        'text/xml'
    );

    /*
     * Array of characters which if found at the 0 index of a Yadis ID string
     * may indicate the use of an XRI.
     *
     * @var array
     */
    protected $xriIdentifiers = array(
        '=', '$', '!', '@', '+'
    );

    protected $httpRequestOptions = array();

    /**
     * HTTP_Request2 object utilised by this class if externally set
     *
     * @var HTTP_Request2
     */
    protected $httpRequest = null;

    /**
     * Class Constructor
     *
     * Allows settings of the initial Yadis ID (an OpenID URL for example) and
     * an optional list of additional namespaces. For example, OpenID uses a
     * namespace such as: xmlns:openid="http://openid.net/xmlns/1.0"
     * Namespaces are assigned to a Services_Yadis_Xrds_Namespace container
     * object to be passed more easily to other objects being
     *
     * @param string $yadisId    Optional Yadis ID
     * @param array  $namespaces Optional array of namespaces
     *
     * @return void
     */
    public function __construct($yadisId = null, array $namespaces = array())
    {
        $this->namespace = new Services_Yadis_Xrds_Namespace;
        $this->addNamespaces($namespaces);
        if (isset($yadisId)) {
            $this->setYadisId($yadisId);
        }
    }

    /**
     * Set options to be passed to the PEAR HTTP_Request2 constructor
     *
     * @param array $options Array of options for HTTP_Request
     *
     * @return Services_Yadis
     */
    public function setHttpRequestOptions(array $options)
    {
        $this->httpRequestOptions = $options;
        return $this;
    }

    /**
     * Get options to be passed to the PEAR HTTP_Request2 constructor
     *
     * @return array
     */
    public function getHttpRequestOptions()
    {
        return $this->httpRequestOptions;
    }

    /**
     * A Yadis ID is usually an URL, but can also include an IRI, or XRI i-name.
     * The initial version will support URLs as standard before examining options
     * for supporting alternatives (IRI,XRI,i-name) since they require additional
     * validation and conversion steps (e.g. Punycode for IRI) before use.
     *
     * Note: The current Validate classes currently do not have complete IDNA
     * validation support for Internationalised Domain Names. To be addressed.
     *
     * @param string $yadisId The Yadis ID
     *
     * @return void
     */
    public function setYadisId($yadisId)
    {
        $this->yadisId = $yadisId;
        $this->setYadisUrl($yadisId);
    }

    /**
     * Returns the original Yadis ID string set for this class.
     *
     * @return string
     */
    public function getYadisId()
    {
        if (!isset($this->yadisId)) {
            throw new Services_Yadis_Exception(
                'No Yadis ID has been set on this object yet'
            );
        }
        return $this->yadisId;
    }

    /**
     * Attempts to create a valid URI based on the value of the parameter
     * which would typically be the Yadis ID.
     * Note: This currently only supports XRI transformations.
     *
     * @param string $yadisId The Yadis ID
     *
     * @return Services_Yadis
     * @throws Services_Yadis_Exception
     */
    public function setYadisUrl($yadisId)
    {
        /**
         * This step should validate IDNs (see ZF-881)
         */
        if (self::validateURI($yadisId)) {
            $this->yadisUrl = $yadisId;
            return $this;
        }

        /**
         * Check if the Yadis ID is an XRI
         */
        if (stripos($yadisId, 'xri://') === 0 ||
            in_array($yadisId[0], $this->xriIdentifiers)) {

            $xri = Services_Yadis_Xri::getInstance();

            $xri->setHttpRequestOptions($this->getHttpRequestOptions());
            $this->yadisUrl = $xri->setNamespace($this->namespace)->toUri($yadisId);

            return $this;
        }

        /**
         * The use of IRIs (International Resource Identifiers) is governed by
         * RFC 3490-3495. Not yet available for validation in PEAR.
         */
        throw new Services_Yadis_Exception(
            'Unable to validate a Yadis ID as a URI, '
            . 'or to transform a Yadis ID into a valid URI.'
        );
    }

    /**
     * Returns the Yadis URL. This will usually be identical to the Yadis ID,
     * unless the Yadis ID (in the future) was one of IRI, XRI or i-name which
     * required transformation to a valid URI.
     *
     * @return string
     */
    public function getYadisUrl()
    {
        if (!isset($this->yadisUrl)) {
            throw new Services_Yadis_Exception(
                'No Yadis ID/URL has been set on this object yet'
            );
        }
        return $this->yadisUrl;
    }

    /**
     * Add a list (array) of additional namespaces to be utilised by the XML
     * parser when it receives a valid XRD document.
     *
     * @param array $namespaces Array of namespaces
     *
     * @return  Services_Yadis
     */
    public function addNamespaces(array $namespaces)
    {
        $this->namespace->addNamespaces($namespaces);
        return $this;
    }

    /**
     * Add a single namespace to be utilised by the XML parser when it receives
     * a valid XRD document.
     *
     * @param string $namespace    The namespace name
     * @param string $namespaceUrl The namespace url
     *
     * @return Services_Yadis
     */
    public function addNamespace($namespace, $namespaceUrl)
    {
        $this->namespace->addNamespace($namespace, $namespaceUrl);
        return $this;
    }

    /**
     * Return the value of a specific namespace.
     *
     * @param string $namespace Namespace name
     *
     * @return string|null
     */
    public function getNamespace($namespace)
    {
        return $this->namespace->getNamespace($namespace);
    }

    /**
     * Returns an array of all currently set namespaces.
     *
     * @return array
     */
    public function getNamespaces()
    {
        return $this->namespace->getNamespaces();
    }

    /**
     * Performs Service Discovery, i.e. the requesting and parsing of a valid
     * Yadis (XRD) document into a list of Services and Service Data. The
     * return value will be an instance of Services_Yadis_Xrds which will
     * implement SeekableIterator. Returns FALSE on failure.
     *
     * @return Services_Yadis_Xrds|boolean
     * @throws Services_Yadis_Exception
     */
    public function discover()
    {
        $currentUri   = $this->getYadisUrl();
        $xrdsDocument = null;
        $request      = null;
        $xrdStatus    = false;

        // Check XRI first
        if (in_array($this->yadisId[0], $this->xriIdentifiers)) {
            $xri                = Services_Yadis_Xri::getInstance();
            $xrds               = $xri->toCanonicalID($xri->getXri());
            $this->httpResponse = $xri->getHTTPResponse();

            return new Services_Yadis_Xrds_Service($xrds, $this->namespace);
        }

        while ($xrdsDocument === null) {
            $this->httpResponse = $this->get($currentUri);
            $responseType = $this->getResponseType($this->httpResponse);

            /**
             * If prior response type was a location header, or a http-equiv
             * content value, then it should have contained a valid URI to
             * an XRD document. Each of these when detected would set the
             * xrdStatus flag to true.
             */
            if (!$responseType == self::XRDS_CONTENT_TYPE && $xrdStatus == true) {
                throw new Services_Yadis_Exception(
                    'Yadis protocol could not locate a valid XRD document'
                );
            }

            /**
             * The Yadis Spec 1.0 specifies that we must use a valid response
             * header in preference to other responses. So even if we receive
             * an XRDS Content-Type, if it also includes an X-XRDS-Location
             * header we must request the Location URI and ignore the response
             * body.
             */
            switch($responseType) {
            case self::XRDS_LOCATION_HEADER:
                $xrdStatus  = true;
                $currentUri = $this->xrdsLocationHeaderUrl;
                break;
            case self::XRDS_META_HTTP_EQUIV:
                $xrdStatus  = true;
                $currentUri = $this->metaHttpEquivUrl;
                break;
            case self::XRDS_CONTENT_TYPE:
                $xrdsDocument = $this->httpResponse->getBody();
                break;
            default:
                throw new Services_Yadis_Exception(
                    'Yadis protocol could not locate a valid XRD document'
                );
            }
        }

        try {
            $serviceList = $this->parseXrds($xrdsDocument);
        } catch (PEAR_Exception $e) {
            throw new Services_Yadis_Exception(
                'XRD Document could not be parsed with the following message: '
                . $e->getMessage(), $e->getCode());
        }
        return $serviceList;
    }

    /**
     * Return the final HTTP response
     *
     * @return HTTP_Request2_Response
     */
    public function getHTTPResponse()
    {
        if ($this->httpResponse instanceof HTTP_Request2_Response) {
            return $this->httpResponse;
        }
        return null;
    }

    /**
     * Setter for custom HTTP_Request2 type object
     *
     * @param HTTP_Request2 $request Instance of HTTP_Request2
     *
     * @return void
     */
    public function setHttpRequest(HTTP_Request2 $request)
    {
        $this->httpRequest = $request;
    }

    /**
     * Gets the HTTP_Request2 object
     *
     * @return HTTP_Request2
     */
    public function getHttpRequest()
    {
        if ($this->httpRequest === null) {
            $this->httpRequest = new HTTP_Request2();
            $this->httpRequest->setConfig($this->getHttpRequestOptions());
        }
        return $this->httpRequest;
    }

    /**
     * Run any instance of HTTP_Request2_Response through a set of filters to
     * determine the Yadis Response type which in turns determines how the
     * response should be reacted to or dealt with.
     *
     * @param HTTP_Request2_Response $request Instance of HTTP_Request2_Response
     *
     * @return integer
     */
    protected function getResponseType(HTTP_Request2_Response $response)
    {
        if ($this->isXrdsContentType($response)) {
            return self::XRDS_CONTENT_TYPE;
        } elseif ($this->isXrdsLocationHeader($response)) {
            return self::XRDS_LOCATION_HEADER;
        } elseif ($this->isMetaHttpEquiv($response)) {
            return self::XRDS_META_HTTP_EQUIV;
        }
        return false;
    }

    /**
     * Use the HTTP_Request2 to issue an HTTP GET request carrying the
     * "Accept" header value of "application/xrds+xml". This can allow
     * servers to quickly respond with a valid XRD document rather than
     * forcing the client to follow the X-XRDS-Location bread crumb trail.
     *
     * @param string $url URL
     *
     * @return HTTP_Request2
     */
    protected function get($url)
    {
        $request = $this->getHttpRequest();
        $request->setUrl($url);
        $request->setMethod(HTTP_REQUEST2::METHOD_GET);
        $request->setHeader('Accept', 'application/xrds+xml');
        try {
            return $request->send();
        } catch (HTTP_Request2_Exception $e) {
            throw new Services_Yadis_Exception(
                'Invalid response to Yadis protocol received: ' . $e->getMessage(),
                $e->getMessage()
            );
        }
    }

    /**
     * Checks whether the Response contains headers which detail where
     * we can find the XRDS resource for this user. If exists, the value
     * is set to the private $xrdsLocationHeaderUrl property.
     *
     * @param HTTP_Request2_Response $response Instance of HTTP_Request2_Response
     *
     * @return boolean
     */
    protected function isXrdsLocationHeader(HTTP_Request2_Response $response)
    {
        if ($response->getHeader('x-xrds-location')) {
            $location = $response->getHeader('x-xrds-location');
        } elseif ($response->getHeader('x-yadis-location')) {
            $location = $response->getHeader('x-yadis-location');
        }
        if (empty($location)) {
            return false;
        } elseif (!self::validateURI($location)) {
            throw new Services_Yadis_Exception(
                'Invalid URI found during Discovery for location of XRDS document:'
                . htmlentities($location, ENT_QUOTES, 'utf-8')
            );
        }
        $this->xrdsLocationHeaderUrl = $location;
        return true;
    }

    /**
     * Checks whether the Response contains the XRDS resource. It should, per
     * the specifications always be served as application/xrds+xml
     *
     * @param HTTP_Request2_Response $response Instance of HTTP_Request2_Response
     *
     * @return boolean
     */
    protected function isXrdsContentType(HTTP_Request2_Response $response)
    {
        if (!$response->getHeader('Content-Type')
            || stripos($response->getHeader('Content-Type'),
                       'application/xrds+xml') === false) {

            return false;
        }
        return true;
    }

    /**
     * Assuming this user is hosting a third party sourced identity under an
     * alias personal URL, we'll need to check if the website's HTML body
     * has a http-equiv meta element with a content attribute pointing to where
     * we can fetch the XRD document.
     *
     * @param HTTP_Request2_Response $response Instance of HTTP_Request2_Response
     *
     * @return boolean
     * @throws Services_Yadis_Exception
     */
    protected function isMetaHttpEquiv(HTTP_Request2_Response $response)
    {
        $location = null;
        if (!in_array($response->getHeader('Content-Type'),
                      $this->validHtmlContentTypes)) {

            return false;
        }

        /**
         * Find a match for a relevant <meta> element, then iterate through the
         * results to see if a valid http-equiv value and matching content URI
         * exist.
         */
        $html = new DOMDocument();
        $html->loadHTML($response->getBody());
        $head = $html->getElementsByTagName('head');
        if ($head->length > 0) {
            $metas = $head->item(0)->getElementsByTagName('meta');
            if ($metas->length > 0) {
                foreach ($metas as $meta) {
                    $equiv = strtolower($meta->getAttribute('http-equiv'));
                    if ($equiv == 'x-xrds-location'
                        || $equiv == 'x-yadis-location') {

                        $location = $meta->getAttribute('content');
                    }
                }
            }
        }

        if (is_null($location)) {
            return false;
        } elseif (!self::validateURI($location)) {
            throw new Services_Yadis_Exception(
                'The URI parsed from the HTML Alias document appears to be invalid, '
                . 'or could not be found: '
                . htmlentities($location, ENT_QUOTES, 'utf-8')
            );
        }
        /**
         * Should now contain the content value of the http-equiv type pointing
         * to an XRDS resource for the user's Identity Provider, as found by
         * passing the meta regex across the response body.
         */
        $this->metaHttpEquivUrl = $location;
        return true;
    }

    /**
     * Creates a new Services_Yadis_Xrds object which uses SimpleXML to
     * parse the XML into a list of Iterable Services_Yadis_Service
     * objects.
     *
     * @param string $xrdsDocument The plaintext XRDS document
     *
     * @return Services_Yadis_Xrds|boolean
     */
    protected function parseXrds($xrdsDocument)
    {
        $xrds = new SimpleXMLElement($xrdsDocument);
        return new Services_Yadis_Xrds_Service($xrds, $this->namespace);
    }

    /**
     * Wrapper for Validate::uri() to avoid non-static strict warnings
     * 
     * @param string $uri The URI to validate
     * 
     * @return bool
     */
    static public function validateURI($uri)
    {
        static $validate = null;
    
        if ($validate === null) {
            $validate = new Validate();
        }

        return $validate->uri($uri);
    }
}

?>
