From 02826f4395cf5f48e68b7ceb664f9089a960990c Mon Sep 17 00:00:00 2001 From: Matt Porritt Date: Thu, 2 Mar 2023 18:25:52 +1100 Subject: [PATCH] MDL-76929 Libraries: Upgrade phpxmlrpc to 4.10.1 --- lib/phpxmlrpc/Client.php | 2210 +++++++++++------ lib/phpxmlrpc/Encoder.php | 216 +- lib/phpxmlrpc/Exception.php | 7 + .../Exception/FaultResponseException.php | 12 + lib/phpxmlrpc/Exception/HttpException.php | 5 +- .../Exception/NoSuchMethodException.php | 7 + lib/phpxmlrpc/Exception/ParsingException.php | 12 + .../Exception/PhpXmlrpcException.php | 7 +- lib/phpxmlrpc/Exception/ServerException.php | 12 + .../Exception/StateErrorException.php | 12 + .../Exception/TransportException.php | 12 + .../Exception/TypeErrorException.php | 12 + .../Exception/ValueErrorException.php | 12 + lib/phpxmlrpc/Exception/XmlException.php | 7 + lib/phpxmlrpc/Exception/XmlRpcException.php | 7 + lib/phpxmlrpc/Helper/Charset.php | 126 +- lib/phpxmlrpc/Helper/Date.php | 27 +- lib/phpxmlrpc/Helper/Http.php | 148 +- lib/phpxmlrpc/Helper/Interop.php | 42 + lib/phpxmlrpc/Helper/Logger.php | 59 +- lib/phpxmlrpc/Helper/XMLParser.php | 1144 ++++++--- lib/phpxmlrpc/PhpXmlRpc.php | 273 +- lib/phpxmlrpc/Request.php | 516 ++-- lib/phpxmlrpc/Response.php | 213 +- lib/phpxmlrpc/Server.php | 976 +++++--- lib/phpxmlrpc/Traits/CharsetEncoderAware.php | 27 + lib/phpxmlrpc/Traits/DeprecationLogger.php | 40 + lib/phpxmlrpc/Traits/LoggerAware.php | 27 + lib/phpxmlrpc/Traits/ParserAware.php | 28 + lib/phpxmlrpc/Traits/PayloadBearer.php | 45 + lib/phpxmlrpc/Value.php | 673 ++--- lib/phpxmlrpc/Wrapper.php | 668 ++--- lib/phpxmlrpc/readme_moodle.txt | 2 +- lib/thirdpartylibs.xml | 2 +- mnet/lib.php | 12 +- 35 files changed, 5060 insertions(+), 2538 deletions(-) create mode 100644 lib/phpxmlrpc/Exception.php create mode 100644 lib/phpxmlrpc/Exception/FaultResponseException.php create mode 100644 lib/phpxmlrpc/Exception/NoSuchMethodException.php create mode 100644 lib/phpxmlrpc/Exception/ParsingException.php create mode 100644 lib/phpxmlrpc/Exception/ServerException.php create mode 100644 lib/phpxmlrpc/Exception/StateErrorException.php create mode 100644 lib/phpxmlrpc/Exception/TransportException.php create mode 100644 lib/phpxmlrpc/Exception/TypeErrorException.php create mode 100644 lib/phpxmlrpc/Exception/ValueErrorException.php create mode 100644 lib/phpxmlrpc/Exception/XmlException.php create mode 100644 lib/phpxmlrpc/Exception/XmlRpcException.php create mode 100644 lib/phpxmlrpc/Helper/Interop.php create mode 100644 lib/phpxmlrpc/Traits/CharsetEncoderAware.php create mode 100644 lib/phpxmlrpc/Traits/DeprecationLogger.php create mode 100644 lib/phpxmlrpc/Traits/LoggerAware.php create mode 100644 lib/phpxmlrpc/Traits/ParserAware.php create mode 100644 lib/phpxmlrpc/Traits/PayloadBearer.php diff --git a/lib/phpxmlrpc/Client.php b/lib/phpxmlrpc/Client.php index 0fccec7f0d7..9ed54fc5fac 100644 --- a/lib/phpxmlrpc/Client.php +++ b/lib/phpxmlrpc/Client.php @@ -2,65 +2,201 @@ namespace PhpXmlRpc; -use PhpXmlRpc\Helper\Logger; +use PhpXmlRpc\Exception\ValueErrorException; use PhpXmlRpc\Helper\XMLParser; +use PhpXmlRpc\Traits\CharsetEncoderAware; +use PhpXmlRpc\Traits\DeprecationLogger; /** * Used to represent a client of an XML-RPC server. + * + * @property int $errno deprecated - public access left in purely for BC. + * @property string $errstr deprecated - public access left in purely for BC. + * @property string $method deprecated - public access left in purely for BC. Access via getUrl()/__construct() + * @property string $server deprecated - public access left in purely for BC. Access via getUrl()/__construct() + * @property int $port deprecated - public access left in purely for BC. Access via getUrl()/__construct() + * @property string $path deprecated - public access left in purely for BC. Access via getUrl()/__construct() */ class Client { + use DeprecationLogger; + //use CharsetEncoderAware; + const USE_CURL_NEVER = 0; const USE_CURL_ALWAYS = 1; const USE_CURL_AUTO = 2; - protected static $logger; + const OPT_ACCEPTED_CHARSET_ENCODINGS = 'accepted_charset_encodings'; + const OPT_ACCEPTED_COMPRESSION = 'accepted_compression'; + const OPT_AUTH_TYPE = 'authtype'; + const OPT_CA_CERT = 'cacert'; + const OPT_CA_CERT_DIR = 'cacertdir'; + const OPT_CERT = 'cert'; + const OPT_CERT_PASS = 'certpass'; + const OPT_COOKIES = 'cookies'; + const OPT_DEBUG = 'debug'; + const OPT_EXTRA_CURL_OPTS = 'extracurlopts'; + const OPT_EXTRA_SOCKET_OPTS = 'extrasockopts'; + const OPT_KEEPALIVE = 'keepalive'; + const OPT_KEY = 'key'; + const OPT_KEY_PASS = 'keypass'; + const OPT_NO_MULTICALL = 'no_multicall'; + const OPT_PASSWORD = 'password'; + const OPT_PROXY = 'proxy'; + const OPT_PROXY_AUTH_TYPE = 'proxy_authtype'; + const OPT_PROXY_PASS = 'proxy_pass'; + const OPT_PROXY_PORT = 'proxyport'; + const OPT_PROXY_USER = 'proxy_user'; + const OPT_REQUEST_CHARSET_ENCODING = 'request_charset_encoding'; + const OPT_REQUEST_COMPRESSION = 'request_compression'; + const OPT_RETURN_TYPE = 'return_type'; + const OPT_SSL_VERSION = 'sslversion'; + const OPT_TIMEOUT = 'timeout'; + const OPT_USERNAME = 'username'; + const OPT_USER_AGENT = 'user_agent'; + const OPT_USE_CURL = 'use_curl'; + const OPT_VERIFY_HOST = 'verifyhost'; + const OPT_VERIFY_PEER = 'verifypeer'; - /// @todo: do these need to be public? - public $method = 'http'; - public $server; - public $port = 0; - public $path; + /** @var string */ + protected static $requestClass = '\\PhpXmlRpc\\Request'; + /** @var string */ + protected static $responseClass = '\\PhpXmlRpc\\Response'; - public $errno; - public $errstr; - public $debug = 0; + /** + * @var int + * @deprecated will be removed in the future + */ + protected $errno; + /** + * @var string + * @deprecated will be removed in the future + */ + protected $errstr; - public $username = ''; - public $password = ''; - public $authtype = 1; + /// @todo: do all the ones below need to be public? - public $cert = ''; - public $certpass = ''; - public $cacert = ''; - public $cacertdir = ''; - public $key = ''; - public $keypass = ''; - public $verifypeer = true; - public $verifyhost = 2; - public $sslversion = 0; // corresponds to CURL_SSLVERSION_DEFAULT - - public $proxy = ''; - public $proxyport = 0; - public $proxy_user = ''; - public $proxy_pass = ''; - public $proxy_authtype = 1; - - public $cookies = array(); - public $extracurlopts = array(); - public $use_curl = self::USE_CURL_AUTO; + /** + * @var string + */ + protected $method = 'http'; + /** + * @var string + */ + protected $server; + /** + * @var int + */ + protected $port = 0; + /** + * @var string + */ + protected $path; + /** + * @var int + */ + protected $debug = 0; + /** + * @var string + */ + protected $username = ''; + /** + * @var string + */ + protected $password = ''; + /** + * @var int + */ + protected $authtype = 1; + /** + * @var string + */ + protected $cert = ''; + /** + * @var string + */ + protected $certpass = ''; + /** + * @var string + */ + protected $cacert = ''; + /** + * @var string + */ + protected $cacertdir = ''; + /** + * @var string + */ + protected $key = ''; + /** + * @var string + */ + protected $keypass = ''; + /** + * @var bool + */ + protected $verifypeer = true; + /** + * @var int + */ + protected $verifyhost = 2; + /** + * @var int + */ + protected $sslversion = 0; // corresponds to CURL_SSLVERSION_DEFAULT. Other CURL_SSLVERSION_ values are supported + /** + * @var string + */ + protected $proxy = ''; + /** + * @var int + */ + protected $proxyport = 0; + /** + * @var string + */ + protected $proxy_user = ''; + /** + * @var string + */ + protected $proxy_pass = ''; + /** + * @var int + */ + protected $proxy_authtype = 1; + /** + * @var array + */ + protected $cookies = array(); + /** + * @var array + */ + protected $extrasockopts = array(); + /** + * @var array + */ + protected $extracurlopts = array(); + /** + * @var int + */ + protected $timeout = 0; + /** + * @var int + */ + protected $use_curl = self::USE_CURL_AUTO; /** * @var bool * - * This determines whether the multicall() method will try to take advantage of the system.multicall xmlrpc method + * This determines whether the multicall() method will try to take advantage of the system.multicall xml-rpc method * to dispatch to the server an array of requests in a single http roundtrip or simply execute many consecutive http * calls. Defaults to FALSE, but it will be enabled automatically on the first failure of execution of * system.multicall. */ - public $no_multicall = false; - + protected $no_multicall = false; /** + * @var array + * * List of http compression methods accepted by the client for responses. * NB: PHP supports deflate, gzip compressions out of the box if compiled w. zlib. * @@ -68,73 +204,106 @@ class Client * decide the compression methods it supports. You might check for the presence of 'zlib' in the output of * curl_version() to determine whether compression is supported or not */ - public $accepted_compression = array(); - + protected $accepted_compression = array(); /** + * @var string|null + * * Name of compression scheme to be used for sending requests. - * Either null, gzip or deflate. + * Either null, 'gzip' or 'deflate'. */ - public $request_compression = ''; - + protected $request_compression = ''; /** - * CURL handle: used for keep-alive connections (PHP 4.3.8 up, see: - * http://curl.haxx.se/docs/faq.html#7.3). - * @internal + * @var bool + * + * Whether to use persistent connections for http 1.1 and https. Value set at constructor time. */ - public $xmlrpc_curl_handle = null; - - /// Whether to use persistent connections for http 1.1 and https - public $keepalive = false; - - /// Charset encodings that can be decoded without problems by the client - public $accepted_charset_encodings = array(); - + protected $keepalive = false; /** + * @var string[] + * + * Charset encodings that can be decoded without problems by the client. Value set at constructor time + */ + protected $accepted_charset_encodings = array(); + /** + * @var string + * * The charset encoding that will be used for serializing request sent by the client. - * It defaults to NULL, which means using US-ASCII and encoding all characters outside of the ASCII printable range + * It defaults to NULL, which means using US-ASCII and encoding all characters outside the ASCII printable range * using their xml character entity representation (this has the benefit that line end characters will not be mangled * in the transfer, a CR-LF will be preserved as well as a singe LF). * Valid values are 'US-ASCII', 'UTF-8' and 'ISO-8859-1'. - * For the fastest mode of operation, set your both your app internal encoding as well as this to UTF-8. + * For the fastest mode of operation, set your both your app internal encoding and this to UTF-8. */ - public $request_charset_encoding = ''; - + protected $request_charset_encoding = ''; /** + * @var string + * * Decides the content of Response objects returned by calls to send() and multicall(). * Valid values are 'xmlrpcvals', 'phpvals' or 'xml'. * - * Determines whether the value returned inside an Response object as results of calls to the send() and multicall() + * Determines whether the value returned inside a Response object as results of calls to the send() and multicall() * methods will be a Value object, a plain php value or a raw xml string. * Allowed values are 'xmlrpcvals' (the default), 'phpvals' and 'xml'. * To allow the user to differentiate between a correct and a faulty response, fault responses will be returned as * Response objects in any case. * Note that the 'phpvals' setting will yield faster execution times, but some of the information from the original * response will be lost. It will be e.g. impossible to tell whether a particular php string value was sent by the - * server as an xmlrpc string or base64 value. + * server as an xml-rpc string or base64 value. */ - public $return_type = XMLParser::RETURN_XMLRPCVALS; + protected $return_type = XMLParser::RETURN_XMLRPCVALS; + /** + * @var string + * + * Sent to servers in http headers. Value set at constructor time. + */ + protected $user_agent; /** - * Sent to servers in http headers. + * CURL handle: used for keep-alive + * @internal */ - public $user_agent; - - public function getLogger() - { - if (self::$logger === null) { - self::$logger = Logger::instance(); - } - return self::$logger; - } - - public static function setLogger($logger) - { - self::$logger = $logger; - } + public $xmlrpc_curl_handle = null; /** - * @param string $path either the PATH part of the xmlrpc server URL, or complete server URL (in which case you - * should use and empty string for all other parameters) + * @var array + */ + protected static $options = array( + self::OPT_ACCEPTED_CHARSET_ENCODINGS, + self::OPT_ACCEPTED_COMPRESSION, + self::OPT_AUTH_TYPE, + self::OPT_CA_CERT, + self::OPT_CA_CERT_DIR, + self::OPT_CERT, + self::OPT_CERT_PASS, + self::OPT_COOKIES, + self::OPT_DEBUG, + self::OPT_EXTRA_CURL_OPTS, + self::OPT_EXTRA_SOCKET_OPTS, + self::OPT_KEEPALIVE, + self::OPT_KEY, + self::OPT_KEY_PASS, + self::OPT_NO_MULTICALL, + self::OPT_PASSWORD, + self::OPT_PROXY, + self::OPT_PROXY_AUTH_TYPE, + self::OPT_PROXY_PASS, + self::OPT_PROXY_USER, + self::OPT_PROXY_PORT, + self::OPT_REQUEST_CHARSET_ENCODING, + self::OPT_REQUEST_COMPRESSION, + self::OPT_RETURN_TYPE, + self::OPT_SSL_VERSION, + self::OPT_TIMEOUT, + self::OPT_USE_CURL, + self::OPT_USER_AGENT, + self::OPT_USERNAME, + self::OPT_VERIFY_HOST, + self::OPT_VERIFY_PEER, + ); + + /** + * @param string $path either the PATH part of the xml-rpc server URL, or complete server URL (in which case you + * should use an empty string for all other parameters) * e.g. /xmlrpc/server.php * e.g. http://phpxmlrpc.sourceforge.net/server.php * e.g. https://james:bond@secret.service.com:444/xmlrpcserver?agent=007 @@ -203,36 +372,91 @@ class Client // by default the xml parser can support these 3 charset encodings $this->accepted_charset_encodings = array('UTF-8', 'ISO-8859-1', 'US-ASCII'); - // Add all charsets which mbstring can handle, but remove junk not found in IANA registry at - // http://www.iana.org/assignments/character-sets/character-sets.xhtml // NB: this is disabled to avoid making all the requests sent huge... mbstring supports more than 80 charsets! - /*if (function_exists('mb_list_encodings')) { - - $encodings = array_diff(mb_list_encodings(), array('pass', 'auto', 'wchar', 'BASE64', 'UUENCODE', 'ASCII', - 'HTML-ENTITIES', 'Quoted-Printable', '7bit','8bit', 'byte2be', 'byte2le', 'byte4be', 'byte4le')); - $this->accepted_charset_encodings = array_unique(array_merge($this->accepted_charset_encodings, $encodings)); - }*/ + //$ch = $this->getCharsetEncoder(); + //$this->accepted_charset_encodings = $ch->knownCharsets(); // initialize user_agent string $this->user_agent = PhpXmlRpc::$xmlrpcName . ' ' . PhpXmlRpc::$xmlrpcVersion; } /** - * Enable/disable the echoing to screen of the xmlrpc responses received. The default is not no output anything. + * @param string $name see all the OPT_ constants + * @param mixed $value + * @return $this + * @throws ValueErrorException on unsupported option + */ + public function setOption($name, $value) + { + if (in_array($name, static::$options)) { + $this->$name = $value; + return $this; + } + + throw new ValueErrorException("Unsupported option '$name'"); + } + + /** + * @param string $name see all the OPT_ constants + * @return mixed + * @throws ValueErrorException on unsupported option + */ + public function getOption($name) + { + if (in_array($name, static::$options)) { + return $this->$name; + } + + throw new ValueErrorException("Unsupported option '$name'"); + } + + /** + * Returns the complete list of Client options, with their value. + * @return array + */ + public function getOptions() + { + $values = array(); + foreach (static::$options as $opt) { + $values[$opt] = $this->getOption($opt); + } + return $values; + } + + /** + * @param array $options key: any valid option (see all the OPT_ constants) + * @return $this + * @throws ValueErrorException on unsupported option + */ + public function setOptions($options) + { + foreach ($options as $name => $value) { + $this->setOption($name, $value); + } + + return $this; + } + + /** + * Enable/disable the echoing to screen of the xml-rpc responses received. The default is not to output anything. * * The debugging information at level 1 includes the raw data returned from the XML-RPC server it was querying * (including bot HTTP headers and the full XML payload), and the PHP value the client attempts to create to - * represent the value returned by the server - * At level2, the complete payload of the xmlrpc request is also printed, before being sent t the server. + * represent the value returned by the server. + * At level 2, the complete payload of the xml-rpc request is also printed, before being sent to the server. + * At level -1, the Response objects returned by send() calls will not carry information about the http response's + * cookies, headers and body, which might save some memory * * This option can be very useful when debugging servers as it allows you to see exactly what the client sends and - * the server returns. + * the server returns. Never leave it enabled for production! * - * @param integer $level values 0, 1 and 2 are supported (2 = echo sent msg too, before received response) + * @param integer $level values -1, 0, 1 and 2 are supported + * @return $this */ public function setDebug($level) { $this->debug = $level; + return $this; } /** @@ -248,12 +472,14 @@ class Client * @param integer $authType auth type. See curl_setopt man page for supported auth types. Defaults to CURLAUTH_BASIC * (basic auth). Note that auth types NTLM and Digest will only work if the Curl php * extension is enabled. + * @return $this */ public function setCredentials($user, $password, $authType = 1) { $this->username = $user; $this->password = $password; $this->authtype = $authType; + return $this; } /** @@ -265,11 +491,13 @@ class Client * * @param string $cert the name of a file containing a PEM formatted certificate * @param string $certPass the password required to use it + * @return $this */ public function setCertificate($cert, $certPass = '') { $this->cert = $cert; $this->certpass = $certPass; + return $this; } /** @@ -279,6 +507,7 @@ class Client * * @param string $caCert certificate file name (or dir holding certificates) * @param bool $isDir set to true to indicate cacert is a dir. defaults to false + * @return $this */ public function setCaCertificate($caCert, $isDir = false) { @@ -287,6 +516,7 @@ class Client } else { $this->cacert = $caCert; } + return $this; } /** @@ -297,11 +527,13 @@ class Client * * @param string $key The name of a file containing a private SSL key * @param string $keyPass The secret password needed to use the private SSL key + * @return $this */ public function setKey($key, $keyPass) { $this->key = $key; $this->keypass = $keyPass; + return $this; } /** @@ -312,10 +544,15 @@ class Client * To specify custom SSL certificates to validate the server with, use the setCaCertificate method. * * @param bool $i enable/disable verification of peer certificate + * @return $this + * @deprecated use setOption */ public function setSSLVerifyPeer($i) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->verifypeer = $i; + return $this; } /** @@ -324,20 +561,30 @@ class Client * Note that support for value 1 has been removed in cURL 7.28.1 * * @param int $i Set to 1 to only the existence of a CN, not that it matches + * @return $this + * @deprecated use setOption */ public function setSSLVerifyHost($i) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->verifyhost = $i; + return $this; } /** * Set attributes for SSL communication: SSL version to use. Best left at 0 (default value): let cURL decide * - * @param int $i + * @param int $i see CURL_SSLVERSION_ constants + * @return $this + * @deprecated use setOption */ public function setSSLVersion($i) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->sslversion = $i; + return $this; } /** @@ -351,6 +598,7 @@ class Client * @param string $proxyPassword Leave blank if proxy has public access * @param int $proxyAuthType defaults to CURLAUTH_BASIC (Basic authentication protocol); set to constant CURLAUTH_NTLM * to use NTLM auth with proxy (has effect only when the client uses the HTTP 1.1 protocol) + * @return $this */ public function setProxy($proxyHost, $proxyPort, $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1) { @@ -359,17 +607,19 @@ class Client $this->proxy_user = $proxyUsername; $this->proxy_pass = $proxyPassword; $this->proxy_authtype = $proxyAuthType; + return $this; } /** - * Enables/disables reception of compressed xmlrpc responses. + * Enables/disables reception of compressed xml-rpc responses. * * This requires the "zlib" extension to be enabled in your php install. If it is, by default xmlrpc_client * instances will enable reception of compressed content. - * Note that enabling reception of compressed responses merely adds some standard http headers to xmlrpc requests. - * It is up to the xmlrpc server to return compressed responses when receiving such requests. + * Note that enabling reception of compressed responses merely adds some standard http headers to xml-rpc requests. + * It is up to the xml-rpc server to return compressed responses when receiving such requests. * * @param string $compMethod either 'gzip', 'deflate', 'any' or '' + * @return $this */ public function setAcceptedCompression($compMethod) { @@ -380,40 +630,49 @@ class Client } else { $this->accepted_compression = array($compMethod); } + return $this; } /** - * Enables/disables http compression of xmlrpc request. + * Enables/disables http compression of xml-rpc request. * * This requires the "zlib" extension to be enabled in your php install. * Take care when sending compressed requests: servers might not support them (and automatic fallback to * uncompressed requests is not yet implemented). * * @param string $compMethod either 'gzip', 'deflate' or '' + * @return $this + * @deprecated use setOption */ public function setRequestCompression($compMethod) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->request_compression = $compMethod; + return $this; } /** * Adds a cookie to list of cookies that will be sent to server with every further request (useful e.g. for keeping - * session info outside of the xml-rpc payload). + * session info outside the xml-rpc payload). * - * NB: By default cookies are sent using the 'original/netscape' format, which is also the same as the RFC 2965; - * setting any param but name and value will turn the cookie into a 'version 1' cookie (i.e. RFC 2109 cookie) that - * might not be fully supported by the server. Note that RFC 2109 has currently 'historic' status... + * NB: by default all cookies set via this method are sent to the server, regardless of path/domain/port. Taking + * advantage of those values is left to the single developer. * * @param string $name nb: will not be escaped in the request's http headers. Take care not to use CTL chars or * separators! * @param string $value - * @param string $path leave this empty unless the xml-rpc server only accepts RFC 2109 cookies - * @param string $domain leave this empty unless the xml-rpc server only accepts RFC 2109 cookies - * @param int $port leave this empty unless the xml-rpc server only accepts RFC 2109 cookies + * @param string $path + * @param string $domain + * @param int $port do not use! Cookies are not separated by port + * @return $this * * @todo check correctness of urlencoding cookie value (copied from php way of doing it, but php is generally sending * response not requests. We do the opposite...) * @todo strip invalid chars from cookie name? As per RFC6265, we should follow RFC2616, Section 2.2 + * @todo drop/rename $port parameter. Cookies are not isolated by port! + * @todo feature-creep allow storing 'expires', 'secure', 'httponly' and 'samesite' cookie attributes (we could do + * as php, and allow $path to be an array of attributes...) */ public function setCookie($name, $value = '', $path = '', $domain = '', $port = null) { @@ -422,30 +681,38 @@ class Client $this->cookies[$name]['path'] = $path; $this->cookies[$name]['domain'] = $domain; $this->cookies[$name]['port'] = $port; - $this->cookies[$name]['version'] = 1; - } else { - $this->cookies[$name]['version'] = 0; } + return $this; } /** * Directly set cURL options, for extra flexibility (when in cURL mode). * - * It allows eg. to bind client to a specific IP interface / address. + * It allows e.g. to bind client to a specific IP interface / address. * * @param array $options + * @return $this + * @deprecated use setOption */ public function setCurlOptions($options) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->extracurlopts = $options; + return $this; } /** * @param int $useCurlMode self::USE_CURL_ALWAYS, self::USE_CURL_AUTO or self::USE_CURL_NEVER + * @return $this + * @deprecated use setOption */ public function setUseCurl($useCurlMode) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->use_curl = $useCurlMode; + return $this; } @@ -455,14 +722,53 @@ class Client * The default user agent string includes the name of this library and the version number. * * @param string $agentString + * @return $this + * @deprecated use setOption */ public function setUserAgent($agentString) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + $this->user_agent = $agentString; + return $this; } /** - * Send an xmlrpc request to the server. + * @param null|int $component allowed values: PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT, PHP_URL_PATH + * @return string|int Notes: the path component will include query string and fragment; NULL is a valid value for port + * (in which case the default port for http/https will be used); + * @throws ValueErrorException on unsupported component + */ + public function getUrl($component = null) + { + if (is_int($component) || ctype_digit($component)) { + switch ($component) { + case PHP_URL_SCHEME: + return $this->method; + case PHP_URL_HOST: + return $this->server; + case PHP_URL_PORT: + return $this->port; + case PHP_URL_PATH: + return $this->path; + case '': + + default: + throw new ValueErrorException("Unsupported component '$component'"); + } + } + + $url = $this->method . '://' . $this->server; + if ($this->port == 0 || ($this->port == 80 && in_array($this->method, array('http', 'http10', 'http11', 'h2c'))) || + ($this->port == 443 && in_array($this->method, array('https', 'h2')))) { + return $url . $this->path; + } else { + return $url . ':' . $this->port . $this->path; + } + } + + /** + * Send an xml-rpc request to the server. * * @param Request|Request[]|string $req The Request object, or an array of requests for using multicall, or the * complete xml representation of a request. @@ -470,55 +776,68 @@ class Client * a single 'system.multicall' xml-rpc method call to forward to the server all * the requests in a single HTTP round trip, unless $this->no_multicall has * been previously set to TRUE (see the multicall method below), in which case - * many consecutive xmlrpc requests will be sent. The method will return an + * many consecutive xml-rpc requests will be sent. The method will return an * array of Response objects in both cases. * The third variant allows to build by hand (or any other means) a complete - * xmlrpc request message, and send it to the server. $req should be a string + * xml-rpc request message, and send it to the server. $req should be a string * containing the complete xml representation of the request. It is e.g. useful * when, for maximal speed of execution, the request is serialized into a - * string using the native php xmlrpc functions (see http://www.php.net/xmlrpc) - * @param integer $timeout Connection timeout, in seconds, If unspecified, a platform specific timeout will apply. + * string using the native php xml-rpc functions (see http://www.php.net/xmlrpc) + * @param integer $timeout deprecated. Connection timeout, in seconds, If unspecified, the timeout set with setOption + * will be used. If that is 0, a platform specific timeout will apply. * This timeout value is passed to fsockopen(). It is also used for detecting server * timeouts during communication (i.e. if the server does not send anything to the client * for $timeout seconds, the connection will be closed). - * @param string $method valid values are 'http', 'http11', 'https', 'h2' and 'h2c'. If left unspecified, + * @param string $method deprecated. Use the same value in the constructor instead. + * Valid values are 'http', 'http11', 'https', 'h2' and 'h2c'. If left empty, * the http protocol chosen during creation of the object will be used. * Use 'h2' to make the lib attempt to use http/2 over a secure connection, and 'h2c' * for http/2 without tls. Note that 'h2c' will not use the h2c 'upgrade' method, and be * thus incompatible with any server/proxy not supporting http/2. This is because POST * request are not compatible with h2c upgrade. - * * @return Response|Response[] Note that the client will always return a Response object, even if the call fails + * * @todo allow throwing exceptions instead of returning responses in case of failed calls and/or Fault responses * @todo refactor: we now support many options besides connection timeout and http version to use. Why only privilege those? */ public function send($req, $timeout = 0, $method = '') { + if ($method !== '' || $timeout !== 0) { + $this->logDeprecation("Using non-default values for arguments 'method' and 'timeout' when calling method " . __METHOD__ . ' is deprecated'); + } + // if user does not specify http protocol, use native method of this client // (i.e. method set during call to constructor) if ($method == '') { $method = $this->method; } + if ($timeout == 0) { + $timeout = $this->timeout; + } + if (is_array($req)) { // $req is an array of Requests - $r = $this->multicall($req, $timeout, $method); - - return $r; + /// @todo switch to the new syntax for multicall + return $this->multicall($req, $timeout, $method); } elseif (is_string($req)) { - $n = new Request(''); - $n->payload = $req; + $n = new static::$requestClass(''); + $n->setPayload($req); $req = $n; } // where req is a Request $req->setDebug($this->debug); - /// @todo we could be smarter about this and force usage of curl in scenarios where it is both available and - /// needed, such as digest or ntlm auth. Do not attempt to use it for https if not present - $useCurl = ($this->use_curl == self::USE_CURL_ALWAYS) || ($this->use_curl == self::USE_CURL_AUTO && - (in_array($method, array('https', 'http11', 'h2c', 'h2')))); + /// @todo we could be smarter about this and not force usage of curl for https if not present as well as use the + /// presence of curl_extra_opts or socket_extra_opts as a hint + $useCurl = ($this->use_curl == self::USE_CURL_ALWAYS) || ($this->use_curl == self::USE_CURL_AUTO && ( + in_array($method, array('https', 'http11', 'h2c', 'h2')) || + ($this->username != '' && $this->authtype != 1) || + ($this->proxy != '' && $this->proxy_user != '' && $this->proxy_authtype != 1) + )); + // BC - we go through sendPayloadCURL/sendPayloadSocket in case some subclass reimplemented those if ($useCurl) { $r = $this->sendPayloadCURL( $req, @@ -537,7 +856,7 @@ class Client $this->proxy_user, $this->proxy_pass, $this->proxy_authtype, - // bc + // BC $method == 'http11' ? 'http' : $method, $this->keepalive, $this->key, @@ -545,7 +864,6 @@ class Client $this->sslversion ); } else { - // plain 'http 1.0': default to using socket $r = $this->sendPayloadSocket( $req, $this->server, @@ -573,8 +891,809 @@ class Client return $r; } + /** + * @param Request $req + * @param string $method + * @param string $server + * @param int $port + * @param string $path + * @param array $opts + * @return Response + */ + protected function sendViaSocket($req, $method, $server, $port, $path, $opts) + { + /// @todo log a warning if passed an unsupported method + + // Only create the payload if it was not created previously + /// @todo what if the request's payload was created with a different encoding? + /// Also, if we do not call serialize(), the request will not set its content-type to have the charset declared + $payload = $req->getPayload(); + if (empty($payload)) { + $payload = $req->serialize($opts['request_charset_encoding']); + } + + // Deflate request body and set appropriate request headers + $encodingHdr = ''; + if ($opts['request_compression'] == 'gzip' || $opts['request_compression'] == 'deflate') { + if ($opts['request_compression'] == 'gzip' && function_exists('gzencode')) { + $a = @gzencode($payload); + if ($a) { + $payload = $a; + $encodingHdr = "Content-Encoding: gzip\r\n"; + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': gzencode failure in compressing request'); + } + } else if (function_exists('gzcompress')) { + $a = @gzcompress($payload); + if ($a) { + $payload = $a; + $encodingHdr = "Content-Encoding: deflate\r\n"; + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': gzcompress failure in compressing request'); + } + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': desired request compression method is unsupported by this PHP install'); + } + } else { + if ($opts['request_compression'] != '') { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': desired request compression method is unsupported'); + } + } + + // thanks to Grant Rauscher + $credentials = ''; + if ($opts['username'] != '') { + if ($opts['authtype'] != 1) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth is supported with HTTP 1.0'); + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['unsupported_option'], + PhpXmlRpc::$xmlrpcerr['unsupported_option'] . ': only Basic auth is supported with HTTP 1.0'); + } + $credentials = 'Authorization: Basic ' . base64_encode($opts['username'] . ':' . $opts['password']) . "\r\n"; + } + + $acceptedEncoding = ''; + if (is_array($opts['accepted_compression']) && count($opts['accepted_compression'])) { + $acceptedEncoding = 'Accept-Encoding: ' . implode(', ', $opts['accepted_compression']) . "\r\n"; + } + + if ($port == 0) { + $port = ($method === 'https') ? 443 : 80; + } + + $proxyCredentials = ''; + if ($opts['proxy']) { + if ($opts['proxyport'] == 0) { + $opts['proxyport'] = 8080; + } + $connectServer = $opts['proxy']; + $connectPort = $opts['proxyport']; + $transport = 'tcp'; + /// @todo check: should we not use https in some cases? + $uri = 'http://' . $server . ':' . $port . $path; + if ($opts['proxy_user'] != '') { + if ($opts['proxy_authtype'] != 1) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth to proxy is supported with HTTP 1.0'); + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['unsupported_option'], + PhpXmlRpc::$xmlrpcerr['unsupported_option'] . ': only Basic auth to proxy is supported with HTTP 1.0'); + } + $proxyCredentials = 'Proxy-Authorization: Basic ' . base64_encode($opts['proxy_user'] . ':' . + $opts['proxy_pass']) . "\r\n"; + } + } else { + $connectServer = $server; + $connectPort = $port; + $transport = ($method === 'https') ? 'tls' : 'tcp'; + $uri = $path; + } + + // Cookie generation, as per RFC6265 + // NB: the following code does not honour 'expires', 'path' and 'domain' cookie attributes set to client obj by the user... + $cookieHeader = ''; + if (count($opts['cookies'])) { + $version = ''; + foreach ($opts['cookies'] as $name => $cookie) { + /// @todo should we sanitize the cookie value on behalf of the user? See setCookie comments + $cookieHeader .= ' ' . $name . '=' . $cookie['value'] . ";"; + } + $cookieHeader = 'Cookie:' . $version . substr($cookieHeader, 0, -1) . "\r\n"; + } + + // omit port if default + if (($port == 80 && in_array($method, array('http', 'http10'))) || ($port == 443 && $method == 'https')) { + $port = ''; + } else { + $port = ':' . $port; + } + + $op = 'POST ' . $uri . " HTTP/1.0\r\n" . + 'User-Agent: ' . $opts['user_agent'] . "\r\n" . + 'Host: ' . $server . $port . "\r\n" . + $credentials . + $proxyCredentials . + $acceptedEncoding . + $encodingHdr . + 'Accept-Charset: ' . implode(',', $opts['accepted_charset_encodings']) . "\r\n" . + $cookieHeader . + 'Content-Type: ' . $req->getContentType() . "\r\nContent-Length: " . + strlen($payload) . "\r\n\r\n" . + $payload; + + if ($opts['debug'] > 1) { + $this->getLogger()->debug("---SENDING---\n$op\n---END---"); + } + + $contextOptions = array(); + if ($method == 'https') { + if ($opts['cert'] != '') { + $contextOptions['ssl']['local_cert'] = $opts['cert']; + if ($opts['certpass'] != '') { + $contextOptions['ssl']['passphrase'] = $opts['certpass']; + } + } + if ($opts['cacert'] != '') { + $contextOptions['ssl']['cafile'] = $opts['cacert']; + } + if ($opts['cacertdir'] != '') { + $contextOptions['ssl']['capath'] = $opts['cacertdir']; + } + if ($opts['key'] != '') { + $contextOptions['ssl']['local_pk'] = $opts['key']; + } + $contextOptions['ssl']['verify_peer'] = $opts['verifypeer']; + $contextOptions['ssl']['verify_peer_name'] = $opts['verifypeer']; + + if ($opts['sslversion'] != 0) { + /// @see https://www.php.net/manual/en/function.curl-setopt.php, https://www.php.net/manual/en/migration56.openssl.php + switch($opts['sslversion']) { + /// @todo what does this map to? 1.0-1.3? + //case 1: // TLSv1 + // break; + case 2: // SSLv2 + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_SSLv2_CLIENT; + break; + case 3: // SSLv3 + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_SSLv3_CLIENT; + break; + case 4: // TLSv1.0 + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; + break; + case 5: // TLSv1.1 + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; + break; + case 6: // TLSv1.2 + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + break; + case 7: // TLSv1.3 + if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) { + $contextOptions['ssl']['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; + } else { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['unsupported_option'], + PhpXmlRpc::$xmlrpcerr['unsupported_option'] . ': TLS-1.3 only is supported with PHP 7.4 or later'); + } + break; + default: + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['unsupported_option'], + PhpXmlRpc::$xmlrpcerr['unsupported_option'] . ': Unsupported required TLS version'); + } + } + } + + foreach ($opts['extracurlopts'] as $proto => $protoOpts) { + foreach ($protoOpts as $key => $val) { + $contextOptions[$proto][$key] = $val; + } + } + + $context = stream_context_create($contextOptions); + + if ($opts['timeout'] <= 0) { + $connectTimeout = ini_get('default_socket_timeout'); + } else { + $connectTimeout = $opts['timeout']; + } + + $this->errno = 0; + $this->errstr = ''; + + $fp = @stream_socket_client("$transport://$connectServer:$connectPort", $this->errno, $this->errstr, $connectTimeout, + STREAM_CLIENT_CONNECT, $context); + if ($fp) { + if ($opts['timeout'] > 0) { + stream_set_timeout($fp, $opts['timeout'], 0); + } + } else { + if ($this->errstr == '') { + $err = error_get_last(); + $this->errstr = $err['message']; + } + + $this->errstr = 'Connect error: ' . $this->errstr; + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['http_error'], $this->errstr . ' (' . $this->errno . ')'); + + return $r; + } + + if (!fputs($fp, $op, strlen($op))) { + fclose($fp); + $this->errstr = 'Write error'; + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['http_error'], $this->errstr); + } + + // Close socket before parsing. + // It should yield slightly better execution times, and make easier recursive calls (e.g. to follow http redirects) + $ipd = ''; + do { + // shall we check for $data === FALSE? + // as per the manual, it signals an error + $ipd .= fread($fp, 32768); + } while (!feof($fp)); + fclose($fp); + + return $req->parseResponse($ipd, false, $opts['return_type']); + } + + /** + * Contributed by Justin Miller + * Requires curl to be built into PHP + * NB: CURL versions before 7.11.10 cannot use proxy to talk to https servers! + * + * @param Request $req + * @param string $method + * @param string $server + * @param int $port + * @param string $path + * @param array $opts the keys/values match self::getOptions + * @return Response + * + * @todo the $path arg atm is ignored. What to do if it is != $this->path? + */ + protected function sendViaCURL($req, $method, $server, $port, $path, $opts) + { + if (!function_exists('curl_init')) { + $this->errstr = 'CURL unavailable on this install'; + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['no_curl'], PhpXmlRpc::$xmlrpcstr['no_curl']); + } + if ($method == 'https' || $method == 'h2') { + // q: what about installs where we get back a string, but curl is linked to other ssl libs than openssl? + if (($info = curl_version()) && + ((is_string($info) && strpos($info, 'OpenSSL') === null) || (is_array($info) && !isset($info['ssl_version']))) + ) { + $this->errstr = 'SSL unavailable on this install'; + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['no_ssl'], PhpXmlRpc::$xmlrpcstr['no_ssl']); + } + } + if (($method == 'h2' && !defined('CURL_HTTP_VERSION_2_0')) || + ($method == 'h2c' && !defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE'))) { + $this->errstr = 'HTTP/2 unavailable on this install'; + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['no_http2'], PhpXmlRpc::$xmlrpcstr['no_http2']); + } + + // BC - we go through prepareCurlHandle in case some subclass reimplemented it + $curl = $this->prepareCurlHandle($req, $server, $port, $opts['timeout'], $opts['username'], $opts['password'], + $opts['authtype'], $opts['cert'], $opts['certpass'], $opts['cacert'], $opts['cacertdir'], $opts['proxy'], + $opts['proxyport'], $opts['proxy_user'], $opts['proxy_pass'], $opts['proxy_authtype'], $method, + $opts['keepalive'], $opts['key'], $opts['keypass'], $opts['sslversion']); + + if (!$curl) { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['curl_fail'], PhpXmlRpc::$xmlrpcstr['curl_fail'] . + ': error during curl initialization. Check php error log for details'); + } + + $result = curl_exec($curl); + + if ($opts['debug'] > 1) { + $message = "---CURL INFO---\n"; + foreach (curl_getinfo($curl) as $name => $val) { + if (is_array($val)) { + $val = implode("\n", $val); + } + $message .= $name . ': ' . $val . "\n"; + } + $message .= '---END---'; + $this->getLogger()->debug($message); + } + + if (!$result) { + /// @todo we should use a better check here - what if we get back '' or '0'? + + $this->errstr = 'no response'; + $resp = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['curl_fail'], PhpXmlRpc::$xmlrpcstr['curl_fail'] . + ': ' . curl_error($curl)); + curl_close($curl); + if ($opts['keepalive']) { + $this->xmlrpc_curl_handle = null; + } + } else { + if (!$opts['keepalive']) { + curl_close($curl); + } + $resp = $req->parseResponse($result, true, $opts['return_type']); + if ($opts['keepalive']) { + /// @todo if we got back a 302 or 308, we should not reuse the curl handle for later calls + if ($resp->faultCode() == PhpXmlRpc::$xmlrpcerr['http_error']) { + curl_close($curl); + $this->xmlrpc_curl_handle = null; + } + } + } + + return $resp; + } + + /** + * @param Request $req + * @param string $method + * @param string $server + * @param int $port + * @param string $path + * @param array $opts the keys/values match self::getOptions + * @return \CurlHandle|resource|false + * + * @todo allow this method to either throw or return a Response, so that we can pass back to caller more info on errors + */ + protected function createCURLHandle($req, $method, $server, $port, $path, $opts) + { + if ($port == 0) { + if (in_array($method, array('http', 'http10', 'http11', 'h2c'))) { + $port = 80; + } else { + $port = 443; + } + } + + // Only create the payload if it was not created previously + $payload = $req->getPayload(); + if (empty($payload)) { + $payload = $req->serialize($opts['request_charset_encoding']); + } + + // Deflate request body and set appropriate request headers + $encodingHdr = ''; + if (($opts['request_compression'] == 'gzip' || $opts['request_compression'] == 'deflate')) { + if ($opts['request_compression'] == 'gzip' && function_exists('gzencode')) { + $a = @gzencode($payload); + if ($a) { + $payload = $a; + $encodingHdr = 'Content-Encoding: gzip'; + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': gzencode failure in compressing request'); + } + } else if (function_exists('gzcompress')) { + $a = @gzcompress($payload); + if ($a) { + $payload = $a; + $encodingHdr = 'Content-Encoding: deflate'; + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': gzcompress failure in compressing request'); + } + } else { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': desired request compression method is unsupported by this PHP install'); + } + } else { + if ($opts['request_compression'] != '') { + $this->getLogger()->warning('XML-RPC: ' . __METHOD__ . ': desired request compression method is unsupported'); + } + } + + if (!$opts['keepalive'] || !$this->xmlrpc_curl_handle) { + if ($method == 'http11' || $method == 'http10' || $method == 'h2c') { + $protocol = 'http'; + } else { + if ($method == 'h2') { + $protocol = 'https'; + } else { + // http, https + $protocol = $method; + if (strpos($protocol, ':') !== false) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": warning - attempted hacking attempt?. The curl protocol requested for the call is: '$protocol'"); + return false; + } + } + } + $curl = curl_init($protocol . '://' . $server . ':' . $port . $path); + if (!$curl) { + return false; + } + if ($opts['keepalive']) { + $this->xmlrpc_curl_handle = $curl; + } + } else { + $curl = $this->xmlrpc_curl_handle; + } + + // results into variable + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + + if ($opts['debug'] > 1) { + curl_setopt($curl, CURLOPT_VERBOSE, true); + /// @todo redirect curlopt_stderr to some stream which can be piped to the logger + } + curl_setopt($curl, CURLOPT_USERAGENT, $opts['user_agent']); + // required for XMLRPC: post the data + curl_setopt($curl, CURLOPT_POST, 1); + // the data + curl_setopt($curl, CURLOPT_POSTFIELDS, $payload); + + // return the header too + curl_setopt($curl, CURLOPT_HEADER, 1); + + // NB: if we set an empty string, CURL will add http header indicating + // ALL methods it is supporting. This is possibly a better option than letting the user tell what curl can / cannot do... + if (is_array($opts['accepted_compression']) && count($opts['accepted_compression'])) { + //curl_setopt($curl, CURLOPT_ENCODING, implode(',', $opts['accepted_compression'])); + // empty string means 'any supported by CURL' (shall we catch errors in case CURLOPT_SSLKEY undefined ?) + if (count($opts['accepted_compression']) == 1) { + curl_setopt($curl, CURLOPT_ENCODING, $opts['accepted_compression'][0]); + } else { + curl_setopt($curl, CURLOPT_ENCODING, ''); + } + } + // extra headers + $headers = array('Content-Type: ' . $req->getContentType(), 'Accept-Charset: ' . implode(',', $opts['accepted_charset_encodings'])); + // if no keepalive is wanted, let the server know it in advance + if (!$opts['keepalive']) { + $headers[] = 'Connection: close'; + } + // request compression header + if ($encodingHdr) { + $headers[] = $encodingHdr; + } + + // Fix the HTTP/1.1 417 Expectation Failed Bug (curl by default adds a 'Expect: 100-continue' header when POST + // size exceeds 1025 bytes, apparently) + $headers[] = 'Expect:'; + + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + // timeout is borked + if ($opts['timeout']) { + curl_setopt($curl, CURLOPT_TIMEOUT, $opts['timeout'] == 1 ? 1 : $opts['timeout'] - 1); + } + + switch ($method) { + case 'http10': + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0); + break; + case 'http11': + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + break; + case 'h2c': + if (defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')) { + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE); + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': warning. HTTP2 is not supported by the current PHP/curl install'); + curl_close($curl); + return false; + } + break; + case 'h2': + curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); + break; + } + + if ($opts['username'] && $opts['password']) { + curl_setopt($curl, CURLOPT_USERPWD, $opts['username'] . ':' . $opts['password']); + if (defined('CURLOPT_HTTPAUTH')) { + curl_setopt($curl, CURLOPT_HTTPAUTH, $opts['authtype']); + } elseif ($opts['authtype'] != 1) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth is supported by the current PHP/curl install'); + curl_close($curl); + return false; + } + } + + // note: h2c is http2 without the https. No need to have it in this IF + if ($method == 'https' || $method == 'h2') { + // set cert file + if ($opts['cert']) { + curl_setopt($curl, CURLOPT_SSLCERT, $opts['cert']); + } + // set cert password + if ($opts['certpass']) { + curl_setopt($curl, CURLOPT_SSLCERTPASSWD, $opts['certpass']); + } + // whether to verify remote host's cert + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $opts['verifypeer']); + // set ca certificates file/dir + if ($opts['cacert']) { + curl_setopt($curl, CURLOPT_CAINFO, $opts['cacert']); + } + if ($opts['cacertdir']) { + curl_setopt($curl, CURLOPT_CAPATH, $opts['cacertdir']); + } + // set key file (shall we catch errors in case CURLOPT_SSLKEY undefined ?) + if ($opts['key']) { + curl_setopt($curl, CURLOPT_SSLKEY, $opts['key']); + } + // set key password (shall we catch errors in case CURLOPT_SSLKEY undefined ?) + if ($opts['keypass']) { + curl_setopt($curl, CURLOPT_SSLKEYPASSWD, $opts['keypass']); + } + // whether to verify cert's common name (CN); 0 for no, 1 to verify that it exists, and 2 to verify that + // it matches the hostname used + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $opts['verifyhost']); + // allow usage of different SSL versions + curl_setopt($curl, CURLOPT_SSLVERSION, $opts['sslversion']); + } + + // proxy info + if ($opts['proxy']) { + if ($opts['proxyport'] == 0) { + $opts['proxyport'] = 8080; // NB: even for HTTPS, local connection is on port 8080 + } + curl_setopt($curl, CURLOPT_PROXY, $opts['proxy'] . ':' . $opts['proxyport']); + if ($opts['proxy_user']) { + curl_setopt($curl, CURLOPT_PROXYUSERPWD, $opts['proxy_user'] . ':' . $opts['proxy_pass']); + if (defined('CURLOPT_PROXYAUTH')) { + curl_setopt($curl, CURLOPT_PROXYAUTH, $opts['proxy_authtype']); + } elseif ($opts['proxy_authtype'] != 1) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth to proxy is supported by the current PHP/curl install'); + curl_close($curl); + return false; + } + } + } + + // NB: should we build cookie http headers by hand rather than let CURL do it? + // NB: the following code does not honour 'expires', 'path' and 'domain' cookie attributes set to client obj by the user... + if (count($opts['cookies'])) { + $cookieHeader = ''; + foreach ($opts['cookies'] as $name => $cookie) { + $cookieHeader .= $name . '=' . $cookie['value'] . '; '; + } + curl_setopt($curl, CURLOPT_COOKIE, substr($cookieHeader, 0, -2)); + } + + foreach ($opts['extracurlopts'] as $opt => $val) { + curl_setopt($curl, $opt, $val); + } + + if ($opts['debug'] > 1) { + $this->getLogger()->debug("---SENDING---\n$payload\n---END---"); + } + + return $curl; + } + + /** + * Send an array of requests and return an array of responses. + * + * Unless $this->no_multicall has been set to true, it will try first to use one single xml-rpc call to server method + * system.multicall, and revert to sending many successive calls in case of failure. + * This failure is also stored in $this->no_multicall for subsequent calls. + * Unfortunately, there is no server error code universally used to denote the fact that multicall is unsupported, + * so there is no way to reliably distinguish between that and a temporary failure. + * If you are sure that server supports multicall and do not want to fallback to using many single calls, set the + * 2np parameter to FALSE. + * + * NB: trying to shoehorn extra functionality into existing syntax has resulted + * in pretty much convoluted code... + * + * @param Request[] $reqs an array of Request objects + * @param bool $noFallback When true, upon receiving an error during multicall, multiple single calls will not be + * attempted. + * Deprecated alternative, was: int - "connection timeout (in seconds). See the details in the + * docs for the send() method". Please use setOption instead to set a timeout + * @param string $method deprecated. Was: "the http protocol variant to be used. See the details in the docs for the send() method." + * Please use the constructor to set an http protocol variant. + * @param boolean $fallback deprecated. Was: "w"hen true, upon receiving an error during multicall, multiple single + * calls will be attempted" + * @return Response[] + */ + public function multicall($reqs, $timeout = 0, $method = '', $fallback = true) + { + // BC + if (is_bool($timeout) && $fallback === true) { + $fallback = !$timeout; + $timeout = 0; + } + + if ($method == '') { + $method = $this->method; + } + + if (!$this->no_multicall) { + $results = $this->_try_multicall($reqs, $timeout, $method); + /// @todo how to handle the case of $this->return_type = xml? + if (is_array($results)) { + // System.multicall succeeded + return $results; + } else { + // either system.multicall is unsupported by server, or the call failed for some other reason. + // Feature creep: is there a way to tell apart unsupported multicall from other faults? + if ($fallback) { + // Don't try it next time... + $this->no_multicall = true; + } else { + $result = $results; + } + } + } else { + // override fallback, in case careless user tries to do two + // opposite things at the same time + $fallback = true; + } + + $results = array(); + if ($fallback) { + // system.multicall is (probably) unsupported by server: emulate multicall via multiple requests + /// @todo use curl multi_ functions to make this quicker (see the implementation in the parallel.php demo) + foreach ($reqs as $req) { + $results[] = $this->send($req, $timeout, $method); + } + } else { + // user does NOT want to fallback on many single calls: since we should always return an array of responses, + // we return an array with the same error repeated n times + foreach ($reqs as $req) { + $results[] = $result; + } + } + + return $results; + } + + /** + * Attempt to boxcar $reqs via system.multicall. + * + * @param Request[] $reqs + * @param int $timeout + * @param string $method + * @return Response[]|Response a single Response when the call returned a fault / does not conform to what we expect + * from a multicall response + */ + private function _try_multicall($reqs, $timeout, $method) + { + // Construct multicall request + $calls = array(); + foreach ($reqs as $req) { + $call['methodName'] = new Value($req->method(), 'string'); + $numParams = $req->getNumParams(); + $params = array(); + for ($i = 0; $i < $numParams; $i++) { + $params[$i] = $req->getParam($i); + } + $call['params'] = new Value($params, 'array'); + $calls[] = new Value($call, 'struct'); + } + $multiCall = new static::$requestClass('system.multicall'); + $multiCall->addParam(new Value($calls, 'array')); + + // Attempt RPC call + $result = $this->send($multiCall, $timeout, $method); + + if ($result->faultCode() != 0) { + // call to system.multicall failed + return $result; + } + + // Unpack responses. + $rets = $result->value(); + $response = array(); + + if ($this->return_type == 'xml') { + for ($i = 0; $i < count($reqs); $i++) { + $response[] = new static::$responseClass($rets, 0, '', 'xml', $result->httpResponse()); + } + + } elseif ($this->return_type == 'phpvals') { + if (!is_array($rets)) { + // bad return type from system.multicall + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ': not an array', 'phpvals', $result->httpResponse()); + } + $numRets = count($rets); + if ($numRets != count($reqs)) { + // wrong number of return values. + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ': incorrect number of responses', 'phpvals', + $result->httpResponse()); + } + + for ($i = 0; $i < $numRets; $i++) { + $val = $rets[$i]; + if (!is_array($val)) { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i is not an array or struct", + 'phpvals', $result->httpResponse()); + } + switch (count($val)) { + case 1: + if (!isset($val[0])) { + // Bad value + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has no value", + 'phpvals', $result->httpResponse()); + } + // Normal return value + $response[$i] = new static::$responseClass($val[0], 0, '', 'phpvals', $result->httpResponse()); + break; + case 2: + /// @todo remove usage of @: it is apparently quite slow + $code = @$val['faultCode']; + if (!is_int($code)) { + /// @todo should we check that it is != 0? + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has invalid or no faultCode", + 'phpvals', $result->httpResponse()); + } + $str = @$val['faultString']; + if (!is_string($str)) { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has invalid or no FaultString", + 'phpvals', $result->httpResponse()); + } + $response[$i] = new static::$responseClass(0, $code, $str, 'phpvals', $result->httpResponse()); + break; + default: + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has too many items", + 'phpvals', $result->httpResponse()); + } + } + + } else { + // return type == 'xmlrpcvals' + if ($rets->kindOf() != 'array') { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i is not an array", 'xmlrpcvals', + $result->httpResponse()); + } + $numRets = $rets->count(); + if ($numRets != count($reqs)) { + // wrong number of return values. + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ': incorrect number of responses', 'xmlrpcvals', + $result->httpResponse()); + } + + foreach ($rets as $i => $val) { + switch ($val->kindOf()) { + case 'array': + if ($val->count() != 1) { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has too many items", + 'phpvals', $result->httpResponse()); + } + // Normal return value + $response[] = new static::$responseClass($val[0], 0, '', 'xmlrpcvals', $result->httpResponse()); + break; + case 'struct': + if ($val->count() != 2) { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has too many items", + 'phpvals', $result->httpResponse()); + } + /** @var Value $code */ + $code = $val['faultCode']; + if ($code->kindOf() != 'scalar' || $code->scalarTyp() != 'int') { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has invalid or no faultCode", + 'xmlrpcvals', $result->httpResponse()); + } + /** @var Value $str */ + $str = $val['faultString']; + if ($str->kindOf() != 'scalar' || $str->scalarTyp() != 'string') { + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i has invalid or no faultCode", + 'xmlrpcvals', $result->httpResponse()); + } + $response[] = new static::$responseClass(0, $code->scalarVal(), $str->scalarVal(), 'xmlrpcvals', $result->httpResponse()); + break; + default: + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], + PhpXmlRpc::$xmlrpcstr['multicall_error'] . ": response element $i is not an array or struct", + 'xmlrpcvals', $result->httpResponse()); + } + } + } + + return $response; + } + + // *** BC layer *** + /** * @deprecated + * * @param Request $req * @param string $server * @param int $port @@ -592,9 +1711,9 @@ class Client */ protected function sendPayloadHTTP10($req, $server, $port, $timeout = 0, $username = '', $password = '', $authType = 1, $proxyHost = '', $proxyPort = 0, $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, - $method='http') + $method = 'http') { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); return $this->sendPayloadSocket($req, $server, $port, $timeout, $username, $password, $authType, null, null, null, null, $proxyHost, $proxyPort, $proxyUsername, $proxyPassword, $proxyAuthType, $method); @@ -602,6 +1721,7 @@ class Client /** * @deprecated + * * @param Request $req * @param string $server * @param int $port @@ -624,12 +1744,12 @@ class Client * @param int $sslVersion * @return Response */ - protected function sendPayloadHTTPS($req, $server, $port, $timeout = 0, $username = '', $password = '', + protected function sendPayloadHTTPS($req, $server, $port, $timeout = 0, $username = '', $password = '', $authType = 1, $cert = '', $certPass = '', $caCert = '', $caCertDir = '', $proxyHost = '', $proxyPort = 0, $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, $keepAlive = false, $key = '', $keyPass = '', $sslVersion = 0) { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); return $this->sendPayloadCURL($req, $server, $port, $timeout, $username, $password, $authType, $cert, $certPass, $caCert, $caCertDir, $proxyHost, $proxyPort, @@ -637,6 +1757,8 @@ class Client } /** + * @deprecated + * * @param Request $req * @param string $server * @param int $port @@ -658,205 +1780,51 @@ class Client * @param string $keyPass @todo not implemented yet. * @param int $sslVersion @todo not implemented yet. See http://php.net/manual/en/migration56.openssl.php * @return Response - * - * @todo refactor: we get many options for the call passed in, but some we use from $this. We should clean that up */ protected function sendPayloadSocket($req, $server, $port, $timeout = 0, $username = '', $password = '', $authType = 1, $cert = '', $certPass = '', $caCert = '', $caCertDir = '', $proxyHost = '', $proxyPort = 0, - $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, $method='http', $key = '', $keyPass = '', + $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, $method = 'http', $key = '', $keyPass = '', $sslVersion = 0) { - /// @todo log a warning if passed an unsupported method + $this->logDeprecationUnlessCalledBy('send'); - if ($port == 0) { - $port = ( $method === 'https' ) ? 443 : 80; - } - - // Only create the payload if it was not created previously - if (empty($req->payload)) { - $req->serialize($this->request_charset_encoding); - } - - $payload = $req->payload; - // Deflate request body and set appropriate request headers - $encodingHdr = ''; - if (function_exists('gzdeflate') && ($this->request_compression == 'gzip' || $this->request_compression == 'deflate')) { - if ($this->request_compression == 'gzip') { - $a = @gzencode($payload); - if ($a) { - $payload = $a; - $encodingHdr = "Content-Encoding: gzip\r\n"; - } - } else { - $a = @gzcompress($payload); - if ($a) { - $payload = $a; - $encodingHdr = "Content-Encoding: deflate\r\n"; - } - } - } - - // thanks to Grant Rauscher for this - $credentials = ''; - if ($username != '') { - $credentials = 'Authorization: Basic ' . base64_encode($username . ':' . $password) . "\r\n"; - if ($authType != 1) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth is supported with HTTP 1.0'); - } - } - - $acceptedEncoding = ''; - if (is_array($this->accepted_compression) && count($this->accepted_compression)) { - $acceptedEncoding = 'Accept-Encoding: ' . implode(', ', $this->accepted_compression) . "\r\n"; - } - - $proxyCredentials = ''; - if ($proxyHost) { - if ($proxyPort == 0) { - $proxyPort = 8080; - } - $connectServer = $proxyHost; - $connectPort = $proxyPort; - $transport = 'tcp'; - $uri = 'http://' . $server . ':' . $port . $this->path; - if ($proxyUsername != '') { - if ($proxyAuthType != 1) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth to proxy is supported with HTTP 1.0'); - } - $proxyCredentials = 'Proxy-Authorization: Basic ' . base64_encode($proxyUsername . ':' . $proxyPassword) . "\r\n"; - } - } else { - $connectServer = $server; - $connectPort = $port; - $transport = ( $method === 'https' ) ? 'tls' : 'tcp'; - $uri = $this->path; - } - - // Cookie generation, as per rfc2965 (version 1 cookies) or netscape's rules (version 0 cookies) - $cookieHeader = ''; - if (count($this->cookies)) { - $version = ''; - foreach ($this->cookies as $name => $cookie) { - if ($cookie['version']) { - $version = ' $Version="' . $cookie['version'] . '";'; - $cookieHeader .= ' ' . $name . '="' . $cookie['value'] . '";'; - if ($cookie['path']) { - $cookieHeader .= ' $Path="' . $cookie['path'] . '";'; - } - if ($cookie['domain']) { - $cookieHeader .= ' $Domain="' . $cookie['domain'] . '";'; - } - if ($cookie['port']) { - $cookieHeader .= ' $Port="' . $cookie['port'] . '";'; - } - } else { - $cookieHeader .= ' ' . $name . '=' . $cookie['value'] . ";"; - } - } - $cookieHeader = 'Cookie:' . $version . substr($cookieHeader, 0, -1) . "\r\n"; - } - - // omit port if default - if (($port == 80 && in_array($method, array('http', 'http10'))) || ($port == 443 && $method == 'https')) { - $port = ''; - } else { - $port = ':' . $port; - } - - $op = 'POST ' . $uri . " HTTP/1.0\r\n" . - 'User-Agent: ' . $this->user_agent . "\r\n" . - 'Host: ' . $server . $port . "\r\n" . - $credentials . - $proxyCredentials . - $acceptedEncoding . - $encodingHdr . - 'Accept-Charset: ' . implode(',', $this->accepted_charset_encodings) . "\r\n" . - $cookieHeader . - 'Content-Type: ' . $req->content_type . "\r\nContent-Length: " . - strlen($payload) . "\r\n\r\n" . - $payload; - - if ($this->debug > 1) { - $this->getLogger()->debugMessage("---SENDING---\n$op\n---END---"); - } - - $contextOptions = array(); - if ($method == 'https') { - if ($cert != '') { - $contextOptions['ssl']['local_cert'] = $cert; - if ($certPass != '') { - $contextOptions['ssl']['passphrase'] = $certPass; - } - } - if ($caCert != '') { - $contextOptions['ssl']['cafile'] = $caCert; - } - if ($caCertDir != '') { - $contextOptions['ssl']['capath'] = $caCertDir; - } - if ($key != '') { - $contextOptions['ssl']['local_pk'] = $key; - } - $contextOptions['ssl']['verify_peer'] = $this->verifypeer; - $contextOptions['ssl']['verify_peer_name'] = $this->verifypeer; - } - - $context = stream_context_create($contextOptions); - - if ($timeout <= 0) { - $connectTimeout = ini_get('default_socket_timeout'); - } else { - $connectTimeout = $timeout; - } - - $this->errno = 0; - $this->errstr = ''; - - $fp = @stream_socket_client("$transport://$connectServer:$connectPort", $this->errno, $this->errstr, $connectTimeout, - STREAM_CLIENT_CONNECT, $context); - if ($fp) { - if ($timeout > 0) { - stream_set_timeout($fp, $timeout); - } - } else { - if ($this->errstr == '') { - $err = error_get_last(); - $this->errstr = $err['message']; - } - - $this->errstr = 'Connect error: ' . $this->errstr; - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['http_error'], $this->errstr . ' (' . $this->errno . ')'); - - return $r; - } - - if (!fputs($fp, $op, strlen($op))) { - fclose($fp); - $this->errstr = 'Write error'; - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['http_error'], $this->errstr); - - return $r; - } - - // Close socket before parsing. - // It should yield slightly better execution times, and make easier recursive calls (e.g. to follow http redirects) - $ipd = ''; - do { - // shall we check for $data === FALSE? - // as per the manual, it signals an error - $ipd .= fread($fp, 32768); - } while (!feof($fp)); - fclose($fp); - - $r = $req->parseResponse($ipd, false, $this->return_type); - - return $r; + return $this->sendViaSocket($req, $method, $server, $port, $this->path, array( + 'accepted_charset_encodings' => $this->accepted_charset_encodings, + 'accepted_compression' => $this->accepted_compression, + 'authtype' => $authType, + 'cacert' => $caCert, + 'cacertdir' => $caCertDir, + 'cert' => $cert, + 'certpass' => $certPass, + 'cookies' => $this->cookies, + 'debug' => $this->debug, + 'extracurlopts' => $this->extracurlopts, + 'extrasockopts' => $this->extrasockopts, + 'keepalive' => $this->keepalive, + 'key' => $key, + 'keypass' => $keyPass, + 'no_multicall' => $this->no_multicall, + 'password' => $password, + 'proxy' => $proxyHost, + 'proxy_authtype' => $proxyAuthType, + 'proxy_pass' => $proxyPassword, + 'proxyport' => $proxyPort, + 'proxy_user' => $proxyUsername, + 'request_charset_encoding' => $this->request_charset_encoding, + 'request_compression' => $this->request_compression, + 'return_type' => $this->return_type, + 'sslversion' => $sslVersion, + 'timeout' => $timeout, + 'username' => $username, + 'user_agent' => $this->user_agent, + 'use_curl' => $this->use_curl, + 'verifyhost' => $this->verifyhost, + 'verifypeer' => $this->verifypeer, + )); } /** - * Contributed by Justin Miller - * Requires curl to be built into PHP - * NB: CURL versions before 7.11.10 cannot use proxy to talk to https servers! + * @deprecated * * @param Request $req * @param string $server @@ -880,472 +1848,210 @@ class Client * @param string $keyPass * @param int $sslVersion * @return Response - * - * @todo refactor: we get many options for the call passed in, but some we use from $this. We should clean that up */ protected function sendPayloadCURL($req, $server, $port, $timeout = 0, $username = '', $password = '', $authType = 1, $cert = '', $certPass = '', $caCert = '', $caCertDir = '', $proxyHost = '', $proxyPort = 0, $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, $method = 'https', $keepAlive = false, $key = '', $keyPass = '', $sslVersion = 0) { - if (!function_exists('curl_init')) { - $this->errstr = 'CURL unavailable on this install'; - return new Response(0, PhpXmlRpc::$xmlrpcerr['no_curl'], PhpXmlRpc::$xmlrpcstr['no_curl']); - } - if ($method == 'https' || $method == 'h2') { - // q: what about installs where we get back a string, but curl is linked to other ssl libs than openssl? - if (($info = curl_version()) && - ((is_string($info) && strpos($info, 'OpenSSL') === null) || (is_array($info) && !isset($info['ssl_version']))) - ) { - $this->errstr = 'SSL unavailable on this install'; - return new Response(0, PhpXmlRpc::$xmlrpcerr['no_ssl'], PhpXmlRpc::$xmlrpcstr['no_ssl']); - } - } - if (($method == 'h2' && !defined('CURL_HTTP_VERSION_2_0')) || - ($method == 'h2c' && !defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE'))) { - $this->errstr = 'HTTP/2 unavailable on this install'; - return new Response(0, PhpXmlRpc::$xmlrpcerr['no_http2'], PhpXmlRpc::$xmlrpcstr['no_http2']); - } + $this->logDeprecationUnlessCalledBy('send'); - $curl = $this->prepareCurlHandle($req, $server, $port, $timeout, $username, $password, - $authType, $cert, $certPass, $caCert, $caCertDir, $proxyHost, $proxyPort, - $proxyUsername, $proxyPassword, $proxyAuthType, $method, $keepAlive, $key, - $keyPass, $sslVersion); - - $result = curl_exec($curl); - - if ($this->debug > 1) { - $message = "---CURL INFO---\n"; - foreach (curl_getinfo($curl) as $name => $val) { - if (is_array($val)) { - $val = implode("\n", $val); - } - $message .= $name . ': ' . $val . "\n"; - } - $message .= '---END---'; - $this->getLogger()->debugMessage($message); - } - - if (!$result) { - /// @todo we should use a better check here - what if we get back '' or '0'? - - $this->errstr = 'no response'; - $resp = new Response(0, PhpXmlRpc::$xmlrpcerr['curl_fail'], PhpXmlRpc::$xmlrpcstr['curl_fail'] . ': ' . curl_error($curl)); - curl_close($curl); - if ($keepAlive) { - $this->xmlrpc_curl_handle = null; - } - } else { - if (!$keepAlive) { - curl_close($curl); - } - $resp = $req->parseResponse($result, true, $this->return_type); - // if we got back a 302, we can not reuse the curl handle for later calls - if ($resp->faultCode() == PhpXmlRpc::$xmlrpcerr['http_error'] && $keepAlive) { - curl_close($curl); - $this->xmlrpc_curl_handle = null; - } - } - - return $resp; + return $this->sendViaCURL($req, $method, $server, $port, $this->path, array( + 'accepted_charset_encodings' => $this->accepted_charset_encodings, + 'accepted_compression' => $this->accepted_compression, + 'authtype' => $authType, + 'cacert' => $caCert, + 'cacertdir' => $caCertDir, + 'cert' => $cert, + 'certpass' => $certPass, + 'cookies' => $this->cookies, + 'debug' => $this->debug, + 'extracurlopts' => $this->extracurlopts, + 'extrasockopts' => $this->extrasockopts, + 'keepalive' => $keepAlive, + 'key' => $key, + 'keypass' => $keyPass, + 'no_multicall' => $this->no_multicall, + 'password' => $password, + 'proxy' => $proxyHost, + 'proxy_authtype' => $proxyAuthType, + 'proxy_pass' => $proxyPassword, + 'proxyport' => $proxyPort, + 'proxy_user' => $proxyUsername, + 'request_charset_encoding' => $this->request_charset_encoding, + 'request_compression' => $this->request_compression, + 'return_type' => $this->return_type, + 'sslversion' => $sslVersion, + 'timeout' => $timeout, + 'username' => $username, + 'user_agent' => $this->user_agent, + 'use_curl' => $this->use_curl, + 'verifyhost' => $this->verifyhost, + 'verifypeer' => $this->verifypeer, + )); } + /** + * @deprecated + * + * @param $req + * @param $server + * @param $port + * @param $timeout + * @param $username + * @param $password + * @param $authType + * @param $cert + * @param $certPass + * @param $caCert + * @param $caCertDir + * @param $proxyHost + * @param $proxyPort + * @param $proxyUsername + * @param $proxyPassword + * @param $proxyAuthType + * @param $method + * @param $keepAlive + * @param $key + * @param $keyPass + * @param $sslVersion + * @return false|\CurlHandle|resource + */ protected function prepareCurlHandle($req, $server, $port, $timeout = 0, $username = '', $password = '', $authType = 1, $cert = '', $certPass = '', $caCert = '', $caCertDir = '', $proxyHost = '', $proxyPort = 0, $proxyUsername = '', $proxyPassword = '', $proxyAuthType = 1, $method = 'https', $keepAlive = false, $key = '', $keyPass = '', $sslVersion = 0) { - if ($port == 0) { - if (in_array($method, array('http', 'http10', 'http11', 'h2c'))) { - $port = 80; - } else { - $port = 443; - } - } + $this->logDeprecationUnlessCalledBy('sendViaCURL'); - // Only create the payload if it was not created previously - if (empty($req->payload)) { - $req->serialize($this->request_charset_encoding); - } - - // Deflate request body and set appropriate request headers - $payload = $req->payload; - if (function_exists('gzdeflate') && ($this->request_compression == 'gzip' || $this->request_compression == 'deflate')) { - if ($this->request_compression == 'gzip') { - $a = @gzencode($payload); - if ($a) { - $payload = $a; - $encodingHdr = 'Content-Encoding: gzip'; - } - } else { - $a = @gzcompress($payload); - if ($a) { - $payload = $a; - $encodingHdr = 'Content-Encoding: deflate'; - } - } - } else { - $encodingHdr = ''; - } - - if (!$keepAlive || !$this->xmlrpc_curl_handle) { - if ($method == 'http11' || $method == 'http10' || $method == 'h2c') { - $protocol = 'http'; - } else { - if ($method == 'h2') { - $protocol = 'https'; - } else { - // http, https - $protocol = $method; - } - } - $curl = curl_init($protocol . '://' . $server . ':' . $port . $this->path); - if ($keepAlive) { - $this->xmlrpc_curl_handle = $curl; - } - } else { - $curl = $this->xmlrpc_curl_handle; - } - - // results into variable - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - - if ($this->debug > 1) { - curl_setopt($curl, CURLOPT_VERBOSE, true); - /// @todo allow callers to redirect curlopt_stderr to some stream which can be buffered - } - curl_setopt($curl, CURLOPT_USERAGENT, $this->user_agent); - // required for XMLRPC: post the data - curl_setopt($curl, CURLOPT_POST, 1); - // the data - curl_setopt($curl, CURLOPT_POSTFIELDS, $payload); - - // return the header too - curl_setopt($curl, CURLOPT_HEADER, 1); - - // NB: if we set an empty string, CURL will add http header indicating - // ALL methods it is supporting. This is possibly a better option than letting the user tell what curl can / cannot do... - if (is_array($this->accepted_compression) && count($this->accepted_compression)) { - //curl_setopt($curl, CURLOPT_ENCODING, implode(',', $this->accepted_compression)); - // empty string means 'any supported by CURL' (shall we catch errors in case CURLOPT_SSLKEY undefined ?) - if (count($this->accepted_compression) == 1) { - curl_setopt($curl, CURLOPT_ENCODING, $this->accepted_compression[0]); - } else { - curl_setopt($curl, CURLOPT_ENCODING, ''); - } - } - // extra headers - $headers = array('Content-Type: ' . $req->content_type, 'Accept-Charset: ' . implode(',', $this->accepted_charset_encodings)); - // if no keepalive is wanted, let the server know it in advance - if (!$keepAlive) { - $headers[] = 'Connection: close'; - } - // request compression header - if ($encodingHdr) { - $headers[] = $encodingHdr; - } - - // Fix the HTTP/1.1 417 Expectation Failed Bug (curl by default adds a 'Expect: 100-continue' header when POST - // size exceeds 1025 bytes, apparently) - $headers[] = 'Expect:'; - - curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); - // timeout is borked - if ($timeout) { - curl_setopt($curl, CURLOPT_TIMEOUT, $timeout == 1 ? 1 : $timeout - 1); - } - - switch($method) { - case 'http10': - curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0); - break; - case 'http11': - curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); - break; - case 'h2c': - curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE); - break; - case 'h2': - curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); - break; - } - - if ($username && $password) { - curl_setopt($curl, CURLOPT_USERPWD, $username . ':' . $password); - if (defined('CURLOPT_HTTPAUTH')) { - curl_setopt($curl, CURLOPT_HTTPAUTH, $authType); - } elseif ($authType != 1) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth is supported by the current PHP/curl install'); - } - } - - if ($method == 'https' || $method == 'h2') { - // set cert file - if ($cert) { - curl_setopt($curl, CURLOPT_SSLCERT, $cert); - } - // set cert password - if ($certPass) { - curl_setopt($curl, CURLOPT_SSLCERTPASSWD, $certPass); - } - // whether to verify remote host's cert - curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $this->verifypeer); - // set ca certificates file/dir - if ($caCert) { - curl_setopt($curl, CURLOPT_CAINFO, $caCert); - } - if ($caCertDir) { - curl_setopt($curl, CURLOPT_CAPATH, $caCertDir); - } - // set key file (shall we catch errors in case CURLOPT_SSLKEY undefined ?) - if ($key) { - curl_setopt($curl, CURLOPT_SSLKEY, $key); - } - // set key password (shall we catch errors in case CURLOPT_SSLKEY undefined ?) - if ($keyPass) { - curl_setopt($curl, CURLOPT_SSLKEYPASSWD, $keyPass); - } - // whether to verify cert's common name (CN); 0 for no, 1 to verify that it exists, and 2 to verify that - // it matches the hostname used - curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $this->verifyhost); - // allow usage of different SSL versions - curl_setopt($curl, CURLOPT_SSLVERSION, $sslVersion); - } - - // proxy info - if ($proxyHost) { - if ($proxyPort == 0) { - $proxyPort = 8080; // NB: even for HTTPS, local connection is on port 8080 - } - curl_setopt($curl, CURLOPT_PROXY, $proxyHost . ':' . $proxyPort); - if ($proxyUsername) { - curl_setopt($curl, CURLOPT_PROXYUSERPWD, $proxyUsername . ':' . $proxyPassword); - if (defined('CURLOPT_PROXYAUTH')) { - curl_setopt($curl, CURLOPT_PROXYAUTH, $proxyAuthType); - } elseif ($proxyAuthType != 1) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': warning. Only Basic auth to proxy is supported by the current PHP/curl install'); - } - } - } - - // NB: should we build cookie http headers by hand rather than let CURL do it? - // the following code does not honour 'expires', 'path' and 'domain' cookie attributes set to client obj the the user... - if (count($this->cookies)) { - $cookieHeader = ''; - foreach ($this->cookies as $name => $cookie) { - $cookieHeader .= $name . '=' . $cookie['value'] . '; '; - } - curl_setopt($curl, CURLOPT_COOKIE, substr($cookieHeader, 0, -2)); - } - - foreach ($this->extracurlopts as $opt => $val) { - curl_setopt($curl, $opt, $val); - } - - if ($this->debug > 1) { - $this->getLogger()->debugMessage("---SENDING---\n$payload\n---END---"); - } - - return $curl; + return $this->createCURLHandle($req, $method, $server, $port, $this->path, array( + 'accepted_charset_encodings' => $this->accepted_charset_encodings, + 'accepted_compression' => $this->accepted_compression, + 'authtype' => $authType, + 'cacert' => $caCert, + 'cacertdir' => $caCertDir, + 'cert' => $cert, + 'certpass' => $certPass, + 'cookies' => $this->cookies, + 'debug' => $this->debug, + 'extracurlopts' => $this->extracurlopts, + 'keepalive' => $keepAlive, + 'key' => $key, + 'keypass' => $keyPass, + 'no_multicall' => $this->no_multicall, + 'password' => $password, + 'proxy' => $proxyHost, + 'proxy_authtype' => $proxyAuthType, + 'proxy_pass' => $proxyPassword, + 'proxyport' => $proxyPort, + 'proxy_user' => $proxyUsername, + 'request_charset_encoding' => $this->request_charset_encoding, + 'request_compression' => $this->request_compression, + 'return_type' => $this->return_type, + 'sslversion' => $sslVersion, + 'timeout' => $timeout, + 'username' => $username, + 'user_agent' => $this->user_agent, + 'use_curl' => $this->use_curl, + 'verifyhost' => $this->verifyhost, + 'verifypeer' => $this->verifypeer, + )); } - /** - * Send an array of requests and return an array of responses. - * - * Unless $this->no_multicall has been set to true, it will try first to use one single xmlrpc call to server method - * system.multicall, and revert to sending many successive calls in case of failure. - * This failure is also stored in $this->no_multicall for subsequent calls. - * Unfortunately, there is no server error code universally used to denote the fact that multicall is unsupported, - * so there is no way to reliably distinguish between that and a temporary failure. - * If you are sure that server supports multicall and do not want to fallback to using many single calls, set the - * fourth parameter to FALSE. - * - * NB: trying to shoehorn extra functionality into existing syntax has resulted - * in pretty much convoluted code... - * - * @param Request[] $reqs an array of Request objects - * @param integer $timeout connection timeout (in seconds). See the details in the docs for the send() method - * @param string $method the http protocol variant to be used. See the details in the docs for the send() method - * @param boolean fallback When true, upon receiving an error during multicall, multiple single calls will be - * attempted - * - * @return Response[] - */ - public function multicall($reqs, $timeout = 0, $method = '', $fallback = true) + // we have to make this return by ref in order to allow calls such as `$resp->_cookies['name'] = ['value' => 'something'];` + public function &__get($name) { - if ($method == '') { - $method = $this->method; - } - if (!$this->no_multicall) { - $results = $this->_try_multicall($reqs, $timeout, $method); - if (is_array($results)) { - // System.multicall succeeded - return $results; - } else { - // either system.multicall is unsupported by server, - // or call failed for some other reason. - if ($fallback) { - // Don't try it next time... - $this->no_multicall = true; - } else { - if (is_a($results, '\PhpXmlRpc\Response')) { - $result = $results; - } else { - $result = new Response(0, PhpXmlRpc::$xmlrpcerr['multicall_error'], PhpXmlRpc::$xmlrpcstr['multicall_error']); - } - } - } - } else { - // override fallback, in case careless user tries to do two - // opposite things at the same time - $fallback = true; + if (in_array($name, static::$options)) { + $this->logDeprecation('Getting property Client::' . $name . ' is deprecated'); + return $this->$name; } - $results = array(); - if ($fallback) { - // system.multicall is (probably) unsupported by server: - // emulate multicall via multiple requests - /// @todo use curl multi_ functions to make this quicker - foreach ($reqs as $req) { - $results[] = $this->send($req, $timeout, $method); - } - } else { - // user does NOT want to fallback on many single calls: - // since we should always return an array of responses, - // return an array with the same error repeated n times - foreach ($reqs as $req) { - $results[] = $result; - } + switch ($name) { + case 'errno': + case 'errstr': + case 'method': + case 'server': + case 'port': + case 'path': + $this->logDeprecation('Getting property Client::' . $name . ' is deprecated'); + return $this->$name; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + $result = null; + return $result; } - - return $results; } - /** - * Attempt to boxcar $reqs via system.multicall. - * - * Returns either an array of Response, a single error Response or false (when received response does not respect - * valid multicall syntax). - * - * @param Request[] $reqs - * @param int $timeout - * @param string $method - * @return Response[]|false|mixed|Response - */ - private function _try_multicall($reqs, $timeout, $method) + public function __set($name, $value) { - // Construct multicall request - $calls = array(); - foreach ($reqs as $req) { - $call['methodName'] = new Value($req->method(), 'string'); - $numParams = $req->getNumParams(); - $params = array(); - for ($i = 0; $i < $numParams; $i++) { - $params[$i] = $req->getParam($i); - } - $call['params'] = new Value($params, 'array'); - $calls[] = new Value($call, 'struct'); - } - $multiCall = new Request('system.multicall'); - $multiCall->addParam(new Value($calls, 'array')); - - // Attempt RPC call - $result = $this->send($multiCall, $timeout, $method); - - if ($result->faultCode() != 0) { - // call to system.multicall failed - return $result; + if (in_array($name, static::$options)) { + $this->logDeprecation('Setting property Client::' . $name . ' is deprecated'); + $this->$name = $value; + return; } - // Unpack responses. - $rets = $result->value(); + switch ($name) { + case 'errno': + case 'errstr': + case 'method': + case 'server': + case 'port': + case 'path': + $this->logDeprecation('Setting property Client::' . $name . ' is deprecated'); + $this->$name = $value; + return; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } - if ($this->return_type == 'xml') { - return $rets; - } elseif ($this->return_type == 'phpvals') { - /// @todo test this code branch... - $rets = $result->value(); - if (!is_array($rets)) { - return false; // bad return type from system.multicall - } - $numRets = count($rets); - if ($numRets != count($reqs)) { - return false; // wrong number of return values. - } + public function __isset($name) + { + if (in_array($name, static::$options)) { + $this->logDeprecation('Checking property Client::' . $name . ' is deprecated'); + return isset($this->$name); + } - $response = array(); - for ($i = 0; $i < $numRets; $i++) { - $val = $rets[$i]; - if (!is_array($val)) { - return false; - } - switch (count($val)) { - case 1: - if (!isset($val[0])) { - return false; // Bad value - } - // Normal return value - $response[$i] = new Response($val[0], 0, '', 'phpvals'); - break; - case 2: - /// @todo remove usage of @: it is apparently quite slow - $code = @$val['faultCode']; - if (!is_int($code)) { - return false; - } - $str = @$val['faultString']; - if (!is_string($str)) { - return false; - } - $response[$i] = new Response(0, $code, $str); - break; - default: - return false; - } - } + switch ($name) { + case 'errno': + case 'errstr': + case 'method': + case 'server': + case 'port': + case 'path': + $this->logDeprecation('Checking property Client::' . $name . ' is deprecated'); + return isset($this->$name); + default: + return false; + } + } - return $response; - } else { - // return type == 'xmlrpcvals' + public function __unset($name) + { + if (in_array($name, static::$options)) { + $this->logDeprecation('Unsetting property Client::' . $name . ' is deprecated'); + unset($this->$name); + return; + } - $rets = $result->value(); - if ($rets->kindOf() != 'array') { - return false; // bad return type from system.multicall - } - $numRets = $rets->count(); - if ($numRets != count($reqs)) { - return false; // wrong number of return values. - } - - $response = array(); - foreach($rets as $val) { - switch ($val->kindOf()) { - case 'array': - if ($val->count() != 1) { - return false; // Bad value - } - // Normal return value - $response[] = new Response($val[0]); - break; - case 'struct': - $code = $val['faultCode']; - /** @var Value $code */ - if ($code->kindOf() != 'scalar' || $code->scalartyp() != 'int') { - return false; - } - $str = $val['faultString']; - /** @var Value $str */ - if ($str->kindOf() != 'scalar' || $str->scalartyp() != 'string') { - return false; - } - $response[] = new Response(0, $code->scalarval(), $str->scalarval()); - break; - default: - return false; - } - } - - return $response; + switch ($name) { + case 'errno': + case 'errstr': + case 'method': + case 'server': + case 'port': + case 'path': + $this->logDeprecation('Unsetting property Client::' . $name . ' is deprecated'); + unset($this->$name); + return; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); } } } diff --git a/lib/phpxmlrpc/Encoder.php b/lib/phpxmlrpc/Encoder.php index 2bee668b4fb..871d1cbf12f 100644 --- a/lib/phpxmlrpc/Encoder.php +++ b/lib/phpxmlrpc/Encoder.php @@ -2,73 +2,54 @@ namespace PhpXmlRpc; -use PhpXmlRpc\Helper\Logger; use PhpXmlRpc\Helper\XMLParser; +use PhpXmlRpc\Traits\LoggerAware; +use PhpXmlRpc\Traits\ParserAware; /** - * A helper class to easily convert between Value objects and php native values + * A helper class to easily convert between Value objects and php native values. + * * @todo implement an interface * @todo add class constants for the options values */ class Encoder { - protected static $logger; - protected static $parser; - - public function getLogger() - { - if (self::$logger === null) { - self::$logger = Logger::instance(); - } - return self::$logger; - } - - public static function setLogger($logger) - { - self::$logger = $logger; - } - - public function getParser() - { - if (self::$parser === null) { - self::$parser = new XMLParser(); - } - return self::$parser; - } - - public static function setParser($parser) - { - self::$parser = $parser; - } + use LoggerAware; + use ParserAware; /** - * Takes an xmlrpc value in object format and translates it into native PHP types. - * - * Works with xmlrpc requests objects as input, too. - * + * Takes an xml-rpc Value in object instance and translates it into native PHP types, recursively. + * Works with xml-rpc Request objects as input, too. + * Xmlrpc dateTime values will be converted to strings or DateTime objects depending on an $options parameter + * Supports i8 and NIL xml-rpc values without the need for specific options. + * Both xml-rpc arrays and structs are decoded into PHP arrays, with the exception described below: * Given proper options parameter, can rebuild generic php object instances (provided those have been encoded to - * xmlrpc format using a corresponding option in php_xmlrpc_encode()) + * xml-rpc format using a corresponding option in php_xmlrpc_encode()). * PLEASE NOTE that rebuilding php objects involves calling their constructor function. * This means that the remote communication end can decide which php code will get executed on your server, leaving * the door possibly open to 'php-injection' style of attacks (provided you have some classes defined on your server * that might wreak havoc if instances are built outside an appropriate context). * Make sure you trust the remote server/client before enabling this! * - * @author Dan Libby (dan@libby.com) + * @author Dan Libby * * @param Value|Request $xmlrpcVal - * @param array $options if 'decode_php_objs' is set in the options array, xmlrpc structs can be decoded into php - * objects; if 'dates_as_objects' is set xmlrpc datetimes are decoded as php DateTime objects - * + * @param array $options accepted elements: + * - 'decode_php_objs': if set in the options array, xml-rpc structs can be decoded into php + * objects, see the details above; + * - 'dates_as_objects': when set xml-rpc dateTimes are decoded as php DateTime objects + * - 'extension_api': reserved for usage by phpxmlrpc-polyfill * @return mixed + * + * Feature creep -- add an option to allow converting xml-rpc dateTime values to unix timestamps (integers) */ public function decode($xmlrpcVal, $options = array()) { switch ($xmlrpcVal->kindOf()) { case 'scalar': if (in_array('extension_api', $options)) { - $val = reset($xmlrpcVal->me); - $typ = key($xmlrpcVal->me); + $val = $xmlrpcVal->scalarVal(); + $typ = $xmlrpcVal->scalarTyp(); switch ($typ) { case 'dateTime.iso8601': $xmlrpcVal = array( @@ -85,38 +66,48 @@ class Encoder return (object)$xmlrpcVal; case 'string': if (isset($options['extension_api_encoding'])) { - $dval = @iconv('UTF-8', $options['extension_api_encoding'], $val); + // if iconv is not available, we use mb_convert_encoding + if (function_exists('iconv')) { + $dval = @iconv('UTF-8', $options['extension_api_encoding'], $val); + } elseif (function_exists('mb_convert_encoding')) { + /// @todo check for discrepancies between the supported charset names + $dval = @mb_convert_encoding($val, $options['extension_api_encoding'], 'UTF-8'); + } else { + $dval = false; + } if ($dval !== false) { return $dval; } } - //return $val; // break through voluntarily default: return $val; } } - if (in_array('dates_as_objects', $options) && $xmlrpcVal->scalartyp() == 'dateTime.iso8601') { - // we return a Datetime object instead of a string since now the constructor of xmlrpc value accepts - // safely strings, ints and datetimes, we cater to all 3 cases here - $out = $xmlrpcVal->scalarval(); + if (in_array('dates_as_objects', $options) && $xmlrpcVal->scalarTyp() == 'dateTime.iso8601') { + // we return a Datetime object instead of a string; since now the constructor of xml-rpc value accepts + // safely string, int and DateTimeInterface, we cater to all 3 cases here + $out = $xmlrpcVal->scalarVal(); if (is_string($out)) { $out = strtotime($out); + // NB: if the string does not convert into a timestamp, this will return false. + // We avoid logging an error here, as we presume it was already done when parsing the xml + /// @todo we could return null, to be more in line with what the XMLParser does... } if (is_int($out)) { $result = new \DateTime(); $result->setTimestamp($out); return $result; - } elseif (is_a($out, 'DateTimeInterface')) { + } elseif (is_a($out, 'DateTimeInterface') || is_a($out, 'DateTime')) { return $out; } } - return $xmlrpcVal->scalarval(); + return $xmlrpcVal->scalarVal(); case 'array': $arr = array(); - foreach($xmlrpcVal as $value) { + foreach ($xmlrpcVal as $value) { $arr[] = $this->decode($value, $options); } return $arr; @@ -124,7 +115,7 @@ class Encoder case 'struct': // If user said so, try to rebuild php objects for specific struct vals. /// @todo should we raise a warning for class not found? - // shall we check for proper subclass of xmlrpc value instead of presence of _php_class to detect + // shall we check for proper subclass of xml-rpc value instead of presence of _php_class to detect // what we can do? if (in_array('decode_php_objs', $options) && $xmlrpcVal->_php_class != '' && class_exists($xmlrpcVal->_php_class) @@ -155,29 +146,41 @@ class Encoder } /** - * Takes native php types and encodes them into xmlrpc PHP object format. - * It will not re-encode xmlrpc value objects. + * Takes native php types and encodes them into xml-rpc Value objects, recursively. + * PHP strings, integers, floats and booleans have a straightforward encoding - note that integers will _not_ be + * converted to xml-rpc elements, even if they exceed the 32-bit range. + * PHP arrays will be encoded to either xml-rpc structs or arrays, depending on whether they are hashes + * or plain 0..N integer indexed. + * PHP objects will be encoded into xml-rpc structs, except if they implement DateTimeInterface, in which case they + * will be encoded as dateTime values. + * PhpXmlRpc\Value objects will not be double-encoded - which makes it possible to pass in a pre-created base64 Value + * as part of a php array. + * If given a proper $options parameter, php object instances will be encoded into 'special' xml-rpc values, that can + * later be decoded into php object instances by calling php_xmlrpc_decode() with a corresponding option. + * PHP resource and NULL variables will be converted into uninitialized Value objects (which will lead to invalid + * xml-rpc when later serialized); to support encoding of the latter use the appropriate $options parameter. * - * Feature creep -- could support more types via optional type argument - * (string => datetime support has been added, ??? => base64 not yet) - * - * If given a proper options parameter, php object instances will be encoded into 'special' xmlrpc values, that can - * later be decoded into php objects by calling php_xmlrpc_decode() with a corresponding option - * - * @author Dan Libby (dan@libby.com) - * - * @param mixed $phpVal the value to be converted into an xmlrpc value object - * @param array $options can include 'encode_php_objs', 'auto_dates', 'null_extension' or 'extension_api' + * @author Dan Libby * + * @param mixed $phpVal the value to be converted into an xml-rpc value object + * @param array $options can include: + * - 'encode_php_objs' when set, some out-of-band info will be added to the xml produced by + * serializing the built Value, which can later be decoced by this library to rebuild an + * instance of the same php object + * - 'auto_dates': when set, any string which respects the xml-rpc datetime format will be converted to a dateTime Value + * - 'null_extension': when set, php NULL values will be converted to an xml-rpc (or ) Value + * - 'extension_api': reserved for usage by phpxmlrpc-polyfill * @return Value + * + * Feature creep -- could support more types via optional type argument (string => datetime support has been added, + * ??? => base64 not yet). Also: allow auto-encoding of integers to i8 when too-big to fit into i4 */ public function encode($phpVal, $options = array()) { $type = gettype($phpVal); switch ($type) { case 'string': - /// @todo should we be stricter in the accepted dates (ie. reject more of invalid days & times)? - if (in_array('auto_dates', $options) && preg_match('/^[0-9]{8}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/', $phpVal)) { + if (in_array('auto_dates', $options) && preg_match(PhpXmlRpc::$xmlrpc_datetime_format, $phpVal)) { $xmlrpcVal = new Value($phpVal, Value::$xmlrpcDateTime); } else { $xmlrpcVal = new Value($phpVal, Value::$xmlrpcString); @@ -189,15 +192,12 @@ class Encoder case 'double': $xmlrpcVal = new Value($phpVal, Value::$xmlrpcDouble); break; - // Add support for encoding/decoding of booleans, since they are supported in PHP case 'boolean': $xmlrpcVal = new Value($phpVal, Value::$xmlrpcBoolean); break; case 'array': - // PHP arrays can be encoded to either xmlrpc structs or arrays, depending on whether they are hashes - // or plain 0..n integer indexed // A shorter one-liner would be - // $tmp = array_diff(array_keys($phpVal), range(0, count($phpVal)-1)); + // $tmp = array_diff(array_keys($phpVal), range(0, count($phpVal)-1)); // but execution time skyrockets! $j = 0; $arr = array(); @@ -218,7 +218,8 @@ class Encoder case 'object': if (is_a($phpVal, 'PhpXmlRpc\Value')) { $xmlrpcVal = $phpVal; - } elseif (is_a($phpVal, 'DateTimeInterface')) { + // DateTimeInterface is not present in php 5.4... + } elseif (is_a($phpVal, 'DateTimeInterface') || is_a($phpVal, 'DateTime')) { $xmlrpcVal = new Value($phpVal->format('Ymd\TH:i:s'), Value::$xmlrpcDateTime); } elseif (in_array('extension_api', $options) && $phpVal instanceof \stdClass && isset($phpVal->xmlrpc_type)) { // Handle the 'pre-converted' base64 and datetime values @@ -239,13 +240,12 @@ class Encoder } else { $arr = array(); - foreach($phpVal as $k => $v) { + foreach ($phpVal as $k => $v) { $arr[$k] = $this->encode($v, $options); } $xmlrpcVal = new Value($arr, Value::$xmlrpcStruct); if (in_array('encode_php_objs', $options)) { - // let's save original class name into xmlrpc value: - // might be useful later on... + // let's save original class name into xml-rpc value: it might be useful later on... $xmlrpcVal->_php_class = get_class($phpVal); } } @@ -268,8 +268,7 @@ class Encoder break; // catch "user function", "unknown type" default: - // giancarlo pinerolo - // it has to return an empty object in case, not a boolean. + // it has to return an empty object in case, not a boolean. (giancarlo pinerolo) $xmlrpcVal = new Value(); break; } @@ -279,15 +278,17 @@ class Encoder /** * Convert the xml representation of a method response, method request or single - * xmlrpc value into the appropriate object (a.k.a. deserialize). - * - * @todo is this a good name/class for this method? It does something quite different from 'decode' after all - * (returning objects vs returns plain php values)... In fact it belongs rather to a Parser class + * xml-rpc value into the appropriate object (a.k.a. deserialize). * * @param string $xmlVal - * @param array $options - * + * @param array $options unused atm * @return Value|Request|Response|false false on error, or an instance of either Value, Request or Response + * + * @todo is this a good name/class for this method? It does something quite different from 'decode' after all + * (returning objects vs returns plain php values)... In fact, it belongs rather to a Parser class + * @todo feature creep -- we should allow an option to return php native types instead of PhpXmlRpc objects instances + * @todo feature creep -- allow source charset to be passed in as an option, in case the xml misses its declaration + * @todo feature creep -- allow expected type (val/req/resp) to be passed in as an option */ public function decodeXml($xmlVal, $options = array()) { @@ -296,7 +297,7 @@ class Encoder if ($valEncoding != '') { // Since parsing will fail if - // - charset is not specified in the xml prologue, + // - charset is not specified in the xml declaration, // - the encoding is not UTF8 and // - there are non-ascii chars in the text, // we try to work round that... @@ -306,71 +307,80 @@ class Encoder if (function_exists('mb_convert_encoding')) { $xmlVal = mb_convert_encoding($xmlVal, 'UTF-8', $valEncoding); } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': invalid charset encoding of xml text: ' . $valEncoding); + if ($valEncoding == 'ISO-8859-1') { + $xmlVal = utf8_encode($xmlVal); + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': invalid charset encoding of xml text: ' . $valEncoding); + } } } } - // What if internal encoding is not in one of the 3 allowed? We use the broadest one, ie. utf8! - if (!in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { - /// @todo emit a warning - $parserOptions = array(XML_OPTION_TARGET_ENCODING => 'UTF-8'); - } else { + // What if internal encoding is not in one of the 3 allowed? We use the broadest one, i.e. utf8! + /// @todo with php < 5.6, this does not work. We should add a manual conversion of the xml string to UTF8 + if (in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { $parserOptions = array(XML_OPTION_TARGET_ENCODING => PhpXmlRpc::$xmlrpc_internalencoding); + } else { + $parserOptions = array(XML_OPTION_TARGET_ENCODING => 'UTF-8', 'target_charset' => PhpXmlRpc::$xmlrpc_internalencoding); } $xmlRpcParser = $this->getParser(); - $xmlRpcParser->parse( + $_xh = $xmlRpcParser->parse( $xmlVal, XMLParser::RETURN_XMLRPCVALS, XMLParser::ACCEPT_REQUEST | XMLParser::ACCEPT_RESPONSE | XMLParser::ACCEPT_VALUE | XMLParser::ACCEPT_FAULT, $parserOptions ); + // BC + if (!is_array($_xh)) { + $_xh = $xmlRpcParser->_xh; + } - if ($xmlRpcParser->_xh['isf'] > 1) { - // test that $xmlrpc->_xh['value'] is an obj, too??? + if ($_xh['isf'] > 1) { + // test that $_xh['value'] is an obj, too??? - $this->getLogger()->errorLog($xmlRpcParser->_xh['isf_reason']); + $this->getLogger()->error('XML-RPC: ' . $_xh['isf_reason']); return false; } - switch ($xmlRpcParser->_xh['rt']) { + switch ($_xh['rt']) { case 'methodresponse': - $v = $xmlRpcParser->_xh['value']; - if ($xmlRpcParser->_xh['isf'] == 1) { + $v = $_xh['value']; + if ($_xh['isf'] == 1) { /** @var Value $vc */ $vc = $v['faultCode']; /** @var Value $vs */ $vs = $v['faultString']; - $r = new Response(0, $vc->scalarval(), $vs->scalarval()); + $r = new Response(0, $vc->scalarVal(), $vs->scalarVal()); } else { $r = new Response($v); } return $r; case 'methodcall': - $req = new Request($xmlRpcParser->_xh['method']); - for ($i = 0; $i < count($xmlRpcParser->_xh['params']); $i++) { - $req->addParam($xmlRpcParser->_xh['params'][$i]); + $req = new Request($_xh['method']); + for ($i = 0; $i < count($_xh['params']); $i++) { + $req->addParam($_xh['params'][$i]); } return $req; case 'value': - return $xmlRpcParser->_xh['value']; + return $_xh['value']; case 'fault': // EPI api emulation - $v = $xmlRpcParser->_xh['value']; + $v = $_xh['value']; // use a known error code /** @var Value $vc */ - $vc = isset($v['faultCode']) ? $v['faultCode']->scalarval() : PhpXmlRpc::$xmlrpcerr['invalid_return']; + $vc = isset($v['faultCode']) ? $v['faultCode']->scalarVal() : PhpXmlRpc::$xmlrpcerr['invalid_return']; /** @var Value $vs */ - $vs = isset($v['faultString']) ? $v['faultString']->scalarval() : ''; + $vs = isset($v['faultString']) ? $v['faultString']->scalarVal() : ''; if (!is_int($vc) || $vc == 0) { $vc = PhpXmlRpc::$xmlrpcerr['invalid_return']; } return new Response(0, $vc, $vs); + default: return false; } diff --git a/lib/phpxmlrpc/Exception.php b/lib/phpxmlrpc/Exception.php new file mode 100644 index 00000000000..d5d85b48867 --- /dev/null +++ b/lib/phpxmlrpc/Exception.php @@ -0,0 +1,7 @@ + array(), "out" => array()); @@ -27,9 +31,10 @@ class Charset /** * This class is singleton for performance reasons. - * @todo should we just make $xml_iso88591_Entities a static variable instead ? * * @return Charset + * + * @todo should we just make $xml_iso88591_Entities a static variable instead ? */ public static function instance() { @@ -41,7 +46,7 @@ class Charset } /** - * Force usage as singleton + * Force usage as singleton. */ protected function __construct() { @@ -49,7 +54,10 @@ class Charset /** * @param string $tableName - * @throws \Exception for unsupported $tableName + * @return void + * + * @throws ValueErrorException for unsupported $tableName + * * @todo add support for cp1252 as well as latin-2 .. latin-10 * Optimization creep: instead of building all those tables on load, keep them ready-made php files * which are not even included until needed @@ -58,12 +66,12 @@ class Charset * (though no luck when receiving them...) * Note also that, apparently, while 'ISO/IEC 8859-1' has no characters defined for bytes 128 to 159, * IANA ISO-8859-1 does have well-defined 'C1' control codes for those - wikipedia's page on latin-1 says: - * "ISO-8859-1 is the IANA preferred name for this standard when supplemented with the C0 and C1 control codes from ISO/IEC 6429." - * Check what mbstring/iconv do by default with those? + * "ISO-8859-1 is the IANA preferred name for this standard when supplemented with the C0 and C1 control codes + * from ISO/IEC 6429." Check what mbstring/iconv do by default with those? */ protected function buildConversionTable($tableName) { - switch($tableName) { + switch ($tableName) { case 'xml_iso88591_Entities': if (count($this->xml_iso88591_Entities['in'])) { return; @@ -103,7 +111,7 @@ class Charset break;*/ default: - throw new \Exception('Unsupported table: ' . $tableName); + throw new ValueErrorException('Unsupported table: ' . $tableName); } } @@ -111,7 +119,7 @@ class Charset * Convert a string to the correct XML representation in a target charset. * This involves: * - character transformation for all characters which have a different representation in source and dest charsets - * - using 'charset entity' representation for all characters which are outside of the target charset + * - using 'charset entity' representation for all characters which are outside the target charset * * To help correct communication of non-ascii chars inside strings, regardless of the charset used when sending * requests, parsing them, sending responses and parsing responses, an option is to convert all non-ascii chars @@ -121,19 +129,19 @@ class Charset * Note that when not sending a charset encoding mime type along with http headers, we are bound by RFC 3023 to emit * strict us-ascii for 'text/xml' payloads (but we should review RFC 7303, which seems to have changed the rules...) * - * @todo do a bit of basic benchmarking (strtr vs. str_replace) - * @todo make usage of iconv() or mb_string() where available - * @todo support aliases for charset names, eg ASCII, LATIN1, ISO-88591 (see f.e. polyfill-iconv for a list), - * but then take those into account as well in other methods, ie.isValidCharset) - * @todo when converting to ASCII, allow to choose whether to escape the range 0-31,127 (non-print chars) or not - * @todo allow picking different strategies to deal w. invalid chars? eg. source in latin-1 and chars 128-159 - * @todo add support for escaping using CDATA sections? (add cdata start and end tokens, replace only ']]>' with ']]]]>') - * * @param string $data * @param string $srcEncoding * @param string $destEncoding - * * @return string + * + * @todo do a bit of basic benchmarking: strtr vs. str_replace, str_replace vs htmlspecialchars, hand-coded conversion + * vs mbstring when that is enabled + * @todo make use of iconv when it is available and mbstring is not + * @todo support aliases for charset names, eg ASCII, LATIN1, ISO-88591 (see f.e. polyfill-iconv for a list), + * but then take those into account as well in other methods, ie. isValidCharset) + * @todo when converting to ASCII, allow to choose whether to escape the range 0-31,127 (non-print chars) or not + * @todo allow picking different strategies to deal w. invalid chars? eg. source in latin-1 and chars 128-159 + * @todo add support for escaping using CDATA sections? (add cdata start and end tokens, replace only ']]>' with ']]]]>') */ public function encodeEntities($data, $srcEncoding = '', $destEncoding = '') { @@ -146,6 +154,15 @@ class Charset $destEncoding = 'US-ASCII'; } + // in case there is transcoding going on, let's upscale to UTF8 + /// @todo we should do this as well when $srcEncoding == $destEncoding and the encoding is not supported by + /// htmlspecialchars + if (!in_array($srcEncoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII')) && $srcEncoding != $destEncoding && + function_exists('mb_convert_encoding')) { + $data = mb_convert_encoding($data, 'UTF-8', str_replace('US-ASCII', 'ASCII', $srcEncoding)); + $srcEncoding = 'UTF-8'; + } + $conversion = strtoupper($srcEncoding . '_' . $destEncoding); // list ordered with (expected) most common scenarios first @@ -163,7 +180,7 @@ class Charset case 'UTF-8_ISO-8859-1': // NB: this will choke on invalid UTF-8, going most likely beyond EOF $escapedData = ''; - // be kind to users creating string xmlrpc values out of different php types + // be kind to users creating string xml-rpc values out of different php types $data = (string)$data; $ns = strlen($data); for ($nn = 0; $nn < $ns; $nn++) { @@ -235,8 +252,11 @@ class Charset case 'ISO-8859-1_UTF-8': $escapedData = str_replace(array('&', '"', "'", '<', '>'), array('&', '"', ''', '<', '>'), $data); + /// @todo if on php >= 8.2, prefer using mbstring or iconv. Also: suppress the warning! if (function_exists('mb_convert_encoding')) { - $escapedData = mb_convert_encoding($escapedData, 'UTF-8', 'ISO-8859-1'); + $escapedData = mb_convert_encoding($escapedData, 'UTF-8', 'ISO-8859-1'); + } else { + $escapedData = utf8_encode($escapedData); } break; @@ -258,7 +278,7 @@ class Charset $escapedData = str_replace(array('&', '"', "'", '<', '>'), array('&', '"', ''', '<', '>'), $data); /// @todo we could use real UTF8 chars here instead of xml entities... (note that utf_8 encode all alone will NOT convert them) $escapedData = str_replace($this->xml_cp1252_Entities['in'], $this->xml_cp1252_Entities['out'], $escapedData); - $escapedData = mb_convert_encoding($escapedData, 'UTF-8', 'ISO-8859-1'); + $escapedData = utf8_encode($escapedData); break; case 'CP1252_ISO-8859-1': $this->buildConversionTable('xml_cp1252_Entities'); @@ -269,28 +289,76 @@ class Charset */ default: - $escapedData = ''; - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ": Converting from $srcEncoding to $destEncoding: not supported..."); + if (function_exists('mb_convert_encoding')) { + // If reaching where, there are only 2 cases possible: UTF8->XXX or XXX->XXX + // If src is UTF8, we run htmlspecialchars before converting to the target charset, as + // htmlspecialchars has limited charset support, but it groks utf8 + if ($srcEncoding === 'UTF-8') { + $data = htmlspecialchars($data, defined('ENT_XML1') ? ENT_XML1 | ENT_QUOTES : ENT_QUOTES, 'UTF-8'); + } + if ($srcEncoding !== $destEncoding) { + try { + // php 7.4 and lower: a warning is generated. php 8.0 and up: an Error is thrown. So much for BC... + $data = @mb_convert_encoding($data, str_replace('US-ASCII', 'ASCII', $destEncoding), str_replace('US-ASCII', 'ASCII', $srcEncoding)); + } catch (\ValueError $e) { + $data = false; + } + } + if ($data === false) { + $escapedData = ''; + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": Converting from $srcEncoding to $destEncoding via mbstring: failed..."); + } else { + if ($srcEncoding === 'UTF-8') { + $escapedData = $data; + } else { + $escapedData = htmlspecialchars($data, defined('ENT_XML1') ? ENT_XML1 | ENT_QUOTES : ENT_QUOTES, $destEncoding); + } + } + } else { + $escapedData = ''; + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": Converting from $srcEncoding to $destEncoding: not supported..."); + } } return $escapedData; } + /** + * @return string[] + */ + public function knownCharsets() + { + $knownCharsets = array('UTF-8', 'ISO-8859-1', 'US-ASCII'); + // Add all charsets which mbstring can handle, but remove junk not found in IANA registry at + // http://www.iana.org/assignments/character-sets/character-sets.xhtml + if (function_exists('mb_list_encodings')) { + $knownCharsets = array_unique(array_merge($knownCharsets, array_diff(mb_list_encodings(), array( + 'pass', 'auto', 'wchar', 'BASE64', 'UUENCODE', 'ASCII', 'HTML-ENTITIES', 'Quoted-Printable', + '7bit','8bit', 'byte2be', 'byte2le', 'byte4be', 'byte4le' + )))); + } + return $knownCharsets; + } + + // *** BC layer *** + /** * Checks if a given charset encoding is present in a list of encodings or if it is a valid subset of any encoding * in the list. + * @deprecated kept around for BC, as it is not in use by the lib * * @param string $encoding charset to be tested * @param string|array $validList comma separated list of valid charsets (or array of charsets) - * * @return bool */ public function isValidCharset($encoding, $validList) { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + if (is_string($validList)) { $validList = explode(',', $validList); } - if (@in_array(strtoupper($encoding), $validList)) { + if (in_array(strtoupper($encoding), $validList)) { return true; } else { if (array_key_exists($encoding, $this->charset_supersets)) { @@ -306,25 +374,23 @@ class Charset } /** - * Used only for backwards compatibility + * Used only for backwards compatibility (the .inc shims). * @deprecated * * @param string $charset - * * @return array - * - * @throws \Exception for unknown/unsupported charsets + * @throws ValueErrorException for unknown/unsupported charsets */ public function getEntities($charset) { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); switch ($charset) { case 'iso88591': return $this->xml_iso88591_Entities; default: - throw new \Exception('Unsupported charset: ' . $charset); + throw new ValueErrorException('Unsupported charset: ' . $charset); } } } diff --git a/lib/phpxmlrpc/Helper/Date.php b/lib/phpxmlrpc/Helper/Date.php index a9eb3012d3c..ad4c3d51c7c 100644 --- a/lib/phpxmlrpc/Helper/Date.php +++ b/lib/phpxmlrpc/Helper/Date.php @@ -2,6 +2,13 @@ namespace PhpXmlRpc\Helper; +use PhpXmlRpc\PhpXmlRpc; + +/** + * Helps to convert timestamps to the xml-rpc date format. + * + * Feature creep -- add support for custom TZs + */ class Date { /** @@ -12,16 +19,18 @@ class Date * "Don't assume a timezone. It should be specified by the server in its documentation what assumptions it makes * about timezones." * - * These routines always assume localtime unless $utc is set to 1, in which case UTC is assumed and an adjustment - * for locale is made when encoding - * - * @param int $timet (timestamp) - * @param int $utc (0 or 1) + * This routine always encodes to local time unless $utc is set to 1, in which case UTC output is produced and an + * adjustment for the local timezone's offset is made * + * @param int|\DateTimeInterface $timet timestamp or datetime + * @param bool|int $utc (0 or 1) * @return string */ public static function iso8601Encode($timet, $utc = 0) { + if (is_a($timet, 'DateTimeInterface') || is_a($timet, 'DateTime')) { + $timet = $timet->getTimestamp(); + } if (!$utc) { $t = date('Ymd\TH:i:s', $timet); } else { @@ -32,17 +41,17 @@ class Date } /** - * Given an ISO8601 date string, return a timet in the localtime, or UTC. + * Given an ISO8601 date string, return a timestamp in the localtime, or UTC. * * @param string $idate - * @param int $utc either 0 or 1 + * @param bool|int $utc either 0 (assume date is in local time) or 1 (assume date is in UTC) * - * @return int (datetime) + * @return int (timestamp) 0 if the source string does not match the xml-rpc dateTime format */ public static function iso8601Decode($idate, $utc = 0) { $t = 0; - if (preg_match('/([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9]):([0-5][0-9]):([0-5][0-9])/', $idate, $regs)) { + if (preg_match(PhpXmlRpc::$xmlrpc_datetime_format, $idate, $regs)) { if ($utc) { $t = gmmktime($regs[4], $regs[5], $regs[6], $regs[2], $regs[3], $regs[1]); } else { diff --git a/lib/phpxmlrpc/Helper/Http.php b/lib/phpxmlrpc/Helper/Http.php index 7d6cb48e6c2..971ca42ccf0 100644 --- a/lib/phpxmlrpc/Helper/Http.php +++ b/lib/phpxmlrpc/Helper/Http.php @@ -4,17 +4,19 @@ namespace PhpXmlRpc\Helper; use PhpXmlRpc\Exception\HttpException; use PhpXmlRpc\PhpXmlRpc; +use PhpXmlRpc\Traits\LoggerAware; class Http { + use LoggerAware; + /** - * Decode a string that is encoded with "chunked" transfer encoding as defined in rfc2068 par. 19.4.6 + * Decode a string that is encoded with "chunked" transfer encoding as defined in rfc2068 par. 19.4.6. * Code shamelessly stolen from nusoap library by Dietrich Ayala. + * @internal this function will become protected in the future * * @param string $buffer the string to be decoded - * * @return string - * @internal this function will become protected in the future */ public static function decodeChunked($buffer) { @@ -51,7 +53,7 @@ class Http $chunkEnd = strpos($buffer, "\r\n", $chunkStart) + 2; if ($chunkEnd == false) { - break; //just in case we got a broken connection + break; // just in case we got a broken connection } $temp = substr($buffer, $chunkStart, $chunkEnd - $chunkStart); $chunkSize = hexdec(trim($temp)); @@ -62,22 +64,24 @@ class Http } /** - * Parses HTTP an http response headers and separates them from the body. + * Parses HTTP an http response's headers and separates them from the body. * * @param string $data the http response, headers and body. It will be stripped of headers * @param bool $headersProcessed when true, we assume that response inflating and dechunking has been already carried out - * + * @param int $debug when > 0, logs to screen messages detailing info about the parsed data * @return array with keys 'headers', 'cookies', 'raw_data' and 'status_code' * @throws HttpException + * + * @todo if $debug is < 0, we could avoid populating 'raw_data' and 'headers' in the returned value - but that would + * be a weird API... */ - public function parseResponseHeaders(&$data, $headersProcessed = false, $debug=0) + public function parseResponseHeaders(&$data, $headersProcessed = false, $debug = 0) { $httpResponse = array('raw_data' => $data, 'headers'=> array(), 'cookies' => array(), 'status_code' => null); // Support "web-proxy-tunnelling" connections for https through proxies if (preg_match('/^HTTP\/1\.[0-1] 200 Connection established/', $data)) { - // Look for CR/LF or simple LF as line separator, - // (even though it is not valid http) + // Look for CR/LF or simple LF as line separator (even though it is not valid http) $pos = strpos($data, "\r\n\r\n"); if ($pos || is_int($pos)) { $bd = $pos + 4; @@ -91,11 +95,10 @@ class Http } } if ($bd) { - // this filters out all http headers from proxy. - // maybe we could take them into account, too? + // this filters out all http headers from proxy. maybe we could take them into account, too? $data = substr($data, $bd); } else { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': HTTPS via proxy error, tunnel connection possibly failed'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': HTTPS via proxy error, tunnel connection possibly failed'); throw new HttpException(PhpXmlRpc::$xmlrpcstr['http_error'] . ' (HTTPS via proxy error, tunnel connection possibly failed)', PhpXmlRpc::$xmlrpcerr['http_error']); } } @@ -114,10 +117,12 @@ class Http } // When using Curl to query servers using Digest Auth, we get back a double set of http headers. + // Same when following redirects // We strip out the 1st... - if ($headersProcessed && preg_match('/^HTTP\/[0-9](?:\.[0-9])? 401 /', $data)) { + /// @todo we should let the caller know that there was a redirect involved + if ($headersProcessed && preg_match('/^HTTP\/[0-9](?:\.[0-9])? (?:401|30[1278]) /', $data)) { if (preg_match('/(\r?\n){2}HTTP\/[0-9](?:\.[0-9])? 200 /', $data)) { - $data = preg_replace('/^HTTP\/[0-9](?:\.[0-9])? 401 .+?(?:\r?\n){2}(HTTP\/[0-9.]+ 200 )/s', '$1', $data, 1); + $data = preg_replace('/^HTTP\/[0-9](?:\.[0-9])? (?:401|30[1278]) .+?(?:\r?\n){2}(HTTP\/[0-9.]+ 200 )/s', '$1', $data, 1); } } @@ -128,12 +133,11 @@ class Http if ($httpResponse['status_code'] !== '200') { $errstr = substr($data, 0, strpos($data, "\n") - 1); - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': HTTP error, got response: ' . $errstr); - throw new HttpException(PhpXmlRpc::$xmlrpcstr['http_error'] . ' (' . $errstr . ')', PhpXmlRpc::$xmlrpcerr['http_error'], null, $httpResponse['status_code'] ); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': HTTP error, got response: ' . $errstr); + throw new HttpException(PhpXmlRpc::$xmlrpcstr['http_error'] . ' (' . $errstr . ')', PhpXmlRpc::$xmlrpcerr['http_error'], null, $httpResponse['status_code']); } - // be tolerant to usage of \n instead of \r\n to separate headers and data - // (even though it is not valid http) + // be tolerant to usage of \n instead of \r\n to separate headers and data (even though it is not valid http) $pos = strpos($data, "\r\n\r\n"); if ($pos || is_int($pos)) { $bd = $pos + 4; @@ -151,57 +155,48 @@ class Http // be tolerant to line endings, and extra empty lines $ar = preg_split("/\r?\n/", trim(substr($data, 0, $pos))); - foreach($ar as $line) { - // take care of multi-line headers and cookies + foreach ($ar as $line) { + // take care of (multi-line) headers and cookies $arr = explode(':', $line, 2); if (count($arr) > 1) { + /// @todo according to https://www.rfc-editor.org/rfc/rfc7230#section-3.2.4, we should reject with error + /// 400 any messages where a space is present between the header name and colon $headerName = strtolower(trim($arr[0])); - /// @todo some other headers (the ones that allow a CSV list of values) - /// do allow many values to be passed using multiple header lines. - /// We should add content to $xmlrpc->_xh['headers'][$headerName] - /// instead of replacing it for those... - /// @todo should we drop support for rfc2965 (set-cookie2) cookies? It has been obsoleted since 2011 - if ($headerName == 'set-cookie' || $headerName == 'set-cookie2') { - if ($headerName == 'set-cookie2') { - // version 2 cookies: - // there could be many cookies on one line, comma separated - $cookies = explode(',', $arr[1]); + if ($headerName == 'set-cookie') { + $cookie = $arr[1]; + // glue together all received cookies, using a comma to separate them (same as php does with getallheaders()) + if (isset($httpResponse['headers'][$headerName])) { + $httpResponse['headers'][$headerName] .= ', ' . trim($cookie); } else { - $cookies = array($arr[1]); + $httpResponse['headers'][$headerName] = trim($cookie); } - foreach ($cookies as $cookie) { - // glue together all received cookies, using a comma to separate them - // (same as php does with getallheaders()) - if (isset($httpResponse['headers'][$headerName])) { - $httpResponse['headers'][$headerName] .= ', ' . trim($cookie); - } else { - $httpResponse['headers'][$headerName] = trim($cookie); - } - // parse cookie attributes, in case user wants to correctly honour them - // feature creep: only allow rfc-compliant cookie attributes? - // @todo support for server sending multiple time cookie with same name, but using different PATHs - $cookie = explode(';', $cookie); - foreach ($cookie as $pos => $val) { - $val = explode('=', $val, 2); - $tag = trim($val[0]); - $val = isset($val[1]) ? trim($val[1]) : ''; - /// @todo with version 1 cookies, we should strip leading and trailing " chars - if ($pos == 0) { - $cookiename = $tag; - $httpResponse['cookies'][$tag] = array(); - $httpResponse['cookies'][$cookiename]['value'] = urldecode($val); - } else { - if ($tag != 'value') { - $httpResponse['cookies'][$cookiename][$tag] = $val; - } + // parse cookie attributes, in case user wants to correctly honour them + // @todo support for server sending multiple time cookie with same name, but using different PATHs + $cookie = explode(';', $cookie); + foreach ($cookie as $pos => $val) { + $val = explode('=', $val, 2); + $tag = trim($val[0]); + $val = isset($val[1]) ? trim($val[1]) : ''; + if ($pos === 0) { + $cookieName = $tag; + // if present, we have strip leading and trailing " chars from $val + if (preg_match('/^"(.*)"$/', $val, $matches)) { + $val = $matches[1]; } + $httpResponse['cookies'][$cookieName] = array('value' => urldecode($val)); + } else { + $httpResponse['cookies'][$cookieName][$tag] = $val; } } } else { + /// @todo some other headers (the ones that allow a CSV list of values) do allow many values to be + /// passed using multiple header lines. + /// We should add content to $xmlrpc->_xh['headers'][$headerName] instead of replacing it for those... $httpResponse['headers'][$headerName] = trim($arr[1]); } } elseif (isset($headerName)) { - /// @todo version1 cookies might span multiple lines, thus breaking the parsing above + /// @todo improve this: 1. check that the line starts with a space or tab; 2. according to + /// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.4, we should flat out refuse these messages $httpResponse['headers'][$headerName] .= ' ' . trim($line); } } @@ -216,17 +211,16 @@ class Http foreach ($httpResponse['cookies'] as $header => $value) { $msg .= "COOKIE: $header={$value['value']}\n"; } - Logger::instance()->debugMessage($msg); + $this->getLogger()->debug($msg); } - // if CURL was used for the call, http headers have been processed, - // and dechunking + reinflating have been carried out + // if CURL was used for the call, http headers have been processed, and dechunking + reinflating have been carried out if (!$headersProcessed) { // Decode chunked encoding sent by http 1.1 servers if (isset($httpResponse['headers']['transfer-encoding']) && $httpResponse['headers']['transfer-encoding'] == 'chunked') { if (!$data = static::decodeChunked($data)) { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': errors occurred when trying to rebuild the chunked data received from server'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': errors occurred when trying to rebuild the chunked data received from server'); throw new HttpException(PhpXmlRpc::$xmlrpcstr['dechunk_fail'], PhpXmlRpc::$xmlrpcerr['dechunk_fail'], null, $httpResponse['status_code']); } } @@ -241,19 +235,19 @@ class Http if ($httpResponse['headers']['content-encoding'] == 'deflate' && $degzdata = @gzuncompress($data)) { $data = $degzdata; if ($debug) { - Logger::instance()->debugMessage("---INFLATED RESPONSE---[" . strlen($data) . " chars]---\n$data\n---END---"); + $this->getLogger()->debug("---INFLATED RESPONSE---[" . strlen($data) . " chars]---\n$data\n---END---"); } } elseif ($httpResponse['headers']['content-encoding'] == 'gzip' && $degzdata = @gzinflate(substr($data, 10))) { $data = $degzdata; if ($debug) { - Logger::instance()->debugMessage("---INFLATED RESPONSE---[" . strlen($data) . " chars]---\n$data\n---END---"); + $this->getLogger()->debug("---INFLATED RESPONSE---[" . strlen($data) . " chars]---\n$data\n---END---"); } } else { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': errors occurred when trying to decode the deflated data received from server'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': errors occurred when trying to decode the deflated data received from server'); throw new HttpException(PhpXmlRpc::$xmlrpcstr['decompress_fail'], PhpXmlRpc::$xmlrpcerr['decompress_fail'], null, $httpResponse['status_code']); } } else { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': the server sent deflated data. Your php install must have the Zlib extension compiled in to support this.'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': the server sent deflated data. Your php install must have the Zlib extension compiled in to support this.'); throw new HttpException(PhpXmlRpc::$xmlrpcstr['cannot_decompress'], PhpXmlRpc::$xmlrpcerr['cannot_decompress'], null, $httpResponse['status_code']); } } @@ -262,4 +256,28 @@ class Http return $httpResponse; } + + /** + * Parses one of the http headers which can have a list of values with quality param. + * @see https://www.rfc-editor.org/rfc/rfc7231#section-5.3.1 + * + * @param string $header + * @return string[] + */ + public function parseAcceptHeader($header) + { + $accepted = array(); + foreach(explode(',', $header) as $c) { + if (preg_match('/^([^;]+); *q=([0-9.]+)/', $c, $matches)) { + $c = $matches[1]; + $w = $matches[2]; + } else { + $c = preg_replace('/;.*/', '', $c); + $w = 1; + } + $accepted[(trim($c))] = $w; + } + arsort($accepted); + return array_keys($accepted); + } } diff --git a/lib/phpxmlrpc/Helper/Interop.php b/lib/phpxmlrpc/Helper/Interop.php new file mode 100644 index 00000000000..9311576fbf1 --- /dev/null +++ b/lib/phpxmlrpc/Helper/Interop.php @@ -0,0 +1,42 @@ + -32601, + 'invalid_return' => 2, + 'incorrect_params' => -32602, + 'introspect_unknown' => -32601, // this shares the same code but has a separate meaning from 'unknown_method'... + 'http_error' => -32300, + 'no_data' => -32700, + 'no_ssl' => -32400, + 'curl_fail' => -32400, + 'invalid_request' => -32600, + 'no_curl' => -32400, + 'server_error' => -32500, + 'multicall_error' => -32700, + 'multicall_notstruct' => -32600, + 'multicall_nomethod' => -32601, + 'multicall_notstring' => -32600, + 'multicall_recursion' => -32603, + 'multicall_noparams' => -32602, + 'multicall_notarray' => -32600, + 'no_http2' => -32400, + 'invalid_xml' => -32700, + 'xml_not_compliant' => -32700, + 'xml_parsing_error' => -32700, + 'cannot_decompress' => -32400, + 'decompress_fail' => -32300, + 'dechunk_fail' => -32300, + 'server_cannot_decompress' => -32300, + 'server_decompress_fail' => -32300, + ); + + public static $xmlrpcerruser = -32000; +} diff --git a/lib/phpxmlrpc/Helper/Logger.php b/lib/phpxmlrpc/Helper/Logger.php index bee7b08f00b..a934611f57b 100644 --- a/lib/phpxmlrpc/Helper/Logger.php +++ b/lib/phpxmlrpc/Helper/Logger.php @@ -11,7 +11,7 @@ class Logger protected static $instance = null; /** - * This class can be used as singleton, so that later we can move to DI patterns. + * This class can be used as singleton, so that later we can move to DI patterns (ish...) * * @return Logger */ @@ -24,13 +24,60 @@ class Logger return self::$instance; } + // *** Implement the same interface as PSR/LOG, for the sake of interoperability *** + + /** + * NB: unlike other "traditional" loggers, this one echoes to screen the debug messages instead of logging them. + * + * @param string $message + * @param array $context known key: 'encoding' + * @return void + */ + public function debug($message, $context = array()) + { + if (isset($context['encoding'])) { + $this->debugMessage($message, $context['encoding']); + } else { + $this->debugMessage($message); + } + } + + /** + * Following the general principle of 'never break stdout', the default behaviour + * + * @param string $message + * @param $context + * @return void + */ + public function warning($message, $context = array()) + { + $this->errorLog(preg_replace('/^XML-RPC :/', 'XML-RPC Warning: ', $message)); + } + + /** + * Triggers the writing of a message to php's error log + * + * @param string $message + * @param array $context + * @return void + */ + public function error($message, $context = array()) + { + $this->errorLog(preg_replace('/^XML-RPC :/', 'XML-RPC Error: ', $message)); + } + + // BC interface + /** * Echoes a debug message, taking care of escaping it when not in console mode. * NB: if the encoding of the message is not known or wrong, and we are working in web mode, there is no guarantee * of 100% accuracy, which kind of defeats the purpose of debugging * * @param string $message - * @param string $encoding + * @param string $encoding deprecated + * @return void + * + * @internal left in purely for BC */ public function debugMessage($message, $encoding = null) { @@ -57,13 +104,17 @@ class Logger print "\n$message\n"; } - // let the user see this now in case there's a time out later... + // let the user see this now in case there's a time-out later... flush(); } /** - * Writes a message to the error log + * Writes a message to the error log. + * * @param string $message + * @return void + * + * @internal left in purely for BC */ public function errorLog($message) { diff --git a/lib/phpxmlrpc/Helper/XMLParser.php b/lib/phpxmlrpc/Helper/XMLParser.php index d3595c3a981..c23b6fc52d0 100644 --- a/lib/phpxmlrpc/Helper/XMLParser.php +++ b/lib/phpxmlrpc/Helper/XMLParser.php @@ -3,6 +3,7 @@ namespace PhpXmlRpc\Helper; use PhpXmlRpc\PhpXmlRpc; +use PhpXmlRpc\Traits\DeprecationLogger; use PhpXmlRpc\Value; /** @@ -11,12 +12,19 @@ use PhpXmlRpc\Value; * * @todo implement an interface to allow for alternative implementations * - make access to $_xh protected, return more high-level data structures + * - move the private parts of $_xh to the internal-use parsing-options config * - add parseRequest, parseResponse, parseValue methods * @todo if iconv() or mb_string() are available, we could allow to convert the received xml to a custom charset encoding * while parsing, which is faster than doing it later by going over the rebuilt data structure + * @todo rename? This is an xml-rpc parser, not a generic xml parser... + * + * @property array $xmlrpc_valid_parents deprecated - public access left in purely for BC + * @property int $accept deprecated - (protected) access left in purely for BC */ class XMLParser { + use DeprecationLogger; + const RETURN_XMLRPCVALS = 'xmlrpcvals'; const RETURN_EPIVALS = 'epivals'; const RETURN_PHP = 'phpvals'; @@ -26,25 +34,36 @@ class XMLParser const ACCEPT_VALUE = 4; const ACCEPT_FAULT = 8; - // Used to store state during parsing and to pass parsing results to callers. - // Quick explanation of components: - // private: - // ac - used to accumulate values - // stack - array with genealogy of xml elements names used to validate nesting of xmlrpc elements - // valuestack - array used for parsing arrays and structs - // lv - used to indicate "looking for a value": implements the logic to allow values with no types to be strings - // public: - // isf - used to indicate an xml parsing fault (3), invalid xmlrpc fault (2) or xmlrpc response fault (1) - // isf_reason - used for storing xmlrpc response fault string - // value - used to store the value in responses - // method - used to store method name in requests - // params - used to store parameters in requests - // pt - used to store the type of each received parameter. Useful if parameters are automatically decoded to php values - // rt - 'methodcall', 'methodresponse', 'value' or 'fault' (the last one used only in EPI emulation mode) - public $_xh = array( + /** + * @var int + * The max length beyond which data will get truncated in error messages + */ + protected $maxLogValueLength = 100; + + /** + * @var array + * Used to store state during parsing and to pass parsing results to callers. + * Quick explanation of components: + * private: + * ac - used to accumulate values + * stack - array with genealogy of xml elements names, used to validate nesting of xml-rpc elements + * valuestack - array used for parsing arrays and structs + * lv - used to indicate "looking for a value": implements the logic to allow values with no types to be strings + * (values: 0=not looking, 1=looking, 3=found) + * public: + * isf - used to indicate an xml-rpc response fault (1), invalid xml-rpc fault (2), xml parsing fault (3) + * isf_reason - used for storing xml-rpc response fault string + * value - used to store the value in responses + * method - used to store method name in requests + * params - used to store parameters in requests + * pt - used to store the type of each received parameter. Useful if parameters are automatically decoded to php values + * rt - 'methodcall', 'methodresponse', 'value' or 'fault' (the last one used only in EPI emulation mode) + */ + protected $_xh = array( 'ac' => '', 'stack' => array(), 'valuestack' => array(), + 'lv' => 0, 'isf' => 0, 'isf_reason' => '', 'value' => null, @@ -54,7 +73,10 @@ class XMLParser 'rt' => '', ); - public $xmlrpc_valid_parents = array( + /** + * @var array[] + */ + protected $xmlrpc_valid_parents = array( 'VALUE' => array('MEMBER', 'DATA', 'PARAM', 'FAULT'), 'BOOLEAN' => array('VALUE'), 'I4' => array('VALUE'), @@ -80,13 +102,31 @@ class XMLParser /** @var array $parsing_options */ protected $parsing_options = array(); + /** @var int $accept self::ACCEPT_REQUEST | self::ACCEPT_RESPONSE by default */ - protected $accept = 3; + //protected $accept = 3; + /** @var int $maxChunkLength 4 MB by default. Any value below 10MB should be good */ protected $maxChunkLength = 4194304; + /** @var array + * Used keys: accept, target_charset, methodname_callback, plus the ones set here. + * We initialize it partially to help keep BC with subclasses which might have reimplemented `parse()` but not + * the element handler methods + */ + protected $current_parsing_options = array( + 'xmlrpc_null_extension' => false, + 'xmlrpc_return_datetimes' => false, + 'xmlrpc_reject_invalid_values' => false + ); /** - * @param array $options passed to the xml parser + * @param array $options integer keys: options passed to the inner xml parser + * string keys: + * - target_charset (string) + * - methodname_callback (callable) + * - xmlrpc_null_extension (bool) + * - xmlrpc_return_datetimes (bool) + * - xmlrpc_reject_invalid_values (bool) */ public function __construct(array $options = array()) { @@ -94,10 +134,22 @@ class XMLParser } /** + * Parses an xml-rpc xml string. Results of the parsing are found in $this->['_xh']. + * Logs to the error log any issues which do not cause the parsing to fail. + * * @param string $data - * @param string $returnType + * @param string $returnType self::RETURN_XMLRPCVALS, self::RETURN_PHP, self::RETURN_EPIVALS * @param int $accept a bit-combination of self::ACCEPT_REQUEST, self::ACCEPT_RESPONSE, self::ACCEPT_VALUE - * @param array $options + * @param array $options integer-key options are passed to the xml parser, string-key options are used independently. + * These options are added to options received in the constructor. + * Note that if options xmlrpc_null_extension, xmlrpc_return_datetimes and xmlrpc_reject_invalid_values + * are not set, the default settings from PhpXmlRpc\PhpXmlRpc are used + * @return array see the definition of $this->_xh for the meaning of the results + * @throws \Exception this can happen if a callback function is set and it does throw (i.e. we do not catch exceptions) + * + * @todo refactor? we could 1. return the parsed data structure, and 2. move $returnType and $accept into options + * @todo feature-creep make it possible to pass in options overriding usage of PhpXmlRpc::$xmlrpc_XXX_format, so + * that parsing will be completely independent of global state. Note that it might incur a small perf hit... */ public function parse($data, $returnType = self::RETURN_XMLRPCVALS, $accept = 3, $options = array()) { @@ -105,6 +157,7 @@ class XMLParser 'ac' => '', 'stack' => array(), 'valuestack' => array(), + 'lv' => 0, 'isf' => 0, 'isf_reason' => '', 'value' => null, @@ -116,33 +169,85 @@ class XMLParser $len = strlen($data); - // we test for empty documents here to save on resource allocation and simply the chunked-parsing loop below + // we test for empty documents here to save on resource allocation and simplify the chunked-parsing loop below if ($len == 0) { $this->_xh['isf'] = 3; $this->_xh['isf_reason'] = 'XML error 5: empty document'; - return; + return $this->_xh; } - $parser = xml_parser_create(); + $this->current_parsing_options = array('accept' => $accept); - foreach ($this->parsing_options as $key => $val) { - xml_parser_set_option($parser, $key, $val); - } + $mergedOptions = $this->parsing_options; foreach ($options as $key => $val) { + $mergedOptions[$key] = $val; + } + + foreach ($mergedOptions as $key => $val) { + // q: can php be built without ctype? should we use a regexp? + if (is_string($key) && !ctype_digit($key)) { + /// @todo on invalid options, throw/error-out instead of logging an error message? + switch($key) { + case 'target_charset': + if (function_exists('mb_convert_encoding')) { + $this->current_parsing_options['target_charset'] = $val; + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": 'target_charset' option is unsupported without mbstring"); + } + break; + + case 'methodname_callback': + if (is_callable($val)) { + $this->current_parsing_options['methodname_callback'] = $val; + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": Callback passed as 'methodname_callback' is not callable"); + } + break; + + case 'xmlrpc_null_extension': + case 'xmlrpc_return_datetimes': + case 'xmlrpc_reject_invalid_values': + $this->current_parsing_options[$key] = $val; + break; + + default: + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ": unsupported option: $key"); + } + unset($mergedOptions[$key]); + } + } + + if (!isset($this->current_parsing_options['xmlrpc_null_extension'])) { + $this->current_parsing_options['xmlrpc_null_extension'] = PhpXmlRpc::$xmlrpc_null_extension; + } + if (!isset($this->current_parsing_options['xmlrpc_return_datetimes'])) { + $this->current_parsing_options['xmlrpc_return_datetimes'] = PhpXmlRpc::$xmlrpc_return_datetimes; + } + if (!isset($this->current_parsing_options['xmlrpc_reject_invalid_values'])) { + $this->current_parsing_options['xmlrpc_reject_invalid_values'] = PhpXmlRpc::$xmlrpc_reject_invalid_values; + } + + // NB: we use '' instead of null to force charset detection from the xml declaration + $parser = xml_parser_create(''); + + foreach ($mergedOptions as $key => $val) { xml_parser_set_option($parser, $key, $val); } + // always set this, in case someone tries to disable it via options... xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 1); xml_set_object($parser, $this); - switch($returnType) { + switch ($returnType) { case self::RETURN_PHP: xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee_fast'); break; case self::RETURN_EPIVALS: xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee_epi'); break; + /// @todo log an error / throw / error-out on unsupported return type + case XMLParser::RETURN_XMLRPCVALS: default: xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee'); } @@ -150,395 +255,526 @@ class XMLParser xml_set_character_data_handler($parser, 'xmlrpc_cd'); xml_set_default_handler($parser, 'xmlrpc_dh'); - $this->accept = $accept; + try { + // @see ticket #70 - we have to parse big xml docs in chunks to avoid errors + for ($offset = 0; $offset < $len; $offset += $this->maxChunkLength) { + $chunk = substr($data, $offset, $this->maxChunkLength); + // error handling: xml not well formed + if (!xml_parse($parser, $chunk, $offset + $this->maxChunkLength >= $len)) { + $errCode = xml_get_error_code($parser); + $errStr = sprintf('XML error %s: %s at line %d, column %d', $errCode, xml_error_string($errCode), + xml_get_current_line_number($parser), xml_get_current_column_number($parser)); - // @see ticket #70 - we have to parse big xml docks in chunks to avoid errors - for ($offset = 0; $offset < $len; $offset += $this->maxChunkLength) { - $chunk = substr($data, $offset, $this->maxChunkLength); - // error handling: xml not well formed - if (!xml_parse($parser, $chunk, $offset + $this->maxChunkLength >= $len)) { - $errCode = xml_get_error_code($parser); - $errStr = sprintf('XML error %s: %s at line %d, column %d', $errCode, xml_error_string($errCode), - xml_get_current_line_number($parser), xml_get_current_column_number($parser)); - - $this->_xh['isf'] = 3; - $this->_xh['isf_reason'] = $errStr; - break; + $this->_xh['isf'] = 3; + $this->_xh['isf_reason'] = $errStr; + } + // no need to parse further if we already have a fatal error + if ($this->_xh['isf'] >= 2) { + break; + } } + /// @todo bump minimum php version to 5.5 and use a finally clause instead of doing cleanup 3 times + } catch (\Exception $e) { + xml_parser_free($parser); + $this->current_parsing_options = array(); + /// @todo should we set $this->_xh['isf'] and $this->_xh['isf_reason'] ? + throw $e; + } catch (\Error $e) { + xml_parser_free($parser); + $this->current_parsing_options = array(); + //$this->accept = $prevAccept; + /// @todo should we set $this->_xh['isf'] and $this->_xh['isf_reason'] ? + throw $e; } xml_parser_free($parser); + $this->current_parsing_options = array(); + + return $this->_xh; } /** * xml parser handler function for opening element tags. * @internal + * * @param resource $parser * @param string $name * @param $attrs * @param bool $acceptSingleVals DEPRECATED use the $accept parameter instead + * @return void + * + * @todo optimization creep: throw when setting $this->_xh['isf'] > 1, to completely avoid further xml parsing + * and remove the checking for $this->_xh['isf'] >= 2 everywhere */ public function xmlrpc_se($parser, $name, $attrs, $acceptSingleVals = false) { - // if invalid xmlrpc already detected, skip all processing - if ($this->_xh['isf'] < 2) { + // if invalid xml-rpc already detected, skip all processing + if ($this->_xh['isf'] >= 2) { + return; + } - // check for correct element nesting - if (count($this->_xh['stack']) == 0) { - // top level element can only be of 2 types - /// @todo optimization creep: save this check into a bool variable, instead of using count() every time: - /// there is only a single top level element in xml anyway - // BC - if ($acceptSingleVals === false) { - $accept = $this->accept; - } else { - $accept = self::ACCEPT_REQUEST | self::ACCEPT_RESPONSE | self::ACCEPT_VALUE; - } - if (($name == 'METHODCALL' && ($accept & self::ACCEPT_REQUEST)) || - ($name == 'METHODRESPONSE' && ($accept & self::ACCEPT_RESPONSE)) || - ($name == 'VALUE' && ($accept & self::ACCEPT_VALUE)) || - ($name == 'FAULT' && ($accept & self::ACCEPT_FAULT))) { - $this->_xh['rt'] = strtolower($name); - } else { - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = 'missing top level xmlrpc element. Found: ' . $name; + // check for correct element nesting + if (count($this->_xh['stack']) == 0) { + // top level element can only be of 2 types + /// @todo optimization creep: save this check into a bool variable, instead of using count() every time: + /// there is only a single top level element in xml anyway - return; - } + // BC + if ($acceptSingleVals === false) { + $accept = $this->current_parsing_options['accept']; } else { - // not top level element: see if parent is OK - $parent = end($this->_xh['stack']); - if (!array_key_exists($name, $this->xmlrpc_valid_parents) || !in_array($parent, $this->xmlrpc_valid_parents[$name])) { - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "xmlrpc element $name cannot be child of $parent"; - - return; - } + $this->logDeprecation('Using argument $acceptSingleVals for method ' . __METHOD__ . ' is deprecated'); + $accept = self::ACCEPT_REQUEST | self::ACCEPT_RESPONSE | self::ACCEPT_VALUE; } + if (($name == 'METHODCALL' && ($accept & self::ACCEPT_REQUEST)) || + ($name == 'METHODRESPONSE' && ($accept & self::ACCEPT_RESPONSE)) || + ($name == 'VALUE' && ($accept & self::ACCEPT_VALUE)) || + ($name == 'FAULT' && ($accept & self::ACCEPT_FAULT))) { + $this->_xh['rt'] = strtolower($name); + } else { + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = 'missing top level xmlrpc element. Found: ' . $name; - switch ($name) { - // optimize for speed switch cases: most common cases first - case 'VALUE': - /// @todo we could check for 2 VALUE elements inside a MEMBER or PARAM element - $this->_xh['vt'] = 'value'; // indicator: no value found yet - $this->_xh['ac'] = ''; - $this->_xh['lv'] = 1; - $this->_xh['php_class'] = null; - break; - case 'I8': - case 'EX:I8': - if (PHP_INT_SIZE === 4) { - // INVALID ELEMENT: RAISE ISF so that it is later recognized!!! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "Received i8 element but php is compiled in 32 bit mode"; - - return; - } - // fall through voluntarily - case 'I4': - case 'INT': - case 'STRING': - case 'BOOLEAN': - case 'DOUBLE': - case 'DATETIME.ISO8601': - case 'BASE64': - if ($this->_xh['vt'] != 'value') { - // two data elements inside a value: an error occurred! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; - - return; - } - $this->_xh['ac'] = ''; // reset the accumulator - break; - case 'STRUCT': - case 'ARRAY': - if ($this->_xh['vt'] != 'value') { - // two data elements inside a value: an error occurred! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; - - return; - } - // create an empty array to hold child values, and push it onto appropriate stack - $curVal = array(); - $curVal['values'] = array(); - $curVal['type'] = $name; - // check for out-of-band information to rebuild php objs - // and in case it is found, save it - if (@isset($attrs['PHP_CLASS'])) { - $curVal['php_class'] = $attrs['PHP_CLASS']; - } - $this->_xh['valuestack'][] = $curVal; - $this->_xh['vt'] = 'data'; // be prepared for a data element next - break; - case 'DATA': - if ($this->_xh['vt'] != 'data') { - // two data elements inside a value: an error occurred! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "found two data elements inside an array element"; - - return; - } - case 'METHODCALL': - case 'METHODRESPONSE': - case 'PARAMS': - // valid elements that add little to processing - break; - case 'METHODNAME': - case 'NAME': - /// @todo we could check for 2 NAME elements inside a MEMBER element - $this->_xh['ac'] = ''; - break; - case 'FAULT': - $this->_xh['isf'] = 1; - break; - case 'MEMBER': - // set member name to null, in case we do not find in the xml later on - $this->_xh['valuestack'][count($this->_xh['valuestack']) - 1]['name'] = ''; - //$this->_xh['ac']=''; - // Drop trough intentionally - case 'PARAM': - // clear value type, so we can check later if no value has been passed for this param/member - $this->_xh['vt'] = null; - break; - case 'NIL': - case 'EX:NIL': - if (PhpXmlRpc::$xmlrpc_null_extension) { - if ($this->_xh['vt'] != 'value') { - // two data elements inside a value: an error occurred! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; - - return; - } - $this->_xh['ac'] = ''; // reset the accumulator - break; - } - // if here, we do not support the extension, so - // drop through intentionally - default: - // INVALID ELEMENT: RAISE ISF so that it is later recognized!!! - $this->_xh['isf'] = 2; - $this->_xh['isf_reason'] = "found not-xmlrpc xml element $name"; - break; + return; } + } else { + // not top level element: see if parent is OK + $parent = end($this->_xh['stack']); + if (!array_key_exists($name, $this->xmlrpc_valid_parents) || !in_array($parent, $this->xmlrpc_valid_parents[$name])) { + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "xmlrpc element $name cannot be child of $parent"; - // Save current element name to stack, to validate nesting - $this->_xh['stack'][] = $name; - - /// @todo optimization creep: move this inside the big switch() above - if ($name != 'VALUE') { - $this->_xh['lv'] = 0; + return; } } - } - /** - * xml parser handler function for opening element tags. - * Used in decoding xml chunks that might represent single xmlrpc values as well as requests, responses. - * @deprecated - * @param resource $parser - * @param $name - * @param $attrs - */ - public function xmlrpc_se_any($parser, $name, $attrs) - { - $this->xmlrpc_se($parser, $name, $attrs, true); + switch ($name) { + // optimize for speed switch cases: most common cases first + case 'VALUE': + /// @todo we could check for 2 VALUE elements inside a MEMBER or PARAM element + $this->_xh['vt'] = 'value'; // indicator: no value found yet + $this->_xh['ac'] = ''; + $this->_xh['lv'] = 1; + $this->_xh['php_class'] = null; + break; + + case 'I8': + case 'EX:I8': + if (PHP_INT_SIZE === 4) { + // INVALID ELEMENT: RAISE ISF so that it is later recognized!!! + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "Received i8 element but php is compiled in 32 bit mode"; + + return; + } + // fall through voluntarily + + case 'I4': + case 'INT': + case 'STRING': + case 'BOOLEAN': + case 'DOUBLE': + case 'DATETIME.ISO8601': + case 'BASE64': + if ($this->_xh['vt'] != 'value') { + // two data elements inside a value: an error occurred! + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; + + return; + } + $this->_xh['ac'] = ''; // reset the accumulator + break; + + case 'STRUCT': + case 'ARRAY': + if ($this->_xh['vt'] != 'value') { + // two data elements inside a value: an error occurred! + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; + + return; + } + // create an empty array to hold child values, and push it onto appropriate stack + $curVal = array( + 'values' => array(), + 'type' => $name, + ); + // check for out-of-band information to rebuild php objs and, in case it is found, save it + if (@isset($attrs['PHP_CLASS'])) { + $curVal['php_class'] = $attrs['PHP_CLASS']; + } + $this->_xh['valuestack'][] = $curVal; + $this->_xh['vt'] = 'data'; // be prepared for a data element next + break; + + case 'DATA': + if ($this->_xh['vt'] != 'data') { + // two data elements inside a value: an error occurred! + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "found two data elements inside an array element"; + + return; + } + + case 'METHODCALL': + case 'METHODRESPONSE': + case 'PARAMS': + // valid elements that add little to processing + break; + + case 'METHODNAME': + case 'NAME': + /// @todo we could check for 2 NAME elements inside a MEMBER element + $this->_xh['ac'] = ''; + break; + + case 'FAULT': + $this->_xh['isf'] = 1; + break; + + case 'MEMBER': + // set member name to null, in case we do not find in the xml later on + $this->_xh['valuestack'][count($this->_xh['valuestack']) - 1]['name'] = null; + //$this->_xh['ac']=''; + // Drop trough intentionally + + case 'PARAM': + // clear value type, so we can check later if no value has been passed for this param/member + $this->_xh['vt'] = null; + break; + + case 'NIL': + case 'EX:NIL': + if ($this->current_parsing_options['xmlrpc_null_extension']) { + if ($this->_xh['vt'] != 'value') { + // two data elements inside a value: an error occurred! + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "$name element following a {$this->_xh['vt']} element inside a single value"; + + return; + } + // reset the accumulator - q: is this necessary at all here? we don't use it on _ee anyway for NILs + $this->_xh['ac'] = ''; + + } else { + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = 'Invalid NIL value received. Support for NIL can be enabled via \\PhpXmlRpc\\PhpXmlRpc::$xmlrpc_null_extension'; + + return; + } + break; + + default: + // INVALID ELEMENT: RAISE ISF so that it is later recognized + /// @todo feature creep = allow a callback instead + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = "found not-xmlrpc xml element $name"; + + return; + } + + // Save current element name to stack, to validate nesting + $this->_xh['stack'][] = $name; + + /// @todo optimization creep: move this inside the big switch() above + if ($name != 'VALUE') { + $this->_xh['lv'] = 0; + } } /** * xml parser handler function for close element tags. * @internal + * * @param resource $parser * @param string $name * @param int $rebuildXmlrpcvals >1 for rebuilding xmlrpcvals, 0 for rebuilding php values, -1 for xmlrpc-extension compatibility + * @return void + * @throws \Exception this can happen if a callback function is set and it does throw (i.e. we do not catch exceptions) + * + * @todo optimization creep: throw when setting $this->_xh['isf'] > 1, to completely avoid further xml parsing + * and remove the checking for $this->_xh['isf'] >= 2 everywhere */ public function xmlrpc_ee($parser, $name, $rebuildXmlrpcvals = 1) { - if ($this->_xh['isf'] < 2) { - // push this element name from stack - // NB: if XML validates, correct opening/closing is guaranteed and - // we do not have to check for $name == $currElem. - // we also checked for proper nesting at start of elements... - $currElem = array_pop($this->_xh['stack']); + if ($this->_xh['isf'] >= 2) { + return; + } - switch ($name) { - case 'VALUE': - // This if() detects if no scalar was inside - if ($this->_xh['vt'] == 'value') { - $this->_xh['value'] = $this->_xh['ac']; - $this->_xh['vt'] = Value::$xmlrpcString; + // push this element name from stack + // NB: if XML validates, correct opening/closing is guaranteed and we do not have to check for $name == $currElem. + // we also checked for proper nesting at start of elements... + $currElem = array_pop($this->_xh['stack']); + + switch ($name) { + case 'VALUE': + // If no scalar was inside , it was a string value + if ($this->_xh['vt'] == 'value') { + $this->_xh['value'] = $this->_xh['ac']; + $this->_xh['vt'] = Value::$xmlrpcString; + } + + // in case there is charset conversion required, do it here, to catch both cases of string values + if (isset($this->current_parsing_options['target_charset']) && $this->_xh['vt'] === Value::$xmlrpcString) { + $this->_xh['value'] = mb_convert_encoding($this->_xh['value'], $this->current_parsing_options['target_charset'], 'UTF-8'); + } + + if ($rebuildXmlrpcvals > 0) { + // build the xml-rpc val out of the data received, and substitute it + $temp = new Value($this->_xh['value'], $this->_xh['vt']); + // in case we got info about underlying php class, save it in the object we're rebuilding + if (isset($this->_xh['php_class'])) { + $temp->_php_class = $this->_xh['php_class']; + } + $this->_xh['value'] = $temp; + } elseif ($rebuildXmlrpcvals < 0) { + if ($this->_xh['vt'] == Value::$xmlrpcDateTime) { + $this->_xh['value'] = (object)array( + 'xmlrpc_type' => 'datetime', + 'scalar' => $this->_xh['value'], + 'timestamp' => \PhpXmlRpc\Helper\Date::iso8601Decode($this->_xh['value']) + ); + } elseif ($this->_xh['vt'] == Value::$xmlrpcBase64) { + $this->_xh['value'] = (object)array( + 'xmlrpc_type' => 'base64', + 'scalar' => $this->_xh['value'] + ); + } + } else { + /// @todo this should handle php-serialized objects, since std deserializing is done + /// by php_xmlrpc_decode, which we will not be calling... + //if (isset($this->_xh['php_class'])) { + //} + } + + // check if we are inside an array or struct: + // if value just built is inside an array, let's move it into array on the stack + $vscount = count($this->_xh['valuestack']); + if ($vscount && $this->_xh['valuestack'][$vscount - 1]['type'] == 'ARRAY') { + $this->_xh['valuestack'][$vscount - 1]['values'][] = $this->_xh['value']; + } + break; + + case 'STRING': + $this->_xh['vt'] = Value::$xmlrpcString; + $this->_xh['lv'] = 3; // indicate we've found a value + $this->_xh['value'] = $this->_xh['ac']; + break; + + case 'BOOLEAN': + $this->_xh['vt'] = Value::$xmlrpcBoolean; + $this->_xh['lv'] = 3; // indicate we've found a value + // We translate boolean 1 or 0 into PHP constants true or false. Strings 'true' and 'false' are accepted, + // even though the spec never mentions them (see e.g. Blogger api docs) + // NB: this simple checks helps a lot sanitizing input, i.e. no security problems around here + // Note the non-strict type check: it will allow ' 1 ' + /// @todo feature-creep: use a flexible regexp, the same as we do with int, double and datetime. + /// Note that using a regexp would also make this test less sensitive to phpunit shenanigans, and + /// to changes in the way php compares strings (since 8.0, leading and trailing newlines are + /// accepted when deciding if a string numeric...) + if ($this->_xh['ac'] == '1' || strcasecmp($this->_xh['ac'], 'true') === 0) { + $this->_xh['value'] = true; + } else { + // log if receiving something strange, even though we set the value to false anyway + /// @todo to be consistent with the other types, we should return a value outside the good-value domain, e.g. NULL + if ($this->_xh['ac'] != '0' && strcasecmp($this->_xh['ac'], 'false') !== 0) { + if (!$this->handleParsingError('invalid data received in BOOLEAN value: ' . + $this->truncateValueForLog($this->_xh['ac']), __METHOD__)) { + return; + } + } + $this->_xh['value'] = false; + } + break; + + case 'EX:I8': + $name = 'i8'; + // fall through voluntarily + case 'I4': + case 'I8': + case 'INT': + // NB: we build the Value object with the original xml element name found, except for ex:i8. The + // `Value::scalarTyp()` function will do some normalization of the data + $this->_xh['vt'] = strtolower($name); + $this->_xh['lv'] = 3; // indicate we've found a value + if (!preg_match(PhpXmlRpc::$xmlrpc_int_format, $this->_xh['ac'])) { + if (!$this->handleParsingError('non numeric data received in INT value: ' . + $this->truncateValueForLog($this->_xh['ac']), __METHOD__)) { + return; + } + /// @todo: find a better way of reporting an error value than this! Use NaN? + $this->_xh['value'] = 'ERROR_NON_NUMERIC_FOUND'; + } else { + // it's ok, add it on + $this->_xh['value'] = (int)$this->_xh['ac']; + } + break; + + case 'DOUBLE': + $this->_xh['vt'] = Value::$xmlrpcDouble; + $this->_xh['lv'] = 3; // indicate we've found a value + if (!preg_match(PhpXmlRpc::$xmlrpc_double_format, $this->_xh['ac'])) { + if (!$this->handleParsingError('non numeric data received in DOUBLE value: ' . + $this->truncateValueForLog($this->_xh['ac']), __METHOD__)) { + return; } - if ($rebuildXmlrpcvals > 0) { - // build the xmlrpc val out of the data received, and substitute it - $temp = new Value($this->_xh['value'], $this->_xh['vt']); - // in case we got info about underlying php class, save it - // in the object we're rebuilding - if (isset($this->_xh['php_class'])) { - $temp->_php_class = $this->_xh['php_class']; - } - $this->_xh['value'] = $temp; - } elseif ($rebuildXmlrpcvals < 0) { - if ($this->_xh['vt'] == Value::$xmlrpcDateTime) { - $this->_xh['value'] = (object)array( - 'xmlrpc_type' => 'datetime', - 'scalar' => $this->_xh['value'], - 'timestamp' => \PhpXmlRpc\Helper\Date::iso8601Decode($this->_xh['value']) - ); - } elseif ($this->_xh['vt'] == Value::$xmlrpcBase64) { - $this->_xh['value'] = (object)array( - 'xmlrpc_type' => 'base64', - 'scalar' => $this->_xh['value'] - ); - } - } else { - /// @todo this should handle php-serialized objects, - /// since std deserializing is done by php_xmlrpc_decode, - /// which we will not be calling... - //if (isset($this->_xh['php_class'])) { - //} - } + $this->_xh['value'] = 'ERROR_NON_NUMERIC_FOUND'; + } else { + // it's ok, add it on + $this->_xh['value'] = (double)$this->_xh['ac']; + } + break; - // check if we are inside an array or struct: - // if value just built is inside an array, let's move it into array on the stack + case 'DATETIME.ISO8601': + $this->_xh['vt'] = Value::$xmlrpcDateTime; + $this->_xh['lv'] = 3; // indicate we've found a value + if (!preg_match(PhpXmlRpc::$xmlrpc_datetime_format, $this->_xh['ac'])) { + if (!$this->handleParsingError('invalid data received in DATETIME value: ' . + $this->truncateValueForLog($this->_xh['ac']), __METHOD__)) { + return; + } + } + if ($this->current_parsing_options['xmlrpc_return_datetimes']) { + try { + $this->_xh['value'] = new \DateTime($this->_xh['ac']); + + // the default regex used to validate the date string a few lines above should make this case impossible, + // but one never knows... + } catch(\Exception $e) { + // what to do? We can not guarantee that a valid date can be created. We return null... + if (!$this->handleParsingError('invalid data received in DATETIME value. Error ' . + $e->getMessage(), __METHOD__)) { + return; + } + } + } else { + $this->_xh['value'] = $this->_xh['ac']; + } + break; + + case 'BASE64': + $this->_xh['vt'] = Value::$xmlrpcBase64; + $this->_xh['lv'] = 3; // indicate we've found a value + if ($this->current_parsing_options['xmlrpc_reject_invalid_values']) { + $v = base64_decode($this->_xh['ac'], true); + if ($v === false) { + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = 'Invalid data received in BASE64 value: '. $this->truncateValueForLog($this->_xh['ac']); + return; + } + } else { + $v = base64_decode($this->_xh['ac']); + if ($v === '' && $this->_xh['ac'] !== '') { + // only the empty string should decode to the empty string + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': invalid data received in BASE64 value: ' . + $this->truncateValueForLog($this->_xh['ac'])); + } + } + $this->_xh['value'] = $v; + break; + + case 'NAME': + $this->_xh['valuestack'][count($this->_xh['valuestack']) - 1]['name'] = $this->_xh['ac']; + break; + + case 'MEMBER': + // add to array in the stack the last element built, unless no VALUE or no NAME were found + if ($this->_xh['vt']) { $vscount = count($this->_xh['valuestack']); - if ($vscount && $this->_xh['valuestack'][$vscount - 1]['type'] == 'ARRAY') { - $this->_xh['valuestack'][$vscount - 1]['values'][] = $this->_xh['value']; - } - break; - case 'BOOLEAN': - case 'I4': - case 'I8': - case 'EX:I8': - case 'INT': - case 'STRING': - case 'DOUBLE': - case 'DATETIME.ISO8601': - case 'BASE64': - $this->_xh['vt'] = strtolower($name); - /// @todo: optimization creep - remove the if/elseif cycle below - /// since the case() in which we are already did that - if ($name == 'STRING') { - $this->_xh['value'] = $this->_xh['ac']; - } elseif ($name == 'DATETIME.ISO8601') { - if (!preg_match('/^[0-9]{8}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/', $this->_xh['ac'])) { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': invalid value received in DATETIME: ' . $this->_xh['ac']); - } - $this->_xh['vt'] = Value::$xmlrpcDateTime; - $this->_xh['value'] = $this->_xh['ac']; - } elseif ($name == 'BASE64') { - /// @todo check for failure of base64 decoding / catch warnings - $this->_xh['value'] = base64_decode($this->_xh['ac']); - } elseif ($name == 'BOOLEAN') { - // special case here: we translate boolean 1 or 0 into PHP - // constants true or false. - // Strings 'true' and 'false' are accepted, even though the - // spec never mentions them (see eg. Blogger api docs) - // NB: this simple checks helps a lot sanitizing input, ie no - // security problems around here - if ($this->_xh['ac'] == '1' || strcasecmp($this->_xh['ac'], 'true') == 0) { - $this->_xh['value'] = true; - } else { - // log if receiving something strange, even though we set the value to false anyway - if ($this->_xh['ac'] != '0' && strcasecmp($this->_xh['ac'], 'false') != 0) { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': invalid value received in BOOLEAN: ' . $this->_xh['ac']); - } - $this->_xh['value'] = false; - } - } elseif ($name == 'DOUBLE') { - // we have a DOUBLE - // we must check that only 0123456789-. are characters here - // NOTE: regexp could be much stricter than this... - if (!preg_match('/^[+-eE0123456789 \t.]+$/', $this->_xh['ac'])) { - /// @todo: find a better way of throwing an error than this! - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': non numeric value received in DOUBLE: ' . $this->_xh['ac']); - $this->_xh['value'] = 'ERROR_NON_NUMERIC_FOUND'; - } else { - // it's ok, add it on - $this->_xh['value'] = (double)$this->_xh['ac']; - } - } else { - // we have an I4/I8/INT - // we must check that only 0123456789- are characters here - if (!preg_match('/^[+-]?[0123456789 \t]+$/', $this->_xh['ac'])) { - /// @todo find a better way of throwing an error than this! - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': non numeric value received in INT: ' . $this->_xh['ac']); - $this->_xh['value'] = 'ERROR_NON_NUMERIC_FOUND'; - } else { - // it's ok, add it on - $this->_xh['value'] = (int)$this->_xh['ac']; + if ($this->_xh['valuestack'][$vscount - 1]['name'] === null) { + if (!$this->handleParsingError('missing NAME inside STRUCT in received xml', __METHOD__)) { + return; } + $this->_xh['valuestack'][$vscount - 1]['name'] = ''; } - $this->_xh['lv'] = 3; // indicate we've found a value - break; - case 'NAME': - $this->_xh['valuestack'][count($this->_xh['valuestack']) - 1]['name'] = $this->_xh['ac']; - break; - case 'MEMBER': - // add to array in the stack the last element built, - // unless no VALUE was found - if ($this->_xh['vt']) { - $vscount = count($this->_xh['valuestack']); - $this->_xh['valuestack'][$vscount - 1]['values'][$this->_xh['valuestack'][$vscount - 1]['name']] = $this->_xh['value']; - } else { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': missing VALUE inside STRUCT in received xml'); + $this->_xh['valuestack'][$vscount - 1]['values'][$this->_xh['valuestack'][$vscount - 1]['name']] = $this->_xh['value']; + } else { + if (!$this->handleParsingError('missing VALUE inside STRUCT in received xml', __METHOD__)) { + return; } - break; - case 'DATA': - $this->_xh['vt'] = null; // reset this to check for 2 data elements in a row - even if they're empty - break; - case 'STRUCT': - case 'ARRAY': - // fetch out of stack array of values, and promote it to current value - $currVal = array_pop($this->_xh['valuestack']); - $this->_xh['value'] = $currVal['values']; - $this->_xh['vt'] = strtolower($name); - if (isset($currVal['php_class'])) { - $this->_xh['php_class'] = $currVal['php_class']; + } + break; + + case 'DATA': + $this->_xh['vt'] = null; // reset this to check for 2 data elements in a row - even if they're empty + break; + + case 'STRUCT': + case 'ARRAY': + // fetch out of stack array of values, and promote it to current value + $currVal = array_pop($this->_xh['valuestack']); + $this->_xh['value'] = $currVal['values']; + $this->_xh['vt'] = strtolower($name); + if (isset($currVal['php_class'])) { + $this->_xh['php_class'] = $currVal['php_class']; + } + break; + + case 'PARAM': + // add to array of params the current value, unless no VALUE was found + /// @todo should we also check if there were two VALUE inside the PARAM? + if ($this->_xh['vt']) { + $this->_xh['params'][] = $this->_xh['value']; + $this->_xh['pt'][] = $this->_xh['vt']; + } else { + if (!$this->handleParsingError('missing VALUE inside PARAM in received xml', __METHOD__)) { + return; } - break; - case 'PARAM': - // add to array of params the current value, - // unless no VALUE was found - if ($this->_xh['vt']) { - $this->_xh['params'][] = $this->_xh['value']; - $this->_xh['pt'][] = $this->_xh['vt']; - } else { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': missing VALUE inside PARAM in received xml'); + } + break; + + case 'METHODNAME': + if (!preg_match(PhpXmlRpc::$xmlrpc_methodname_format, $this->_xh['ac'])) { + if (!$this->handleParsingError('invalid data received in METHODNAME: '. + $this->truncateValueForLog($this->_xh['ac']), __METHOD__)) { + return; } - break; - case 'METHODNAME': - $this->_xh['method'] = preg_replace('/^[\n\r\t ]+/', '', $this->_xh['ac']); - break; - case 'NIL': - case 'EX:NIL': - if (PhpXmlRpc::$xmlrpc_null_extension) { - $this->_xh['vt'] = 'null'; - $this->_xh['value'] = null; - $this->_xh['lv'] = 3; - break; - } - // drop through intentionally if nil extension not enabled - case 'PARAMS': - case 'FAULT': - case 'METHODCALL': - case 'METHORESPONSE': - break; - default: - // End of INVALID ELEMENT! - // shall we add an assert here for unreachable code??? - break; - } + } + $methodName = trim($this->_xh['ac']); + $this->_xh['method'] = $methodName; + // we allow the callback to f.e. give us back a mangled method name by manipulating $this + if (isset($this->current_parsing_options['methodname_callback'])) { + call_user_func($this->current_parsing_options['methodname_callback'], $methodName, $this, $parser); + } + break; + + case 'NIL': + case 'EX:NIL': + // NB: if NIL support is not enabled, parsing stops at element start. So this If is redundant + //if ($this->current_parsing_options['xmlrpc_null_extension']) { + $this->_xh['vt'] = 'null'; + $this->_xh['value'] = null; + $this->_xh['lv'] = 3; + //} + break; + + /// @todo add extra checking: + /// - METHODRESPONSE should contain either a PARAMS with a single PARAM, or a FAULT + /// - FAULT should contain a single struct with the 2 expected members (check their name and type) + /// - METHODCALL should contain a methodname + case 'PARAMS': + case 'FAULT': + case 'METHODCALL': + case 'METHODRESPONSE': + break; + + default: + // End of INVALID ELEMENT + // Should we add an assert here for unreachable code? When an invalid element is found in xmlrpc_se, + // $this->_xh['isf'] is set to 2... + break; } } /** - * Used in decoding xmlrpc requests/responses without rebuilding xmlrpc Values. + * Used in decoding xml-rpc requests/responses without rebuilding xml-rpc Values. * @internal + * * @param resource $parser * @param string $name + * @return void */ public function xmlrpc_ee_fast($parser, $name) { @@ -546,10 +782,12 @@ class XMLParser } /** - * Used in decoding xmlrpc requests/responses while building xmlrpc-extension Values (plain php for all but base64 and datetime). + * Used in decoding xml-rpc requests/responses while building xmlrpc-extension Values (plain php for all but base64 and datetime). * @internal + * * @param resource $parser * @param string $name + * @return void */ public function xmlrpc_ee_epi($parser, $name) { @@ -559,45 +797,50 @@ class XMLParser /** * xml parser handler function for character data. * @internal + * * @param resource $parser * @param string $data + * @return void */ public function xmlrpc_cd($parser, $data) { // skip processing if xml fault already detected - if ($this->_xh['isf'] < 2) { - // "lookforvalue==3" means that we've found an entire value - // and should discard any further character data - if ($this->_xh['lv'] != 3) { - $this->_xh['ac'] .= $data; - } + if ($this->_xh['isf'] >= 2) { + return; + } + + // "lookforvalue == 3" means that we've found an entire value and should discard any further character data + if ($this->_xh['lv'] != 3) { + $this->_xh['ac'] .= $data; } } /** - * xml parser handler function for 'other stuff', ie. not char data or - * element start/end tag. In fact it only gets called on unknown entities... + * xml parser handler function for 'other stuff', i.e. not char data or element start/end tag. + * In fact, it only gets called on unknown entities... * @internal + * * @param $parser * @param string data + * @return void */ public function xmlrpc_dh($parser, $data) { // skip processing if xml fault already detected - if ($this->_xh['isf'] < 2) { - if (substr($data, 0, 1) == '&' && substr($data, -1, 1) == ';') { - $this->_xh['ac'] .= $data; - } + if ($this->_xh['isf'] >= 2) { + return; } - //return true; + if (substr($data, 0, 1) == '&' && substr($data, -1, 1) == ';') { + $this->_xh['ac'] .= $data; + } } /** * xml charset encoding guessing helper function. * Tries to determine the charset encoding of an XML chunk received over HTTP. * NB: according to the spec (RFC 3023), if text/xml content-type is received over HTTP without a content-type, - * we SHOULD assume it is strictly US-ASCII. But we try to be more tolerant of non conforming (legacy?) clients/servers, + * we SHOULD assume it is strictly US-ASCII. But we try to be more tolerant of non-conforming (legacy?) clients/servers, * which will be most probably using UTF-8 anyway... * In order of importance checks: * 1. http headers @@ -613,6 +856,8 @@ class XMLParser * PhpXmlRpc::$xmlrpc_defencoding if it can't be determined and mbstring is not enabled * * @todo explore usage of mb_http_input(): does it detect http headers + post data? if so, use it instead of hand-detection!!! + * @todo feature-creep make it possible to pass in options overriding usage of PhpXmlRpc static variables, to make + * the method independent of global state */ public static function guessEncoding($httpHeader = '', $xmlChunk = '', $encodingPrefs = null) { @@ -643,15 +888,18 @@ class XMLParser // in the xml declaration, and verify if they match. /// @todo implement check as described above? /// @todo implement check for first bytes of string even without a BOM? (It sure looks harder than for cases WITH a BOM) - if (preg_match('/^(\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\x00\x00\xFF\xFE|\xFE\xFF\x00\x00)/', $xmlChunk)) { + if (preg_match('/^(?:\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\x00\x00\xFF\xFE|\xFE\xFF\x00\x00)/', $xmlChunk)) { return 'UCS-4'; - } elseif (preg_match('/^(\xFE\xFF|\xFF\xFE)/', $xmlChunk)) { + } elseif (preg_match('/^(?:\xFE\xFF|\xFF\xFE)/', $xmlChunk)) { return 'UTF-16'; - } elseif (preg_match('/^(\xEF\xBB\xBF)/', $xmlChunk)) { + } elseif (preg_match('/^(?:\xEF\xBB\xBF)/', $xmlChunk)) { return 'UTF-8'; } // 3 - test if encoding is specified in the xml declaration + /// @todo this regexp will fail if $xmlChunk uses UTF-32/UCS-4, and most likely UTF-16/UCS-2 as well. In that + /// case we leave the guesswork up to mbstring - which seems to be able to detect it, starting with php 5.6. + /// For lower versions, we could attempt usage of mb_ereg... // Details: // SPACE: (#x20 | #x9 | #xD | #xA)+ === [ \x9\xD\xA]+ // EQ: SPACE?=SPACE? === [ \x9\xD\xA]*=[ \x9\xD\xA]* @@ -662,7 +910,7 @@ class XMLParser } // 4 - if mbstring is available, let it do the guesswork - if (extension_loaded('mbstring')) { + if (function_exists('mb_detect_encoding')) { if ($encodingPrefs == null && PhpXmlRpc::$xmlrpc_detectencodings != null) { $encodingPrefs = PhpXmlRpc::$xmlrpc_detectencodings; } @@ -688,20 +936,22 @@ class XMLParser } /** - * Helper function: checks if an xml chunk as a charset declaration (BOM or in the xml declaration) + * Helper function: checks if an xml chunk has a charset declaration (BOM or in the xml declaration). * * @param string $xmlChunk * @return bool + * + * @todo rename to hasEncodingDeclaration */ public static function hasEncoding($xmlChunk) { // scan the first bytes of the data for a UTF-16 (or other) BOM pattern // (source: http://www.w3.org/TR/2000/REC-xml-20001006) - if (preg_match('/^(\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\x00\x00\xFF\xFE|\xFE\xFF\x00\x00)/', $xmlChunk)) { + if (preg_match('/^(?:\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\x00\x00\xFF\xFE|\xFE\xFF\x00\x00)/', $xmlChunk)) { return true; - } elseif (preg_match('/^(\xFE\xFF|\xFF\xFE)/', $xmlChunk)) { + } elseif (preg_match('/^(?:\xFE\xFF|\xFF\xFE)/', $xmlChunk)) { return true; - } elseif (preg_match('/^(\xEF\xBB\xBF)/', $xmlChunk)) { + } elseif (preg_match('/^(?:\xEF\xBB\xBF)/', $xmlChunk)) { return true; } @@ -711,10 +961,132 @@ class XMLParser // EQ: SPACE?=SPACE? === [ \x9\xD\xA]*=[ \x9\xD\xA]* if (preg_match('/^<\?xml\s+version\s*=\s*' . "((?:\"[a-zA-Z0-9_.:-]+\")|(?:'[a-zA-Z0-9_.:-]+'))" . '\s+encoding\s*=\s*' . "((?:\"[A-Za-z][A-Za-z0-9._-]*\")|(?:'[A-Za-z][A-Za-z0-9._-]*'))/", - $xmlChunk, $matches)) { + $xmlChunk)) { return true; } return false; } + + /** + * @param string $message + * @param string $method method/file/line info + * @return bool false if the caller has to stop parsing + */ + protected function handleParsingError($message, $method = '') + { + if ($this->current_parsing_options['xmlrpc_reject_invalid_values']) { + $this->_xh['isf'] = 2; + $this->_xh['isf_reason'] = ucfirst($message); + return false; + } else { + $this->getLogger()->error('XML-RPC: ' . ($method != '' ? $method . ': ' : '') . $message); + return true; + } + } + + /** + * Truncates unsafe data + * @param string $data + * @return string + */ + protected function truncateValueForLog($data) + { + if (strlen($data) > $this->maxLogValueLength) { + return substr($data, 0, $this->maxLogValueLength - 3) . '...'; + } + + return $data; + } + + // *** BC layer *** + + /** + * xml parser handler function for opening element tags. + * Used in decoding xml chunks that might represent single xml-rpc values as well as requests, responses. + * @deprecated + * + * @param resource $parser + * @param $name + * @param $attrs + * @return void + */ + public function xmlrpc_se_any($parser, $name, $attrs) + { + // this will be spamming the log if this method is in use... + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + $this->xmlrpc_se($parser, $name, $attrs, true); + } + + public function &__get($name) + { + switch ($name) { + case '_xh': + case 'xmlrpc_valid_parents': + $this->logDeprecation('Getting property XMLParser::' . $name . ' is deprecated'); + return $this->$name; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + $result = null; + return $result; + } + } + + public function __set($name, $value) + { + switch ($name) { + // this should only ever be called by subclasses which overtook `parse()` + case 'accept': + $this->logDeprecation('Setting property XMLParser::' . $name . ' is deprecated'); + $this->current_parsing_options['accept'] = $value; + break; + case '_xh': + case 'xmlrpc_valid_parents': + $this->logDeprecation('Setting property XMLParser::' . $name . ' is deprecated'); + $this->$name = $value; + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } + + public function __isset($name) + { + switch ($name) { + case 'accept': + $this->logDeprecation('Checking property XMLParser::' . $name . ' is deprecated'); + return isset($this->current_parsing_options['accept']); + case '_xh': + case 'xmlrpc_valid_parents': + $this->logDeprecation('Checking property XMLParser::' . $name . ' is deprecated'); + return isset($this->$name); + default: + return false; + } + } + + public function __unset($name) + { + switch ($name) { + // q: does this make sense at all? + case 'accept': + $this->logDeprecation('Unsetting property XMLParser::' . $name . ' is deprecated'); + unset($this->current_parsing_options['accept']); + break; + case '_xh': + case 'xmlrpc_valid_parents': + $this->logDeprecation('Unsetting property XMLParser::' . $name . ' is deprecated'); + unset($this->$name); + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } } diff --git a/lib/phpxmlrpc/PhpXmlRpc.php b/lib/phpxmlrpc/PhpXmlRpc.php index c7c39eaa3e6..ce9a07accf6 100644 --- a/lib/phpxmlrpc/PhpXmlRpc.php +++ b/lib/phpxmlrpc/PhpXmlRpc.php @@ -2,32 +2,48 @@ namespace PhpXmlRpc; +use PhpXmlRpc\Helper\Charset; +use PhpXmlRpc\Helper\Http; +use PhpXmlRpc\Helper\Interop; +use PhpXmlRpc\Helper\XMLParser; + /** * Manages global configuration for operation of the library. */ class PhpXmlRpc { - static public $xmlrpcerr = array( - 'unknown_method' => 1, - 'invalid_return' => 2, - 'incorrect_params' => 3, - 'introspect_unknown' => 4, - 'http_error' => 5, - 'no_data' => 6, - 'no_ssl' => 7, - 'curl_fail' => 8, - 'invalid_request' => 15, - 'no_curl' => 16, - 'server_error' => 17, - 'multicall_error' => 18, - 'multicall_notstruct' => 9, - 'multicall_nomethod' => 10, - 'multicall_notstring' => 11, - 'multicall_recursion' => 12, - 'multicall_noparams' => 13, - 'multicall_notarray' => 14, - 'no_http2' => 15, + /** + * @var int[] + */ + public static $xmlrpcerr = array( + 'unknown_method' => 1, // server + /// @deprecated. left in for BC + 'invalid_return' => 2, // client + 'incorrect_params' => 3, // server + 'introspect_unknown' => 4, // server + 'http_error' => 5, // client + 'no_data' => 6, // client + 'no_ssl' => 7, // client + 'curl_fail' => 8, // client + 'invalid_request' => 15, // server + 'no_curl' => 16, // client + 'server_error' => 17, // server + 'multicall_error' => 18, // client + 'multicall_notstruct' => 9, // client + 'multicall_nomethod' => 10, // client + 'multicall_notstring' => 11, // client + 'multicall_recursion' => 12, // client + 'multicall_noparams' => 13, // client + 'multicall_notarray' => 14, // client + 'no_http2' => 19, // client + 'unsupported_option' => 20, // client + // the following 3 are meant to give greater insight than 'invalid_return'. They use the same code for BC, + // but you can override their value in your own code + 'invalid_xml' => 2, // client + 'xml_not_compliant' => 2, // client + 'xml_parsing_error' => 2, // client + /// @todo verify: can these conflict with $xmlrpcerrxml? 'cannot_decompress' => 103, 'decompress_fail' => 104, 'dechunk_fail' => 105, @@ -35,8 +51,12 @@ class PhpXmlRpc 'server_decompress_fail' => 107, ); - static public $xmlrpcstr = array( + /** + * @var string[] + */ + public static $xmlrpcstr = array( 'unknown_method' => 'Unknown method', + /// @deprecated. left in for BC 'invalid_return' => 'Invalid response payload (you can use the setDebug method to allow analysis of the response)', 'incorrect_params' => 'Incorrect parameters passed to method', 'introspect_unknown' => "Can't introspect: method unknown", @@ -55,6 +75,12 @@ class PhpXmlRpc 'multicall_noparams' => 'Missing params', 'multicall_notarray' => 'params is not an array', 'no_http2' => 'No HTTP/2 support compiled in', + 'unsupported_option' => 'Some client option is not supported with the transport method currently in use', + // the following 3 are meant to give greater insight than 'invalid_return'. They use the same string for BC, + // but you can override their value in your own code + 'invalid_xml' => 'Invalid response payload (you can use the setDebug method to allow analysis of the response)', + 'xml_not_compliant' => 'Invalid response payload (you can use the setDebug method to allow analysis of the response)', + 'xml_parsing_error' => 'Invalid response payload (you can use the setDebug method to allow analysis of the response)', 'cannot_decompress' => 'Received from server compressed HTTP and cannot decompress', 'decompress_fail' => 'Received from server invalid compressed HTTP', @@ -63,50 +89,182 @@ class PhpXmlRpc 'server_decompress_fail' => 'Received from client invalid compressed HTTP request', ); - // The charset encoding used by the server for received requests and by the client for received responses when - // received charset cannot be determined and mbstring extension is not enabled + /** + * @var string + * The charset encoding used by the server for received requests and by the client for received responses when + * received charset cannot be determined and mbstring extension is not enabled. + */ public static $xmlrpc_defencoding = "UTF-8"; - - // The list of encodings used by the server for requests and by the client for responses to detect the charset of - // the received payload when - // - the charset cannot be determined by looking at http headers, xml declaration or BOM - // - mbstring extension is enabled + /** + * @var string[] + * The list of preferred encodings used by the server for requests and by the client for responses to detect the + * charset of the received payload when + * - the charset cannot be determined by looking at http headers, xml declaration or BOM + * - mbstring extension is enabled + */ public static $xmlrpc_detectencodings = array(); - - // The encoding used internally by PHP. - // String values received as xml will be converted to this, and php strings will be converted to xml as if - // having been coded with this. - // Valid also when defining names of xmlrpc methods + /** + * @var string + * The encoding used internally by PHP. + * String values received as xml will be converted to this, and php strings will be converted to xml as if + * having been coded with this. + * Valid also when defining names of xml-rpc methods + */ public static $xmlrpc_internalencoding = "UTF-8"; + /** + * @var string + */ public static $xmlrpcName = "XML-RPC for PHP"; - public static $xmlrpcVersion = "4.8.1"; + /** + * @var string + */ + public static $xmlrpcVersion = "4.10.1"; - // let user errors start at 800 + /** + * @var int + * Let user errors start at 800 + */ public static $xmlrpcerruser = 800; - // let XML parse errors start at 100 + /** + * @var int + * Let XML parse errors start at 100 + */ public static $xmlrpcerrxml = 100; - // set to TRUE to enable correct decoding of and values + /** + * @var bool + * Set to TRUE to enable correct decoding of and values + */ public static $xmlrpc_null_extension = false; - // set to TRUE to enable encoding of php NULL values to instead of + /** + * @var bool + * Set to TRUE to make the library use DateTime objects instead of strings for all values parsed from incoming XML. + * NB: if the received strings are not parseable as dates, NULL will be returned. To prevent that, enable as + * well `xmlrpc_reject_invalid_values`, so that invalid dates will be rejected by the library + */ + public static $xmlrpc_return_datetimes = false; + + /** + * @var bool + * Set to TRUE to make the library reject incoming xml which uses invalid data for xml-rpc elements, such + * as base64 strings which can not be decoded, dateTime strings which do not represent a valid date, invalid bools, + * floats and integers, method names with forbidden characters, or struct members missing the value or name + */ + public static $xmlrpc_reject_invalid_values = false; + + /** + * @var bool + * Set to TRUE to enable encoding of php NULL values to instead of + */ public static $xmlrpc_null_apache_encoding = false; public static $xmlrpc_null_apache_encoding_ns = "http://ws.apache.org/xmlrpc/namespaces/extensions"; - // number of decimal digits used to serialize Double values + /** + * @var int + * Number of decimal digits used to serialize Double values. + * @todo rename :'-( + */ public static $xmlpc_double_precision = 128; + /** + * @var string + * Used to validate received date values. Alter this if the server/client you are communicating with uses date + * formats non-conformant with the spec + * NB: the string should not match any data which php can not successfully use in a DateTime object constructor call + * NB: atm, the Date helper uses this regexp and expects to find matches in a specific order + */ + public static $xmlrpc_datetime_format = '/^([0-9]{4})(0[1-9]|1[012])(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-4]):([0-5][0-9]):([0-5][0-9]|60)$/'; + + /** + * @var string + * Used to validate received integer values. Alter this if the server/client you are communicating with uses + * formats non-conformant with the spec. + * We keep in spaces for BC, even though they are forbidden by the spec. + * NB: the string should not match any data which php can not successfully cast to an integer + */ + public static $xmlrpc_int_format = '/^[ \t]*[+-]?[0-9]+[ \t]*$/'; + + /** + * @var string + * Used to validate received double values. Alter this if the server/client you are communicating with uses + * formats non-conformant with the spec, e.g. with leading/trailing spaces/tabs/newlines. + * We keep in spaces for BC, even though they are forbidden by the spec. + * NB: the string should not match any data which php can not successfully cast to a float + */ + public static $xmlrpc_double_format = '/^[ \t]*[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?[ \t]*$/'; + + /** + * @var string + * Used to validate received methodname values. + * According to the spec: "The string may only contain identifier characters, upper and lower-case A-Z, the numeric + * characters, 0-9, underscore, dot, colon and slash". + * We keep in leading and trailing spaces for BC, even though they are forbidden by the spec. + * But what about "identifier characters"? Is that meant to be 'identifier characters: upper and lower-case A-Z, ...' + * or something else? If the latter, there is no consensus across programming languages about what is a valid + * identifier character. PHP has one of the most crazy definitions of what is a valid identifier character, allowing + * _bytes_ in range x80-xff, without even specifying a character set (and then lowercasing anyway in some cases)... + */ + public static $xmlrpc_methodname_format = '|^[ \t]*[a-zA-Z0-9_.:/]+[ \t]*$|'; + + /** + * @var bool + * Set this to false to have a warning added to the log whenever user code uses a deprecated method/parameter/property + */ + public static $xmlrpc_silence_deprecations = true; + + // *** BC layer *** + + /** + * Inject a logger into all classes of the PhpXmlRpc library which use one + * + * @param $logger + * @return void + */ + public static function setLogger($logger) + { + Charset::setLogger($logger); + Client::setLogger($logger); + Encoder::setLogger($logger); + Http::setLogger($logger); + Request::setLogger($logger); + Server::setLogger($logger); + Value::setLogger($logger); + Wrapper::setLogger($logger); + XMLParser::setLogger($logger); + } + + /** + * Makes the library use the error codes detailed at https://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php + * + * @return void + * + * @tofo feature creep - allow switching back to the original set of codes; querying the current mode + */ + public static function useInteropFaults() + { + self::$xmlrpcerr = Interop::$xmlrpcerr; + + self::$xmlrpcerruser = -Interop::$xmlrpcerruser; + } + /** * A function to be used for compatibility with legacy code: it creates all global variables which used to be declared, * such as library version etc... + * @return void + * + * @deprecated */ public static function exportGlobals() { $reflection = new \ReflectionClass('PhpXmlRpc\PhpXmlRpc'); foreach ($reflection->getStaticProperties() as $name => $value) { - $GLOBALS[$name] = $value; + if (!in_array($name, array('xmlrpc_return_datetimes', 'xmlrpc_reject_invalid_values', 'xmlrpc_datetime_format', + 'xmlrpc_int_format', 'xmlrpc_double_format', 'xmlrpc_methodname_format', 'xmlrpc_silence_deprecations'))) { + $GLOBALS[$name] = $value; + } } // NB: all the variables exported into the global namespace below here do NOT guarantee 100% compatibility, @@ -114,19 +272,17 @@ class PhpXmlRpc $reflection = new \ReflectionClass('PhpXmlRpc\Value'); foreach ($reflection->getStaticProperties() as $name => $value) { - $GLOBALS[$name] = $value; - } - - $parser = new Helper\XMLParser(); - $reflection = new \ReflectionClass('PhpXmlRpc\Helper\XMLParser'); - foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $name => $value) { - if (in_array($value->getName(), array('xmlrpc_valid_parents'))) - { - $GLOBALS[$value->getName()] = $value->getValue($parser); + if (!in_array($name, array('logger', 'charsetEncoder'))) { + $GLOBALS[$name] = $value; } } - $charset = Helper\Charset::instance(); + /// @todo mke it possible to inject the XMLParser and Charset, as we do in other classes + + $parser = new Helper\XMLParser(); + $GLOBALS['xmlrpc_valid_parents'] = $parser->xmlrpc_valid_parents; + + $charset = Charset::instance(); $GLOBALS['xml_iso88591_Entities'] = $charset->getEntities('iso88591'); } @@ -139,14 +295,23 @@ class PhpXmlRpc * 2. set the values, e.g. $GLOBALS['xmlrpc_internalencoding'] = 'UTF-8'; * 3. import them: PhpXmlRpc\PhpXmlRpc::importGlobals(); * 4. run your own code. + * + * @return void + * + * @deprecated + * + * @todo this function does not import back xmlrpc_valid_parents and xml_iso88591_Entities */ public static function importGlobals() { $reflection = new \ReflectionClass('PhpXmlRpc\PhpXmlRpc'); - $staticProperties = $reflection->getStaticProperties(); - foreach ($staticProperties as $name => $value) { - if (isset($GLOBALS[$name])) { - self::$$name = $GLOBALS[$name]; + foreach ($reflection->getStaticProperties() as $name => $value) { + if (!in_array($name, array('xmlrpc_return_datetimes', 'xmlrpc_reject_invalid_values', 'xmlrpc_datetime_format', + 'xmlrpc_int_format', 'xmlrpc_double_format', 'xmlrpc_methodname_format', 'xmlrpc_silence_deprecations'))) + { + if (isset($GLOBALS[$name])) { + self::$$name = $GLOBALS[$name]; + } } } } diff --git a/lib/phpxmlrpc/Request.php b/lib/phpxmlrpc/Request.php index 0e4d1b87b80..0b89150728f 100644 --- a/lib/phpxmlrpc/Request.php +++ b/lib/phpxmlrpc/Request.php @@ -3,72 +3,44 @@ namespace PhpXmlRpc; use PhpXmlRpc\Exception\HttpException; -use PhpXmlRpc\Helper\Charset; use PhpXmlRpc\Helper\Http; -use PhpXmlRpc\Helper\Logger; use PhpXmlRpc\Helper\XMLParser; +use PhpXmlRpc\Traits\CharsetEncoderAware; +use PhpXmlRpc\Traits\DeprecationLogger; +use PhpXmlRpc\Traits\ParserAware; +use PhpXmlRpc\Traits\PayloadBearer; /** * This class provides the representation of a request to an XML-RPC server. * A client sends a PhpXmlrpc\Request to a server, and receives back an PhpXmlrpc\Response. + * + * @todo feature creep - add a protected $httpRequest member, in the same way the Response has one + * + * @property string $methodname deprecated - public access left in purely for BC. Access via method()/__construct() + * @property Value[] $params deprecated - public access left in purely for BC. Access via getParam()/__construct() + * @property int $debug deprecated - public access left in purely for BC. Access via .../setDebug() + * @property string $payload deprecated - public access left in purely for BC. Access via getPayload()/setPayload() + * @property string $content_type deprecated - public access left in purely for BC. Access via getContentType()/setPayload() */ class Request { - protected static $logger; - protected static $parser; - protected static $charsetEncoder; + use CharsetEncoderAware; + use DeprecationLogger; + use ParserAware; + use PayloadBearer; - /// @todo: do these need to be public? - public $payload; - /** @internal */ - public $methodname; - /** @internal */ - public $params = array(); - public $debug = 0; - public $content_type = 'text/xml'; + /** @var string */ + protected $methodname; + /** @var Value[] */ + protected $params = array(); + /** @var int */ + protected $debug = 0; - // holds data while parsing the response. NB: Not a full Response object - /** @deprecated will be removed in a future release */ - protected $httpResponse = array(); - - public function getLogger() - { - if (self::$logger === null) { - self::$logger = Logger::instance(); - } - return self::$logger; - } - - public static function setLogger($logger) - { - self::$logger = $logger; - } - - public function getParser() - { - if (self::$parser === null) { - self::$parser = new XMLParser(); - } - return self::$parser; - } - - public static function setParser($parser) - { - self::$parser = $parser; - } - - public function getCharsetEncoder() - { - if (self::$charsetEncoder === null) { - self::$charsetEncoder = Charset::instance(); - } - return self::$charsetEncoder; - } - - public function setCharsetEncoder($charsetEncoder) - { - self::$charsetEncoder = $charsetEncoder; - } + /** + * holds data while parsing the response. NB: Not a full Response object + * @deprecated will be removed in a future release; still accessible by subclasses for the moment + */ + private $httpResponse = array(); /** * @param string $methodName the name of the method to invoke @@ -83,56 +55,9 @@ class Request } /** - * @internal this function will become protected in the future - * @param string $charsetEncoding - * @return string - */ - public function xml_header($charsetEncoding = '') - { - if ($charsetEncoding != '') { - return "\n\n"; - } else { - return "\n\n"; - } - } - - /** - * @internal this function will become protected in the future - * @return string - */ - public function xml_footer() - { - return ''; - } - - /** - * @internal this function will become protected in the future - * @param string $charsetEncoding - */ - public function createPayload($charsetEncoding = '') - { - if ($charsetEncoding != '') { - $this->content_type = 'text/xml; charset=' . $charsetEncoding; - } else { - $this->content_type = 'text/xml'; - } - $this->payload = $this->xml_header($charsetEncoding); - $this->payload .= '' . $this->getCharsetEncoder()->encodeEntities( - $this->methodname, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . "\n"; - $this->payload .= "\n"; - foreach ($this->params as $p) { - $this->payload .= "\n" . $p->serialize($charsetEncoding) . - "\n"; - } - $this->payload .= "\n"; - $this->payload .= $this->xml_footer(); - } - - /** - * Gets/sets the xmlrpc method to be invoked. + * Gets/sets the xml-rpc method to be invoked. * * @param string $methodName the method to be set (leave empty not to set it) - * * @return string the method that will be invoked */ public function method($methodName = '') @@ -144,37 +69,22 @@ class Request return $this->methodname; } - /** - * Returns xml representation of the message. XML prologue included. - * - * @param string $charsetEncoding - * - * @return string the xml representation of the message, xml prologue included - */ - public function serialize($charsetEncoding = '') - { - $this->createPayload($charsetEncoding); - - return $this->payload; - } - /** * Add a parameter to the list of parameters to be used upon method invocation. - * * Checks that $params is actually a Value object and not a plain php value. * * @param Value $param - * * @return boolean false on failure */ public function addParam($param) { - // check: do not add to self params which are not xmlrpc values + // check: do not add to self params which are not xml-rpc values if (is_object($param) && is_a($param, 'PhpXmlRpc\Value')) { $this->params[] = $param; return true; } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': value passed in must be a PhpXmlRpc\Value'); return false; } } @@ -183,7 +93,6 @@ class Request * Returns the nth parameter in the request. The index zero-based. * * @param integer $i the index of the parameter to fetch (zero based) - * * @return Value the i-th parameter */ public function getParam($i) @@ -202,7 +111,79 @@ class Request } /** - * Given an open file handle, read all data available and parse it as an xmlrpc response. + * Returns xml representation of the message, XML prologue included. Sets `payload` and `content_type` properties + * + * @param string $charsetEncoding + * @return string the xml representation of the message, xml prologue included + */ + public function serialize($charsetEncoding = '') + { + $this->createPayload($charsetEncoding); + + return $this->payload; + } + + /** + * @internal this function will become protected in the future (and be folded into serialize) + * + * @param string $charsetEncoding + * @return void + */ + public function createPayload($charsetEncoding = '') + { + $this->logDeprecationUnlessCalledBy('serialize'); + + if ($charsetEncoding != '') { + $this->content_type = 'text/xml; charset=' . $charsetEncoding; + } else { + $this->content_type = 'text/xml'; + } + + $result = $this->xml_header($charsetEncoding); + $result .= '' . $this->getCharsetEncoder()->encodeEntities( + $this->methodname, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . "\n"; + $result .= "\n"; + foreach ($this->params as $p) { + $result .= "\n" . $p->serialize($charsetEncoding) . + "\n"; + } + $result .= "\n"; + $result .= $this->xml_footer(); + + $this->payload = $result; + } + + /** + * @internal this function will become protected in the future (and be folded into serialize) + * + * @param string $charsetEncoding + * @return string + */ + public function xml_header($charsetEncoding = '') + { + $this->logDeprecationUnlessCalledBy('createPayload'); + + if ($charsetEncoding != '') { + return "\n\n"; + } else { + return "\n\n"; + } + } + + /** + * @internal this function will become protected in the future (and be folded into serialize) + * + * @return string + */ + public function xml_footer() + { + $this->logDeprecationUnlessCalledBy('createPayload'); + + return ''; + } + + /** + * Given an open file handle, read all data available and parse it as an xml-rpc response. * * NB: the file handle is not closed by this function. * NNB: might have trouble in rare cases to work on network streams, as we check for a read of 0 bytes instead of @@ -212,12 +193,15 @@ class Request * @param resource $fp stream pointer * @param bool $headersProcessed * @param string $returnType - * * @return Response + * + * @todo arsing Responses is not really the responsibility of the Request class. Maybe of the Client... + * @todo feature creep - add a flag to disable trying to parse the http headers */ public function parseResponseFile($fp, $headersProcessed = false, $returnType = 'xmlrpcvals') { $ipd = ''; + // q: is there an optimal buffer size? Is there any value in making the buffer size a tuneable? while ($data = fread($fp, 32768)) { $ipd .= $data; } @@ -225,30 +209,33 @@ class Request } /** - * Parse the xmlrpc response contained in the string $data and return a Response object. + * Parse the xml-rpc response contained in the string $data and return a Response object. * * When $this->debug has been set to a value greater than 0, will echo debug messages to screen while decoding. * - * @param string $data the xmlrpc response, possibly including http headers + * @param string $data the xml-rpc response, possibly including http headers * @param bool $headersProcessed when true prevents parsing HTTP headers for interpretation of content-encoding and * consequent decoding * @param string $returnType decides return type, i.e. content of response->value(). Either 'xmlrpcvals', 'xml' or * 'phpvals' - * * @return Response * * @todo parsing Responses is not really the responsibility of the Request class. Maybe of the Client... + * @todo what about only populating 'raw_data' in httpResponse when debug mode is > 0? + * @todo feature creep - allow parsing data gotten from a stream pointer instead of a string: read it piecewise, + * looking first for separation between headers and body, then for charset indicators, server debug info and + * . That would require a notable increase in code complexity... */ public function parseResponse($data = '', $headersProcessed = false, $returnType = XMLParser::RETURN_XMLRPCVALS) { - if ($this->debug) { - $this->getLogger()->debugMessage("---GOT---\n$data\n---END---"); + if ($this->debug > 0) { + $this->getLogger()->debug("---GOT---\n$data\n---END---"); } $this->httpResponse = array('raw_data' => $data, 'headers' => array(), 'cookies' => array()); if ($data == '') { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': no response received from server.'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': no response received from server.'); return new Response(0, PhpXmlRpc::$xmlrpcerr['no_data'], PhpXmlRpc::$xmlrpcstr['no_data']); } @@ -256,7 +243,7 @@ class Request if (substr($data, 0, 4) == 'HTTP') { $httpParser = new Http(); try { - $this->httpResponse = $httpParser->parseResponseHeaders($data, $headersProcessed, $this->debug); + $httpResponse = $httpParser->parseResponseHeaders($data, $headersProcessed, $this->debug > 0); } catch (HttpException $e) { // failed processing of HTTP response headers // save into response obj the full payload received, for debugging @@ -264,42 +251,54 @@ class Request } catch(\Exception $e) { return new Response(0, $e->getCode(), $e->getMessage(), '', array('raw_data' => $data)); } + } else { + $httpResponse = $this->httpResponse; } // be tolerant of extra whitespace in response body $data = trim($data); - /// @todo return an error msg if $data == '' ? + /// @todo optimization creep - return an error msg if $data == '' // be tolerant of junk after methodResponse (e.g. javascript ads automatically inserted by free hosts) - // idea from Luca Mariano originally in PEARified version of the lib + // idea from Luca Mariano, originally in PEARified version of the lib $pos = strrpos($data, ''); if ($pos !== false) { $data = substr($data, 0, $pos + 17); } // try to 'guestimate' the character encoding of the received response - $respEncoding = XMLParser::guessEncoding(@$this->httpResponse['headers']['content-type'], $data); + $respEncoding = XMLParser::guessEncoding( + isset($httpResponse['headers']['content-type']) ? $httpResponse['headers']['content-type'] : '', + $data + ); - if ($this->debug) { + if ($this->debug >= 0) { + $this->httpResponse = $httpResponse; + } else { + $httpResponse = null; + } + + if ($this->debug > 0) { $start = strpos($data, '', $start); $comments = substr($data, $start, $end - $start); - $this->getLogger()->debugMessage("---SERVER DEBUG INFO (DECODED) ---\n\t" . - str_replace("\n", "\n\t", base64_decode($comments)) . "\n---END---", $respEncoding); + $this->getLogger()->debug("---SERVER DEBUG INFO (DECODED)---\n\t" . + str_replace("\n", "\n\t", base64_decode($comments)) . "\n---END---", array('encoding' => $respEncoding)); } } - // if user wants back raw xml, give it to her + // if the user wants back raw xml, give it to her if ($returnType == 'xml') { - return new Response($data, 0, '', 'xml', $this->httpResponse); + return new Response($data, 0, '', 'xml', $httpResponse); } + /// @todo move this block of code into the XMLParser if ($respEncoding != '') { - - // Since parsing will fail if charset is not specified in the xml prologue, + // Since parsing will fail if charset is not specified in the xml declaration, // the encoding is not UTF8 and there are non-ascii chars in the text, we try to work round that... // The following code might be better for mb_string enabled installs, but makes the lib about 200% slower... //if (!is_valid_charset($respEncoding, array('UTF-8'))) @@ -307,75 +306,81 @@ class Request if (function_exists('mb_convert_encoding')) { $data = mb_convert_encoding($data, 'UTF-8', $respEncoding); } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': unsupported charset encoding of received response: ' . $respEncoding); + if ($respEncoding == 'ISO-8859-1') { + $data = utf8_encode($data); + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': unsupported charset encoding of received response: ' . $respEncoding); + } } } } - // PHP internally might use ISO-8859-1, so we have to tell the xml parser to give us back data in the expected charset. - // What if internal encoding is not in one of the 3 allowed? We use the broadest one, ie. utf8 - // This allows to send data which is native in various charset, by extending xmlrpc_encode_entities() and - // setting xmlrpc_internalencoding - if (!in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { - /// @todo emit a warning - $options = array(XML_OPTION_TARGET_ENCODING => 'UTF-8'); - } else { + // What if internal encoding is not in one of the 3 allowed? We use the broadest one, i.e. utf8 + if (in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { $options = array(XML_OPTION_TARGET_ENCODING => PhpXmlRpc::$xmlrpc_internalencoding); + } else { + $options = array(XML_OPTION_TARGET_ENCODING => 'UTF-8', 'target_charset' => PhpXmlRpc::$xmlrpc_internalencoding); } $xmlRpcParser = $this->getParser(); - $xmlRpcParser->parse($data, $returnType, XMLParser::ACCEPT_RESPONSE, $options); + $_xh = $xmlRpcParser->parse($data, $returnType, XMLParser::ACCEPT_RESPONSE, $options); + // BC + if (!is_array($_xh)) { + $_xh = $xmlRpcParser->_xh; + } - // first error check: xml not well formed - if ($xmlRpcParser->_xh['isf'] > 2) { + // first error check: xml not well-formed + if ($_xh['isf'] == 3) { // BC break: in the past for some cases we used the error message: 'XML error at line 1, check URL' - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['invalid_return'], - PhpXmlRpc::$xmlrpcstr['invalid_return'] . ' ' . $xmlRpcParser->_xh['isf_reason'], '', - $this->httpResponse - ); + // Q: should we give back an error with variable error number, as we do server-side? But if we do, will + // we be able to tell apart the two cases? In theory, we never emit invalid xml on our end, but + // there could be proxies meddling with the request, or network data corruption... - if ($this->debug) { - print $xmlRpcParser->_xh['isf_reason']; + $r = new Response(0, PhpXmlRpc::$xmlrpcerr['invalid_xml'], + PhpXmlRpc::$xmlrpcstr['invalid_xml'] . ' ' . $_xh['isf_reason'], '', $httpResponse); + + if ($this->debug > 0) { + $this->getLogger()->debug($_xh['isf_reason']); } } - // second error check: xml well formed but not xml-rpc compliant - elseif ($xmlRpcParser->_xh['isf'] == 2) { - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['invalid_return'], - PhpXmlRpc::$xmlrpcstr['invalid_return'] . ' ' . $xmlRpcParser->_xh['isf_reason'], '', - $this->httpResponse - ); + // second error check: xml well-formed but not xml-rpc compliant + elseif ($_xh['isf'] == 2) { + $r = new Response(0, PhpXmlRpc::$xmlrpcerr['xml_not_compliant'], + PhpXmlRpc::$xmlrpcstr['xml_not_compliant'] . ' ' . $_xh['isf_reason'], '', $httpResponse); - if ($this->debug) { - /// @todo echo something for user? - } + /// @todo echo something for the user? check if it was already done by the parser... + //if ($this->debug > 0) { + // $this->getLogger()->debug($_xh['isf_reason']); + //} } // third error check: parsing of the response has somehow gone boink. /// @todo shall we omit this check, since we trust the parsing code? - elseif ($returnType == XMLParser::RETURN_XMLRPCVALS && !is_object($xmlRpcParser->_xh['value'])) { - // something odd has happened - // and it's time to generate a client side error - // indicating something odd went on - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['invalid_return'], PhpXmlRpc::$xmlrpcstr['invalid_return'], - '', $this->httpResponse + elseif ($_xh['isf'] > 3 || $returnType == XMLParser::RETURN_XMLRPCVALS && !is_object($_xh['value'])) { + // something odd has happened and it's time to generate a client side error indicating something odd went on + $r = new Response(0, PhpXmlRpc::$xmlrpcerr['xml_parsing_error'], PhpXmlRpc::$xmlrpcstr['xml_parsing_error'], + '', $httpResponse ); + + /// @todo echo something for the user? } else { if ($this->debug > 1) { - $this->getLogger()->debugMessage( - "---PARSED---\n".var_export($xmlRpcParser->_xh['value'], true)."\n---END---" + $this->getLogger()->debug( + "---PARSED---\n".var_export($_xh['value'], true)."\n---END---" ); } - $v = $xmlRpcParser->_xh['value']; + $v = $_xh['value']; - if ($xmlRpcParser->_xh['isf']) { - /// @todo we should test here if server sent an int and a string, and/or coerce them into such... + if ($_xh['isf']) { + /// @todo we should test (here or preferably in the parser) if server sent an int and a string, and/or + /// coerce them into such... if ($returnType == XMLParser::RETURN_XMLRPCVALS) { $errNo_v = $v['faultCode']; $errStr_v = $v['faultString']; - $errNo = $errNo_v->scalarval(); - $errStr = $errStr_v->scalarval(); + $errNo = $errNo_v->scalarVal(); + $errStr = $errStr_v->scalarVal(); } else { $errNo = $v['faultCode']; $errStr = $v['faultString']; @@ -383,12 +388,16 @@ class Request if ($errNo == 0) { // FAULT returned, errno needs to reflect that + /// @todo feature creep - add this code to PhpXmlRpc::$xmlrpcerr + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': fault response received with faultCode 0 or null. Converted it to -1'); + /// @todo in Encoder::decodeXML, we use PhpXmlRpc::$xmlrpcerr['invalid_return'] for this case (see + /// also the todo 17 lines above) $errNo = -1; } - $r = new Response(0, $errNo, $errStr, '', $this->httpResponse); + $r = new Response(0, $errNo, $errStr, '', $httpResponse); } else { - $r = new Response($v, 0, '', $returnType, $this->httpResponse); + $r = new Response($v, 0, '', $returnType, $httpResponse); } } @@ -396,7 +405,7 @@ class Request } /** - * Kept the old name even if Request class was renamed, for compatibility. + * Kept the old name even if Request class was renamed, for BC. * * @return string */ @@ -406,12 +415,149 @@ class Request } /** - * Enables/disables the echoing to screen of the xmlrpc responses received. + * Enables/disables the echoing to screen of the xml-rpc responses received. * - * @param integer $level values 0, 1, 2 are supported + * @param integer $level values <0, 0, 1, >1 are supported + * @return $this */ public function setDebug($level) { $this->debug = $level; + return $this; + } + + // *** BC layer *** + + // we have to make this return by ref in order to allow calls such as `$resp->_cookies['name'] = ['value' => 'something'];` + public function &__get($name) + { + switch ($name) { + case 'me': + case 'mytype': + case '_php_class': + case 'payload': + case 'content_type': + $this->logDeprecation('Getting property Request::' . $name . ' is deprecated'); + return $this->$name; + case 'httpResponse': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Request')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Getting property Request::' . $name . ' is deprecated'); + return $this->httpResponse; + } else { + trigger_error("Cannot access protected property Request::httpResponse in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + $result = null; + return $result; + } + } + + public function __set($name, $value) + { + switch ($name) { + case 'methodname': + case 'params': + case 'debug': + case 'payload': + case 'content_type': + $this->logDeprecation('Setting property Request::' . $name . ' is deprecated'); + $this->$name = $value; + break; + case 'httpResponse': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Request')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Setting property Request::' . $name . ' is deprecated'); + $this->httpResponse = $value; + } else { + trigger_error("Cannot access protected property Request::httpResponse in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } + + public function __isset($name) + { + switch ($name) { + case 'methodname': + case 'params': + case 'debug': + case 'payload': + case 'content_type': + $this->logDeprecation('Checking property Request::' . $name . ' is deprecated'); + return isset($this->$name); + case 'httpResponse': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Request')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Checking property Request::' . $name . ' is deprecated'); + return isset($this->httpResponse); + } + // break through voluntarily + default: + return false; + } + } + + public function __unset($name) + { + switch ($name) { + case 'methodname': + case 'params': + case 'debug': + case 'payload': + case 'content_type': + $this->logDeprecation('Unsetting property Request::' . $name . ' is deprecated'); + unset($this->$name); + break; + case 'httpResponse': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Request')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Unsetting property Request::' . $name . ' is deprecated'); + unset($this->httpResponse); + } else { + trigger_error("Cannot access protected property Request::httpResponse in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } } } diff --git a/lib/phpxmlrpc/Response.php b/lib/phpxmlrpc/Response.php index f814d7bcb59..d822d3c4fa3 100644 --- a/lib/phpxmlrpc/Response.php +++ b/lib/phpxmlrpc/Response.php @@ -2,58 +2,58 @@ namespace PhpXmlRpc; -use PhpXmlRpc\Helper\Charset; +use PhpXmlRpc\Exception\StateErrorException; +use PhpXmlRpc\Traits\CharsetEncoderAware; +use PhpXmlRpc\Traits\DeprecationLogger; +use PhpXmlRpc\Traits\PayloadBearer; /** * This class provides the representation of the response of an XML-RPC server. * Server-side, a server method handler will construct a Response and pass it as its return value. * An identical Response object will be returned by the result of an invocation of the send() method of the Client class. * - * @property array $hdrs deprecated, use $httpResponse['headers'] - * @property array _cookies deprecated, use $httpResponse['cookies'] - * @property string $raw_data deprecated, use $httpResponse['raw_data'] + * @property Value|string|mixed $val deprecated - public access left in purely for BC. Access via value()/__construct() + * @property string $valtyp deprecated - public access left in purely for BC. Access via valueType()/__construct() + * @property int $errno deprecated - public access left in purely for BC. Access via faultCode()/__construct() + * @property string $errstr deprecated - public access left in purely for BC. Access faultString()/__construct() + * @property string $payload deprecated - public access left in purely for BC. Access via getPayload()/setPayload() + * @property string $content_type deprecated - public access left in purely for BC. Access via getContentType()/setPayload() + * @property array $hdrs deprecated. Access via httpResponse()['headers'], set via $httpResponse['headers'] + * @property array _cookies deprecated. Access via httpResponse()['cookies'], set via $httpResponse['cookies'] + * @property string $raw_data deprecated. Access via httpResponse()['raw_data'], set via $httpResponse['raw_data'] */ class Response { - protected static $charsetEncoder; + use CharsetEncoderAware; + use DeprecationLogger; + use PayloadBearer; + + /** @var Value|string|mixed */ + protected $val = 0; + /** @var string */ + protected $valtyp; + /** @var int */ + protected $errno = 0; + /** @var string */ + protected $errstr = ''; - /// @todo: do these need to be public? - /** @internal */ - public $val = 0; - /** @internal */ - public $valtyp; - /** @internal */ - public $errno = 0; - /** @internal */ - public $errstr = ''; - public $payload; - public $content_type = 'text/xml'; protected $httpResponse = array('headers' => array(), 'cookies' => array(), 'raw_data' => '', 'status_code' => null); - public function getCharsetEncoder() - { - if (self::$charsetEncoder === null) { - self::$charsetEncoder = Charset::instance(); - } - return self::$charsetEncoder; - } - - public function setCharsetEncoder($charsetEncoder) - { - self::$charsetEncoder = $charsetEncoder; - } - /** - * @param Value|string|mixed $val either a Value object, a php value or the xml serialization of an xmlrpc value (a string) + * @param Value|string|mixed $val either a Value object, a php value or the xml serialization of an xml-rpc value (a string). + * Note that using anything other than a Value object wll have an impact on serialization. * @param integer $fCode set it to anything but 0 to create an error response. In that case, $val is discarded * @param string $fString the error string, in case of an error response * @param string $valType The type of $val passed in. Either 'xmlrpcvals', 'phpvals' or 'xml'. Leave empty to let - * the code guess the correct type. - * @param array|null $httpResponse + * the code guess the correct type by looking at $val - in which case strings are assumed + * to be serialized xml + * @param array|null $httpResponse this should be set when the response is being built out of data received from + * http (i.e. not when programmatically building a Response server-side). Array + * keys should include, if known: headers, cookies, raw_data, status_code * - * @todo add check that $val / $fCode / $fString is of correct type??? - * NB: as of now we do not do it, since it might be either an xmlrpc value or a plain php val, or a complete - * xml chunk, depending on usage of Client::send() inside which creator is called... + * @todo add check that $val / $fCode / $fString is of correct type? We could at least log a warning for fishy cases... + * NB: as of now we do not do it, since it might be either an xml-rpc value or a plain php val, or a complete + * xml chunk, depending on usage of Client::send() inside which the constructor is called. */ public function __construct($val, $fCode = 0, $fString = '', $valType = '', $httpResponse = null) { @@ -74,8 +74,12 @@ class Response $this->valtyp = 'phpvals'; } } else { - // user declares type of resp value: believe him $this->valtyp = $valType; + // user declares the type of resp value: we "almost" trust it... but log errors just in case + if (($this->valtyp == 'xmlrpcvals' && (!is_a($this->val, 'PhpXmlRpc\Value'))) || + ($this->valtyp == 'xml' && (!is_string($this->val)))) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': value passed in does not match type ' . $valType); + } } } @@ -116,13 +120,22 @@ class Response return $this->val; } + /** + * @return string + */ + public function valueType() + { + return $this->valtyp; + } + /** * Returns an array with the cookies received from the server. * Array has the form: $cookiename => array ('value' => $val, $attr1 => $val1, $attr2 => $val2, ...) * with attributes being e.g. 'expires', 'path', domain'. * NB: cookies sent as 'expired' by the server (i.e. with an expiry date in the past) are still present in the array. * It is up to the user-defined code to decide how to use the received cookies, and whether they have to be sent back - * with the next request to the server (using Client::setCookie) or not. + * with the next request to the server (using $client->setCookie) or not. + * The values are filled in at constructor time, and might not be set for specific debug values used. * * @return array[] array of cookies received from the server */ @@ -132,7 +145,10 @@ class Response } /** - * @return array array with keys 'headers', 'cookies', 'raw_data' and 'status_code' + * Returns an array with info about the http response received from the server. + * The values are filled in at constructor time, and might not be set for specific debug values used. + * + * @return array array with keys 'headers', 'cookies', 'raw_data' and 'status_code'. */ public function httpResponse() { @@ -140,13 +156,11 @@ class Response } /** - * Returns xml representation of the response. XML prologue not included. + * Returns xml representation of the response, XML prologue _not_ included. Sets `payload` and `content_type` properties * * @param string $charsetEncoding the charset to be used for serialization. If null, US-ASCII is assumed - * * @return string the xml representation of the response - * - * @throws \Exception + * @throws StateErrorException if the response was built out of a value of an unsupported type */ public function serialize($charsetEncoding = '') { @@ -155,6 +169,7 @@ class Response } else { $this->content_type = 'text/xml'; } + if (PhpXmlRpc::$xmlrpc_null_apache_encoding) { $result = "\n"; } else { @@ -165,78 +180,125 @@ class Response $result .= "\n" . "\nfaultCode\n" . $this->errno . "\n\n\nfaultString\n" . - Charset::instance()->encodeEntities($this->errstr, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . "\n\n" . - "\n\n"; + $this->getCharsetEncoder()->encodeEntities($this->errstr, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . + "\n\n\n\n"; } else { - if (!is_object($this->val) || !is_a($this->val, 'PhpXmlRpc\Value')) { - if (is_string($this->val) && $this->valtyp == 'xml') { - $result .= "\n\n" . - $this->val . - "\n"; - } else { - /// @todo try to build something serializable using the Encoder... - throw new \Exception('cannot serialize xmlrpc response objects whose content is native php values'); - } - } else { + if (is_object($this->val) && is_a($this->val, 'PhpXmlRpc\Value')) { + $result .= "\n\n" . $this->val->serialize($charsetEncoding) . "\n"; + } else if (is_string($this->val) && $this->valtyp == 'xml') { $result .= "\n\n" . - $this->val->serialize($charsetEncoding) . + $this->val . "\n"; + } else if ($this->valtyp == 'phpvals') { + $encoder = new Encoder(); + $val = $encoder->encode($this->val); + $result .= "\n\n" . $val->serialize($charsetEncoding) . "\n"; + } else { + throw new StateErrorException('cannot serialize xmlrpc response objects whose content is native php values'); } } $result .= "\n"; + $this->payload = $result; return $result; } - // BC layer - - public function __get($name) + /** + * @param string $charsetEncoding + * @return string + */ + public function xml_header($charsetEncoding = '') { - //trigger_error('getting property Response::' . $name . ' is deprecated', E_USER_DEPRECATED); + if ($charsetEncoding != '') { + return "\n"; + } else { + return "\n"; + } + } - switch($name) { + // *** BC layer *** + + // we have to make this return by ref in order to allow calls such as `$resp->_cookies['name'] = ['value' => 'something'];` + public function &__get($name) + { + switch ($name) { + case 'val': + case 'valtyp': + case 'errno': + case 'errstr': + case 'payload': + case 'content_type': + $this->logDeprecation('Getting property Response::' . $name . ' is deprecated'); + return $this->$name; case 'hdrs': + $this->logDeprecation('Getting property Response::' . $name . ' is deprecated'); return $this->httpResponse['headers']; case '_cookies': + $this->logDeprecation('Getting property Response::' . $name . ' is deprecated'); return $this->httpResponse['cookies']; case 'raw_data': + $this->logDeprecation('Getting property Response::' . $name . ' is deprecated'); return $this->httpResponse['raw_data']; default: - $trace = debug_backtrace(); + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); - return null; + $result = null; + return $result; } } public function __set($name, $value) { - //trigger_error('setting property Response::' . $name . ' is deprecated', E_USER_DEPRECATED); - - switch($name) { + switch ($name) { + case 'val': + case 'valtyp': + case 'errno': + case 'errstr': + case 'payload': + case 'content_type': + $this->logDeprecation('Setting property Response::' . $name . ' is deprecated'); + $this->$name = $value; + break; case 'hdrs': + $this->logDeprecation('Setting property Response::' . $name . ' is deprecated'); $this->httpResponse['headers'] = $value; break; case '_cookies': + $this->logDeprecation('Setting property Response::' . $name . ' is deprecated'); $this->httpResponse['cookies'] = $value; break; case 'raw_data': + $this->logDeprecation('Setting property Response::' . $name . ' is deprecated'); $this->httpResponse['raw_data'] = $value; break; default: - $trace = debug_backtrace(); + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); } } public function __isset($name) { - switch($name) { + switch ($name) { + case 'val': + case 'valtyp': + case 'errno': + case 'errstr': + case 'payload': + case 'content_type': + $this->logDeprecation('Checking property Response::' . $name . ' is deprecated'); + return isset($this->$name); case 'hdrs': + $this->logDeprecation('Checking property Response::' . $name . ' is deprecated'); return isset($this->httpResponse['headers']); case '_cookies': + $this->logDeprecation('Checking property Response::' . $name . ' is deprecated'); return isset($this->httpResponse['cookies']); case 'raw_data': + $this->logDeprecation('Checking property Response::' . $name . ' is deprecated'); return isset($this->httpResponse['raw_data']); default: return false; @@ -245,18 +307,31 @@ class Response public function __unset($name) { - switch($name) { + switch ($name) { + case 'val': + case 'valtyp': + case 'errno': + case 'errstr': + case 'payload': + case 'content_type': + $this->logDeprecation('Setting property Response::' . $name . ' is deprecated'); + unset($this->$name); + break; case 'hdrs': + $this->logDeprecation('Unsetting property Response::' . $name . ' is deprecated'); unset($this->httpResponse['headers']); break; case '_cookies': + $this->logDeprecation('Unsetting property Response::' . $name . ' is deprecated'); unset($this->httpResponse['cookies']); break; case 'raw_data': + $this->logDeprecation('Unsetting property Response::' . $name . ' is deprecated'); unset($this->httpResponse['raw_data']); break; default: - $trace = debug_backtrace(); + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); } } diff --git a/lib/phpxmlrpc/Server.php b/lib/phpxmlrpc/Server.php index 9004d785c50..e0436d3f60c 100644 --- a/lib/phpxmlrpc/Server.php +++ b/lib/phpxmlrpc/Server.php @@ -2,90 +2,146 @@ namespace PhpXmlRpc; -use PhpXmlRpc\Helper\Charset; +use PhpXmlRpc\Exception\NoSuchMethodException; +use PhpXmlRpc\Exception\ValueErrorException; +use PhpXmlRpc\Helper\Http; +use PhpXmlRpc\Helper\Interop; use PhpXmlRpc\Helper\Logger; use PhpXmlRpc\Helper\XMLParser; +use PhpXmlRpc\Traits\CharsetEncoderAware; +use PhpXmlRpc\Traits\DeprecationLogger; +use PhpXmlRpc\Traits\ParserAware; /** * Allows effortless implementation of XML-RPC servers + * + * @property string[] $accepted_compression deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property bool $allow_system_funcs deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property bool $compress_response deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property int $debug deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property int $exception_handling deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property string $functions_parameters_type deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property array $phpvals_encoding_options deprecated - public access left in purely for BC. Access via getOption()/setOption() + * @property string $response_charset_encoding deprecated - public access left in purely for BC. Access via getOption()/setOption() */ class Server { - protected static $logger; - protected static $parser; - protected static $charsetEncoder; + use CharsetEncoderAware; + use DeprecationLogger; + use ParserAware; + + const OPT_ACCEPTED_COMPRESSION = 'accepted_compression'; + const OPT_ALLOW_SYSTEM_FUNCS = 'allow_system_funcs'; + const OPT_COMPRESS_RESPONSE = 'compress_response'; + const OPT_DEBUG = 'debug'; + const OPT_EXCEPTION_HANDLING = 'exception_handling'; + const OPT_FUNCTIONS_PARAMETERS_TYPE = 'functions_parameters_type'; + const OPT_PHPVALS_ENCODING_OPTIONS = 'phpvals_encoding_options'; + const OPT_RESPONSE_CHARSET_ENCODING = 'response_charset_encoding'; + + /** @var string */ + protected static $responseClass = '\\PhpXmlRpc\\Response'; /** - * Defines how functions in dmap will be invoked: either using an xmlrpc request object - * or plain php values. - * Valid strings are 'xmlrpcvals', 'phpvals' or 'epivals' + * @var string + * Defines how functions in $dmap will be invoked: either using an xml-rpc Request object or plain php values. + * Valid strings are 'xmlrpcvals', 'phpvals' or 'epivals' (only for use by polyfill-xmlrpc). + * * @todo create class constants for these */ - public $functions_parameters_type = 'xmlrpcvals'; + protected $functions_parameters_type = 'xmlrpcvals'; /** - * Option used for fine-tuning the encoding the php values returned from - * functions registered in the dispatch map when the functions_parameters_types - * member is set to 'phpvals' + * @var array + * Option used for fine-tuning the encoding the php values returned from functions registered in the dispatch map + * when the functions_parameters_type member is set to 'phpvals'. * @see Encoder::encode for a list of values */ - public $phpvals_encoding_options = array('auto_dates'); + protected $phpvals_encoding_options = array('auto_dates'); /** + * @var int * Controls whether the server is going to echo debugging messages back to the client as comments in response body. - * Valid values: 0,1,2,3 + * SECURITY SENSITIVE! + * Valid values: + * 0 = + * 1 = + * 2 = + * 3 = */ - public $debug = 1; + protected $debug = 1; /** - * Controls behaviour of server when the invoked user function throws an exception: - * 0 = catch it and return an 'internal error' xmlrpc response (default) - * 1 = catch it and return an xmlrpc response with the error corresponding to the exception + * @var int + * Controls behaviour of server when the invoked method-handler function throws an exception (within the `execute` method): + * 0 = catch it and return an 'internal error' xml-rpc response (default) + * 1 = SECURITY SENSITIVE DO NOT ENABLE ON PUBLIC SERVERS!!! catch it and return an xml-rpc response with the error + * corresponding to the exception, both its code and message. * 2 = allow the exception to float to the upper layers + * Can be overridden per-method-handler in the dispatch map */ - public $exception_handling = 0; + protected $exception_handling = 0; /** - * When set to true, it will enable HTTP compression of the response, in case - * the client has declared its support for compression in the request. - * Set at constructor time. + * @var bool + * When set to true, it will enable HTTP compression of the response, in case the client has declared its support + * for compression in the request. + * Automatically set at constructor time. */ - public $compress_response = false; + protected $compress_response = false; /** - * List of http compression methods accepted by the server for requests. Set at constructor time. + * @var string[] + * List of http compression methods accepted by the server for requests. Automatically set at constructor time. * NB: PHP supports deflate, gzip compressions out of the box if compiled w. zlib */ - public $accepted_compression = array(); + protected $accepted_compression = array(); - /// Shall we serve calls to system.* methods? - public $allow_system_funcs = true; + /** + * @var bool + * Shall we serve calls to system.* methods? + */ + protected $allow_system_funcs = true; /** * List of charset encodings natively accepted for requests. * Set at constructor time. - * UNUSED so far... + * @deprecated UNUSED so far by this library. It is still accessible by subclasses but will be dropped in the future. */ - public $accepted_charset_encodings = array(); + private $accepted_charset_encodings = array(); /** + * @var string * Charset encoding to be used for response. * NB: if we can, we will convert the generated response from internal_encoding to the intended one. - * Can be: a supported xml encoding (only UTF-8 and ISO-8859-1 at present, unless mbstring is enabled), - * null (leave unspecified in response, convert output stream to US_ASCII), - * 'default' (use xmlrpc library default as specified in xmlrpc.inc, convert output stream if needed), - * or 'auto' (use client-specified charset encoding or same as request if request headers do not specify it (unless request is US-ASCII: then use library default anyway). + * Can be: + * - a supported xml encoding (only UTF-8 and ISO-8859-1, unless mbstring is enabled), + * - null (leave unspecified in response, convert output stream to US_ASCII), + * - 'auto' (use client-specified charset encoding or same as request if request headers do not specify it (unless request is US-ASCII: then use library default anyway). * NB: pretty dangerous if you accept every charset and do not have mbstring enabled) */ - public $response_charset_encoding = ''; + protected $response_charset_encoding = ''; + + protected static $options = array( + self::OPT_ACCEPTED_COMPRESSION, + self::OPT_ALLOW_SYSTEM_FUNCS, + self::OPT_COMPRESS_RESPONSE, + self::OPT_DEBUG, + self::OPT_EXCEPTION_HANDLING, + self::OPT_FUNCTIONS_PARAMETERS_TYPE, + self::OPT_PHPVALS_ENCODING_OPTIONS, + self::OPT_RESPONSE_CHARSET_ENCODING, + ); /** + * @var mixed * Extra data passed at runtime to method handling functions. Used only by EPI layer + * @internal */ public $user_data = null; /** - * Array defining php functions exposed as xmlrpc methods by this server. + * Array defining php functions exposed as xml-rpc methods by this server. * @var array[] $dmap */ protected $dmap = array(); @@ -99,45 +155,6 @@ class Server protected static $_xmlrpcs_occurred_errors = ''; protected static $_xmlrpcs_prev_ehandler = ''; - public function getLogger() - { - if (self::$logger === null) { - self::$logger = Logger::instance(); - } - return self::$logger; - } - - public static function setLogger($logger) - { - self::$logger = $logger; - } - - public function getParser() - { - if (self::$parser === null) { - self::$parser = new XMLParser(); - } - return self::$parser; - } - - public static function setParser($parser) - { - self::$parser = $parser; - } - - public function getCharsetEncoder() - { - if (self::$charsetEncoder === null) { - self::$charsetEncoder = Charset::instance(); - } - return self::$charsetEncoder; - } - - public function setCharsetEncoder($charsetEncoder) - { - self::$charsetEncoder = $charsetEncoder; - } - /** * @param array[] $dispatchMap the dispatch map with definition of exposed services * Array keys are the names of the method names. @@ -147,14 +164,20 @@ class Server * - signature (array, optional) * - signature_docs (array, optional) * - parameters_type (string, optional) - * @param boolean $serviceNow set to false to prevent the server from running upon construction + * - exception_handling (int, optional) + * @param boolean $serviceNow set to false in order to prevent the server from running upon construction */ public function __construct($dispatchMap = null, $serviceNow = true) { // if ZLIB is enabled, let the server by default accept compressed requests, // and compress responses sent to clients that support them if (function_exists('gzinflate')) { - $this->accepted_compression = array('gzip', 'deflate'); + $this->accepted_compression[] = 'gzip'; + } + if (function_exists('gzuncompress')) { + $this->accepted_compression[] = 'deflate'; + } + if (function_exists('gzencode') || function_exists('gzcompress')) { $this->compress_response = true; } @@ -162,36 +185,111 @@ class Server $this->accepted_charset_encodings = array('UTF-8', 'ISO-8859-1', 'US-ASCII'); // dispMap is a dispatch array of methods mapped to function names and signatures. - // If a method doesn't appear in the map then an unknown method error is generated - /* milosch - changed to make passing dispMap optional. - * instead, you can use the class add_to_map() function - * to add functions manually (borrowed from SOAPX4) - */ + // If a method doesn't appear in the map then an unknown method error is generated. + // milosch - changed to make passing dispMap optional. Instead, you can use the addToMap() function + // to add functions manually (borrowed from SOAPX4) if ($dispatchMap) { - $this->dmap = $dispatchMap; + $this->setDispatchMap($dispatchMap); if ($serviceNow) { $this->service(); } } } + /** + * @param string $name see all the OPT_ constants + * @param mixed $value + * @return $this + * @throws ValueErrorException on unsupported option + */ + public function setOption($name, $value) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION : + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + $this->$name = $value; + break; + default: + throw new ValueErrorException("Unsupported option '$name'"); + } + + return $this; + } + + /** + * @param string $name see all the OPT_ constants + * @return mixed + * @throws ValueErrorException on unsupported option + */ + public function getOption($name) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION: + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + return $this->$name; + default: + throw new ValueErrorException("Unsupported option '$name'"); + } + } + + /** + * Returns the complete list of Server options. + * @return array + */ + public function getOptions() + { + $values = array(); + foreach(static::$options as $opt) { + $values[$opt] = $this->getOption($opt); + } + return $values; + } + + /** + * @param array $options key: see all the OPT_ constants + * @return $this + * @throws ValueErrorException on unsupported option + */ + public function setOptions($options) + { + foreach($options as $name => $value) { + $this->setOption($name, $value); + } + + return $this; + } + /** * Set debug level of server. * - * @param integer $level debug lvl: determines info added to xmlrpc responses (as xml comments) + * @param integer $level debug lvl: determines info added to xml-rpc responses (as xml comments) * 0 = no debug info, * 1 = msgs set from user with debugmsg(), - * 2 = add complete xmlrpc request (headers and body), + * 2 = add complete xml-rpc request (headers and body), * 3 = add also all processing warnings happened during method processing * (NB: this involves setting a custom error handler, and might interfere * with the standard processing of the php function exposed as method. In - * particular, triggering an USER_ERROR level error will not halt script - * execution anymore, but just end up logged in the xmlrpc response) + * particular, triggering a USER_ERROR level error will not halt script + * execution anymore, but just end up logged in the xml-rpc response) * Note that info added at level 2 and 3 will be base64 encoded + * @return $this */ public function setDebug($level) { $this->debug = $level; + return $this; } /** @@ -200,6 +298,7 @@ class Server * character set. * * @param string $msg + * @return void */ public static function xmlrpc_debugmsg($msg) { @@ -208,10 +307,10 @@ class Server /** * Add a string to the debug info that will be later serialized by the server as part of the response message - * (base64 encoded, only when debug level >= 2) + * (base64 encoded) when debug level >= 2 * - * character set. * @param string $msg + * @return void */ public static function error_occurred($msg) { @@ -221,6 +320,8 @@ class Server /** * Return a string with the serialized representation of all debug info. * + * @internal this function will become protected in the future + * * @param string $charsetEncoding the target charset encoding for the serialization * * @return string an XML comment (or two) @@ -247,13 +348,12 @@ class Server } /** - * Execute the xmlrpc request, printing the response. + * Execute the xml-rpc request, printing the response. * * @param string $data the request body. If null, the http POST request will be examined * @param bool $returnPayload When true, return the response but do not echo it or any http header * * @return Response|string the response object (usually not used by caller...) or its xml serialization - * * @throws \Exception in case the executed method does throw an exception (and depending on server configuration) */ public function service($data = null, $returnPayload = false) @@ -268,35 +368,41 @@ class Server // Save what we received, before parsing it if ($this->debug > 1) { - $this->debugmsg("+++GOT+++\n" . $data . "\n+++END+++"); + $this->debugMsg("+++GOT+++\n" . $data . "\n+++END+++"); } - $r = $this->parseRequestHeaders($data, $reqCharset, $respCharset, $respEncoding); - if (!$r) { + $resp = $this->parseRequestHeaders($data, $reqCharset, $respCharset, $respEncoding); + if (!$resp) { // this actually executes the request - $r = $this->parseRequest($data, $reqCharset); + $resp = $this->parseRequest($data, $reqCharset); - // save full body of request into response, for more debugging usages. - // Note that this is the _request_ data, not the response's own data, unlike what happens client-side - /// @todo try to move this injection to the resp. constructor or use a non-deprecated access method - $r->raw_data = $rawData; + // save full body of request into response, for debugging purposes. + // NB: this is the _request_ data, not the response's own data, unlike what happens client-side + /// @todo try to move this injection to the resp. constructor or use a non-deprecated access method. Or, even + /// better: just avoid setting this, and set debug info of the received http request in the request + /// object instead? It's not like the developer misses access to _SERVER, _COOKIES though... + /// Last but not least: the raw data might be of use to handler functions - but in decompressed form... + $resp->raw_data = $rawData; } - if ($this->debug > 2 && static::$_xmlrpcs_occurred_errors) { - $this->debugmsg("+++PROCESSING ERRORS AND WARNINGS+++\n" . + if ($this->debug > 2 && static::$_xmlrpcs_occurred_errors != '') { + $this->debugMsg("+++PROCESSING ERRORS AND WARNINGS+++\n" . static::$_xmlrpcs_occurred_errors . "+++END+++"); } - $payload = $this->xml_header($respCharset); + $header = $resp->xml_header($respCharset); if ($this->debug > 0) { - $payload = $payload . $this->serializeDebug($respCharset); + $header .= $this->serializeDebug($respCharset); } - // Do not create response serialization if it has already happened. Helps building json magic - if (empty($r->payload)) { - $r->serialize($respCharset); + // Do not create response serialization if it has already happened. Helps to build json magic + /// @todo what if the payload was created targeting a different charset than $respCharset? + /// Also, if we do not call serialize(), the request will not set its content-type to have the charset declared + $payload = $resp->getPayload(); + if (empty($payload)) { + $payload = $resp->serialize($respCharset); } - $payload = $payload . $r->payload; + $payload = $header . $payload; if ($returnPayload) { return $payload; @@ -305,45 +411,39 @@ class Server // if we get a warning/error that has output some text before here, then we cannot // add a new header. We cannot say we are sending xml, either... if (!headers_sent()) { - header('Content-Type: ' . $r->content_type); - // we do not know if client actually told us an accepted charset, but if he did - // we have to tell him what we did + header('Content-Type: ' . $resp->getContentType()); + // we do not know if client actually told us an accepted charset, but if it did we have to tell it what we did header("Vary: Accept-Charset"); - // http compression of output: only - // if we can do it, and we want to do it, and client asked us to, + // http compression of output: only if we can do it, and we want to do it, and client asked us to, // and php ini settings do not force it already - /// @todo check separately for gzencode and gzcompress functions, in case of polyfills $phpNoSelfCompress = !ini_get('zlib.output_compression') && (ini_get('output_handler') != 'ob_gzhandler'); - if ($this->compress_response && function_exists('gzencode') && $respEncoding != '' - && $phpNoSelfCompress - ) { - if (strpos($respEncoding, 'gzip') !== false) { + if ($this->compress_response && $respEncoding != '' && $phpNoSelfCompress) { + if (strpos($respEncoding, 'gzip') !== false && function_exists('gzencode')) { $payload = gzencode($payload); header("Content-Encoding: gzip"); header("Vary: Accept-Encoding"); - } elseif (strpos($respEncoding, 'deflate') !== false) { + } elseif (strpos($respEncoding, 'deflate') !== false && function_exists('gzcompress')) { $payload = gzcompress($payload); header("Content-Encoding: deflate"); header("Vary: Accept-Encoding"); } } - // Do not output content-length header if php is compressing output for us: - // it will mess up measurements. + // Do not output content-length header if php is compressing output for us: it will mess up measurements. // Note that Apache/mod_php will add (and even alter!) the Content-Length header on its own, but only for // responses up to 8000 bytes if ($phpNoSelfCompress) { header('Content-Length: ' . (int)strlen($payload)); } } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': http headers already sent before response is fully generated. Check for php warning or error messages'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': http headers already sent before response is fully generated. Check for php warning or error messages'); } print $payload; - // return request, in case subclasses want it - return $r; + // return response, in case subclasses want it + return $resp; } /** @@ -357,12 +457,41 @@ class Server * @param string $doc method documentation * @param array[] $sigDoc the array of valid method signatures docs, following the format of $sig but with * descriptions instead of types (one string for return type, one per param) + * @param string $parametersType to allow single method handlers to receive php values instead of a Request, or vice-versa + * @param int $exceptionHandling @see $this->exception_handling + * @return void * * @todo raise a warning if the user tries to register a 'system.' method - * @todo allow setting parameters_type */ - public function add_to_map($methodName, $function, $sig = null, $doc = false, $sigDoc = false) + public function addToMap($methodName, $function, $sig = null, $doc = false, $sigDoc = false, $parametersType = false, + $exceptionHandling = false) { + $this->add_to_map($methodName, $function, $sig, $doc, $sigDoc, $parametersType, $exceptionHandling); + } + + /** + * Add a method to the dispatch map. + * + * @param string $methodName the name with which the method will be made available + * @param callable $function the php function that will get invoked + * @param array[] $sig the array of valid method signatures. + * Each element is one signature: an array of strings with at least one element + * First element = type of returned value. Elements 2..N = types of parameters 1..N + * @param string $doc method documentation + * @param array[] $sigDoc the array of valid method signatures docs, following the format of $sig but with + * descriptions instead of types (one string for return type, one per param) + * @param string $parametersType to allow single method handlers to receive php values instead of a Request, or vice-versa + * @param int $exceptionHandling @see $this->exception_handling + * @return void + * + * @todo raise a warning if the user tries to register a 'system.' method + * @deprecated use addToMap instead + */ + public function add_to_map($methodName, $function, $sig = null, $doc = false, $sigDoc = false, $parametersType = false, + $exceptionHandling = false) + { + $this->logDeprecationUnlessCalledBy('addToMap'); + $this->dmap[$methodName] = array( 'function' => $function, 'docstring' => $doc, @@ -373,14 +502,19 @@ class Server if ($sigDoc) { $this->dmap[$methodName]['signature_docs'] = $sigDoc; } + if ($parametersType) { + $this->dmap[$methodName]['parameters_type'] = $parametersType; + } + if ($exceptionHandling !== false) { + $this->dmap[$methodName]['exception_handling'] = $exceptionHandling; + } } /** * Verify type and number of parameters received against a list of known signatures. * - * @param array|Request $in array of either xmlrpc value objects or xmlrpc type definitions + * @param array|Request $in array of either xml-rpc value objects or xml-rpc type definitions * @param array $sigs array of known signatures to match against - * * @return array int, string */ protected function verifySignature($in, $sigs) @@ -398,7 +532,7 @@ class Server if (is_object($in)) { $p = $in->getParam($n); if ($p->kindOf() == 'scalar') { - $pt = $p->scalartyp(); + $pt = $p->scalarTyp(); } else { $pt = $p->kindOf(); } @@ -421,14 +555,14 @@ class Server } } if (isset($wanted)) { - return array(0, "Wanted {$wanted}, got {$got} at param {$pno}"); // TODO: Remove this modification in MDL-76415. + return array(0, "Wanted {$wanted}, got {$got} at param {$pno}"); } else { return array(0, "No method signature matches number of parameters"); } } /** - * Parse http headers received along with xmlrpc request. If needed, inflate request. + * Parse http headers received along with xml-rpc request. If needed, inflate request. * * @return Response|null null on success or an error Response */ @@ -437,14 +571,14 @@ class Server // check if $_SERVER is populated: it might have been disabled via ini file // (this is true even when in CLI mode) if (count($_SERVER) == 0) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': cannot parse request headers as $_SERVER is not populated'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': cannot parse request headers as $_SERVER is not populated'); } if ($this->debug > 1) { if (function_exists('getallheaders')) { - $this->debugmsg(''); // empty line + $this->debugMsg(''); // empty line foreach (getallheaders() as $name => $val) { - $this->debugmsg("HEADER: $name: $val"); + $this->debugMsg("HEADER: $name: $val"); } } } @@ -461,26 +595,27 @@ class Server if ($contentEncoding != '' && strlen($data)) { if ($contentEncoding == 'deflate' || $contentEncoding == 'gzip') { // if decoding works, use it. else assume data wasn't gzencoded + /// @todo test separately for gzinflate and gzuncompress if (function_exists('gzinflate') && in_array($contentEncoding, $this->accepted_compression)) { if ($contentEncoding == 'deflate' && $degzdata = @gzuncompress($data)) { $data = $degzdata; if ($this->debug > 1) { - $this->debugmsg("\n+++INFLATED REQUEST+++[" . strlen($data) . " chars]+++\n" . $data . "\n+++END+++"); + $this->debugMsg("\n+++INFLATED REQUEST+++[" . strlen($data) . " chars]+++\n" . $data . "\n+++END+++"); } } elseif ($contentEncoding == 'gzip' && $degzdata = @gzinflate(substr($data, 10))) { $data = $degzdata; if ($this->debug > 1) { - $this->debugmsg("+++INFLATED REQUEST+++[" . strlen($data) . " chars]+++\n" . $data . "\n+++END+++"); + $this->debugMsg("+++INFLATED REQUEST+++[" . strlen($data) . " chars]+++\n" . $data . "\n+++END+++"); } } else { - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['server_decompress_fail'], + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['server_decompress_fail'], PhpXmlRpc::$xmlrpcstr['server_decompress_fail'], '', array('raw_data' => $rawData) ); return $r; } } else { - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['server_cannot_decompress'], + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['server_cannot_decompress'], PhpXmlRpc::$xmlrpcstr['server_cannot_decompress'], '', array('raw_data' => $rawData) ); @@ -489,27 +624,22 @@ class Server } } - // check if client specified accepted charsets, and if we know how to fulfill - // the request + // check if client specified accepted charsets, and if we know how to fulfill the request if ($this->response_charset_encoding == 'auto') { $respEncoding = ''; if (isset($_SERVER['HTTP_ACCEPT_CHARSET'])) { - // here we should check if we can match the client-requested encoding - // with the encodings we know we can generate. - /// @todo we should parse q=0.x preferences instead of getting first charset specified... - $clientAcceptedCharsets = explode(',', strtoupper($_SERVER['HTTP_ACCEPT_CHARSET'])); - // Give preference to internal encoding - $knownCharsets = array(PhpXmlRpc::$xmlrpc_internalencoding, 'UTF-8', 'ISO-8859-1', 'US-ASCII'); - foreach ($knownCharsets as $charset) { - foreach ($clientAcceptedCharsets as $accepted) { - if (strpos($accepted, $charset) === 0) { + // here we check if we can match the client-requested encoding with the encodings we know we can generate. + // we parse q=0.x preferences instead of preferring the first charset specified + $http = new Http(); + $clientAcceptedCharsets = $http->parseAcceptHeader($_SERVER['HTTP_ACCEPT_CHARSET']); + $knownCharsets = $this->getCharsetEncoder()->knownCharsets(); + foreach ($clientAcceptedCharsets as $accepted) { + foreach ($knownCharsets as $charset) { + if (strtoupper($accepted) == strtoupper($charset)) { $respEncoding = $charset; - break; + break 2; } } - if ($respEncoding) { - break; - } } } } else { @@ -531,103 +661,114 @@ class Server } /** - * Parse an xml chunk containing an xmlrpc request and execute the corresponding - * php function registered with the server. + * Parse an xml chunk containing an xml-rpc request and execute the corresponding php function registered with the + * server. + * @internal this function will become protected in the future * * @param string $data the xml request * @param string $reqEncoding (optional) the charset encoding of the xml request - * * @return Response - * * @throws \Exception in case the executed method does throw an exception (and depending on server configuration) * - * @internal this function will become protected in the future * @todo either rename this function or move the 'execute' part out of it... */ public function parseRequest($data, $reqEncoding = '') { // decompose incoming XML into request structure + /// @todo move this block of code into the XMLParser if ($reqEncoding != '') { // Since parsing will fail if - // - charset is not specified in the xml prologue, + // - charset is not specified in the xml declaration, // - the encoding is not UTF8 and // - there are non-ascii chars in the text, // we try to work round that... - // The following code might be better for mb_string enabled installs, but - // makes the lib about 200% slower... + // The following code might be better for mb_string enabled installs, but it makes the lib about 200% slower... //if (!is_valid_charset($reqEncoding, array('UTF-8'))) if (!in_array($reqEncoding, array('UTF-8', 'US-ASCII')) && !XMLParser::hasEncoding($data)) { if (function_exists('mb_convert_encoding')) { $data = mb_convert_encoding($data, 'UTF-8', $reqEncoding); } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': unsupported charset encoding of received request: ' . $reqEncoding); + if ($reqEncoding == 'ISO-8859-1') { + $data = utf8_encode($data); + } else { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': unsupported charset encoding of received request: ' . $reqEncoding); + } } } } - // PHP internally might use ISO-8859-1, so we have to tell the xml parser to give us back data in the expected charset. - // What if internal encoding is not in one of the 3 allowed? We use the broadest one, ie. utf8 - // This allows to send data which is native in various charset, - // by extending xmlrpc_encode_entities() and setting xmlrpc_internalencoding - if (!in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { - /// @todo emit a warning - $options = array(XML_OPTION_TARGET_ENCODING => 'UTF-8'); - } else { + // What if internal encoding is not in one of the 3 allowed? We use the broadest one, i.e. utf8 + if (in_array(PhpXmlRpc::$xmlrpc_internalencoding, array('UTF-8', 'ISO-8859-1', 'US-ASCII'))) { $options = array(XML_OPTION_TARGET_ENCODING => PhpXmlRpc::$xmlrpc_internalencoding); + } else { + $options = array(XML_OPTION_TARGET_ENCODING => 'UTF-8', 'target_charset' => PhpXmlRpc::$xmlrpc_internalencoding); } + // register a callback with the xml parser for when it finds the method name + $options['methodname_callback'] = array($this, 'methodNameCallback'); $xmlRpcParser = $this->getParser(); - $xmlRpcParser->parse($data, $this->functions_parameters_type, XMLParser::ACCEPT_REQUEST, $options); - if ($xmlRpcParser->_xh['isf'] > 2) { + try { + $_xh = $xmlRpcParser->parse($data, $this->functions_parameters_type, XMLParser::ACCEPT_REQUEST, $options); + // BC + if (!is_array($_xh)) { + $_xh = $xmlRpcParser->_xh; + } + } catch (NoSuchMethodException $e) { + return new static::$responseClass(0, $e->getCode(), $e->getMessage()); + } + + if ($_xh['isf'] == 3) { // (BC) we return XML error as a faultCode - preg_match('/^XML error ([0-9]+)/', $xmlRpcParser->_xh['isf_reason'], $matches); - $r = new Response(0, - PhpXmlRpc::$xmlrpcerrxml + $matches[1], - $xmlRpcParser->_xh['isf_reason']); - } elseif ($xmlRpcParser->_xh['isf']) { - $r = new Response(0, + preg_match('/^XML error ([0-9]+)/', $_xh['isf_reason'], $matches); + return new static::$responseClass( + 0, + PhpXmlRpc::$xmlrpcerrxml + (int)$matches[1], + $_xh['isf_reason']); + } elseif ($_xh['isf']) { + /// @todo separate better the various cases, as we have done in Request::parseResponse: invalid xml-rpc vs. + /// parsing error + return new static::$responseClass( + 0, PhpXmlRpc::$xmlrpcerr['invalid_request'], - PhpXmlRpc::$xmlrpcstr['invalid_request'] . ' ' . $xmlRpcParser->_xh['isf_reason']); + PhpXmlRpc::$xmlrpcstr['invalid_request'] . ' ' . $_xh['isf_reason']); } else { - // small layering violation in favor of speed and memory usage: - // we should allow the 'execute' method handle this, but in the - // most common scenario (xmlrpc values type server with some methods - // registered as phpvals) that would mean a useless encode+decode pass + // small layering violation in favor of speed and memory usage: we should allow the 'execute' method handle + // this, but in the most common scenario (xml-rpc values type server with some methods registered as phpvals) + // that would mean a useless encode+decode pass if ($this->functions_parameters_type != 'xmlrpcvals' || - (isset($this->dmap[$xmlRpcParser->_xh['method']]['parameters_type']) && - ($this->dmap[$xmlRpcParser->_xh['method']]['parameters_type'] != 'xmlrpcvals') + (isset($this->dmap[$_xh['method']]['parameters_type']) && + ($this->dmap[$_xh['method']]['parameters_type'] != 'xmlrpcvals') ) ) { if ($this->debug > 1) { - $this->debugmsg("\n+++PARSED+++\n" . var_export($xmlRpcParser->_xh['params'], true) . "\n+++END+++"); + $this->debugMsg("\n+++PARSED+++\n" . var_export($_xh['params'], true) . "\n+++END+++"); } - $r = $this->execute($xmlRpcParser->_xh['method'], $xmlRpcParser->_xh['params'], $xmlRpcParser->_xh['pt']); + + return $this->execute($_xh['method'], $_xh['params'], $_xh['pt']); } else { - // build a Request object with data parsed from xml - $req = new Request($xmlRpcParser->_xh['method']); - // now add parameters in - for ($i = 0; $i < count($xmlRpcParser->_xh['params']); $i++) { - $req->addParam($xmlRpcParser->_xh['params'][$i]); + // build a Request object with data parsed from xml and add parameters in + $req = new Request($_xh['method']); + /// @todo for more speed, we could just pass in the array to the constructor (and loose the type validation)... + for ($i = 0; $i < count($_xh['params']); $i++) { + $req->addParam($_xh['params'][$i]); } if ($this->debug > 1) { - $this->debugmsg("\n+++PARSED+++\n" . var_export($req, true) . "\n+++END+++"); + $this->debugMsg("\n+++PARSED+++\n" . var_export($req, true) . "\n+++END+++"); } - $r = $this->execute($req); + + return $this->execute($req); } } - - return $r; } /** * Execute a method invoked by the client, checking parameters used. * * @param Request|string $req either a Request obj or a method name - * @param mixed[] $params array with method parameters as php types (only if m is method name) - * @param string[] $paramTypes array with xmlrpc types of method parameters (only if m is method name) - * + * @param mixed[] $params array with method parameters as php types (only if $req is method name) + * @param string[] $paramTypes array with xml-rpc types of method parameters (only if $req is method name) * @return Response * * @throws \Exception in case the executed method does throw an exception (and depending on server configuration) @@ -638,23 +779,22 @@ class Server static::$_xmlrpc_debuginfo = ''; if (is_object($req)) { - $methName = $req->method(); + $methodName = $req->method(); } else { - $methName = $req; + $methodName = $req; } - $sysCall = $this->isSyscall($methName); + + $sysCall = $this->isSyscall($methodName); $dmap = $sysCall ? $this->getSystemDispatchMap() : $this->dmap; - if (!isset($dmap[$methName]['function'])) { + if (!isset($dmap[$methodName]['function'])) { // No such method - return new Response(0, - PhpXmlRpc::$xmlrpcerr['unknown_method'], - PhpXmlRpc::$xmlrpcstr['unknown_method']); + return new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['unknown_method'], PhpXmlRpc::$xmlrpcstr['unknown_method']); } // Check signature - if (isset($dmap[$methName]['signature'])) { - $sig = $dmap[$methName]['signature']; + if (isset($dmap[$methodName]['signature'])) { + $sig = $dmap[$methodName]['signature']; if (is_object($req)) { list($ok, $errStr) = $this->verifySignature($req, $sig); } else { @@ -662,20 +802,22 @@ class Server } if (!$ok) { // Didn't match. - return new Response( + return new static::$responseClass( 0, PhpXmlRpc::$xmlrpcerr['incorrect_params'], - PhpXmlRpc::$xmlrpcstr['incorrect_params'] . ": {$errStr}" // TODO: Remove this modification in MDL-76415. + PhpXmlRpc::$xmlrpcstr['incorrect_params'] . ": {$errStr}" ); } } - $func = $dmap[$methName]['function']; + $func = $dmap[$methodName]['function']; + // let the 'class::function' syntax be accepted in dispatch maps if (is_string($func) && strpos($func, '::')) { $func = explode('::', $func); } + // build string representation of function 'name' if (is_array($func)) { if (is_object($func[0])) { $funcName = get_class($func[0]) . '->' . $func[1]; @@ -690,16 +832,22 @@ class Server // verify that function to be invoked is in fact callable if (!is_callable($func)) { - $this->getLogger()->errorLog("XML-RPC: " . __METHOD__ . ": function '$funcName' registered as method handler is not callable"); - return new Response( + $this->getLogger()->error("XML-RPC: " . __METHOD__ . ": function '$funcName' registered as method handler is not callable"); + return new static::$responseClass( 0, PhpXmlRpc::$xmlrpcerr['server_error'], PhpXmlRpc::$xmlrpcstr['server_error'] . ": no function matches method" ); } - // If debug level is 3, we should catch all errors generated during - // processing of user function, and log them as part of response + if (isset($dmap[$methodName]['exception_handling'])) { + $exception_handling = (int)$dmap[$methodName]['exception_handling']; + } else { + $exception_handling = $this->exception_handling; + } + + // If debug level is 3, we should catch all errors generated during processing of user function, and log them + // as part of response if ($this->debug > 2) { self::$_xmlrpcs_prev_ehandler = set_error_handler(array('\PhpXmlRpc\Server', '_xmlrpcs_errorHandler')); } @@ -707,17 +855,18 @@ class Server try { // Allow mixed-convention servers if (is_object($req)) { + // call an 'xml-rpc aware' function if ($sysCall) { $r = call_user_func($func, $this, $req); } else { $r = call_user_func($func, $req); } if (!is_a($r, 'PhpXmlRpc\Response')) { - $this->getLogger()->errorLog("XML-RPC: " . __METHOD__ . ": function '$funcName' registered as method handler does not return an xmlrpc response object but a " . gettype($r)); + $this->getLogger()->error("XML-RPC: " . __METHOD__ . ": function '$funcName' registered as method handler does not return an xmlrpc response object but a " . gettype($r)); if (is_a($r, 'PhpXmlRpc\Value')) { - $r = new Response($r); + $r = new static::$responseClass($r); } else { - $r = new Response( + $r = new static::$responseClass( 0, PhpXmlRpc::$xmlrpcerr['server_error'], PhpXmlRpc::$xmlrpcstr['server_error'] . ": function does not return xmlrpc response object" @@ -732,16 +881,15 @@ class Server } else { // 3rd API convention for method-handling functions: EPI-style if ($this->functions_parameters_type == 'epivals') { - $r = call_user_func_array($func, array($methName, $params, $this->user_data)); - // mimic EPI behaviour: if we get an array that looks like an error, make it - // an error response + $r = call_user_func_array($func, array($methodName, $params, $this->user_data)); + // mimic EPI behaviour: if we get an array that looks like an error, make it an error response if (is_array($r) && array_key_exists('faultCode', $r) && array_key_exists('faultString', $r)) { - $r = new Response(0, (integer)$r['faultCode'], (string)$r['faultString']); + $r = new static::$responseClass(0, (integer)$r['faultCode'], (string)$r['faultString']); } else { - // functions using EPI api should NOT return resp objects, - // so make sure we encode the return type correctly + // functions using EPI api should NOT return resp objects, so make sure we encode the + // return type correctly $encoder = new Encoder(); - $r = new Response($encoder->encode($r, array('extension_api'))); + $r = new static::$responseClass($encoder->encode($r, array('extension_api'))); } } else { $r = call_user_func_array($func, $params); @@ -749,16 +897,17 @@ class Server } // the return type can be either a Response object or a plain php value... if (!is_a($r, '\PhpXmlRpc\Response')) { - // what should we assume here about automatic encoding of datetimes - // and php classes instances??? + // q: what should we assume here about automatic encoding of datetimes and php classes instances? + // a: let the user decide $encoder = new Encoder(); - $r = new Response($encoder->encode($r, $this->phpvals_encoding_options)); + $r = new static::$responseClass($encoder->encode($r, $this->phpvals_encoding_options)); } } + /// @todo bump minimum php version to 7.1 and use a single catch clause instead of the duplicate blocks } catch (\Exception $e) { - // (barring errors in the lib) an uncatched exception happened - // in the called function, we wrap it in a proper error-response - switch ($this->exception_handling) { + // (barring errors in the lib) an uncaught exception happened in the called function, we wrap it in a + // proper error-response + switch ($exception_handling) { case 2: if ($this->debug > 2) { if (self::$_xmlrpcs_prev_ehandler) { @@ -769,15 +918,43 @@ class Server } throw $e; case 1: - $r = new Response(0, $e->getCode(), $e->getMessage()); + $errCode = $e->getCode(); + if ($errCode == 0) { + $errCode = PhpXmlRpc::$xmlrpcerr['server_error']; + } + $r = new static::$responseClass(0, $errCode, $e->getMessage()); break; default: - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['server_error'], PhpXmlRpc::$xmlrpcstr['server_error']); + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['server_error'], PhpXmlRpc::$xmlrpcstr['server_error']); + } + } catch (\Error $e) { + // (barring errors in the lib) an uncaught exception happened in the called function, we wrap it in a + // proper error-response + switch ($exception_handling) { + case 2: + if ($this->debug > 2) { + if (self::$_xmlrpcs_prev_ehandler) { + set_error_handler(self::$_xmlrpcs_prev_ehandler); + } else { + restore_error_handler(); + } + } + throw $e; + case 1: + $errCode = $e->getCode(); + if ($errCode == 0) { + $errCode = PhpXmlRpc::$xmlrpcerr['server_error']; + } + $r = new static::$responseClass(0, $errCode, $e->getMessage()); + break; + default: + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['server_error'], PhpXmlRpc::$xmlrpcstr['server_error']); } } + if ($this->debug > 2) { - // note: restore the error handler we found before calling the - // user func, even if it has been changed inside the func itself + // note: restore the error handler we found before calling the user func, even if it has been changed + // inside the func itself if (self::$_xmlrpcs_prev_ehandler) { set_error_handler(self::$_xmlrpcs_prev_ehandler); } else { @@ -789,26 +966,59 @@ class Server } /** - * Add a string to the 'internal debug message' (separate from 'user debug message'). + * Registered as callback for when the XMLParser has found the name of the method to execute. + * Handling that early allows to 1. stop parsing the rest of the xml if there is no such method registered, and + * 2. tweak the type of data that the parser will return, in case the server uses mixed-calling-convention * - * @param string $string + * @internal + * @param $methodName + * @param XMLParser $xmlParser + * @param resource $parser + * @return void + * @throws NoSuchMethodException + * + * @todo feature creep - we could validate here that the method in the dispatch map is valid, but that would mean + * dirtying a lot the logic, as we would have back to both parseRequest() and execute() methods the info + * about the matched method handler, in order to avoid doing the work twice... */ - protected function debugmsg($string) + public function methodNameCallback($methodName, $xmlParser, $parser) { - $this->debug_info .= $string . "\n"; + $sysCall = $this->isSyscall($methodName); + $dmap = $sysCall ? $this->getSystemDispatchMap() : $this->dmap; + + if (!isset($dmap[$methodName]['function'])) { + // No such method + throw new NoSuchMethodException(PhpXmlRpc::$xmlrpcstr['unknown_method'], PhpXmlRpc::$xmlrpcerr['unknown_method']); + } + + // alter on-the-fly the config of the xml parser if needed + if (isset($dmap[$methodName]['parameters_type']) && + $dmap[$methodName]['parameters_type'] != $this->functions_parameters_type) { + /// @todo this should be done by a method of the XMLParser + switch ($dmap[$methodName]['parameters_type']) { + case XMLParser::RETURN_PHP: + xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee_fast'); + break; + case XMLParser::RETURN_EPIVALS: + xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee_epi'); + break; + /// @todo log a warning on unsupported return type + case XMLParser::RETURN_XMLRPCVALS: + default: + xml_set_element_handler($parser, 'xmlrpc_se', 'xmlrpc_ee'); + } + } } /** - * @param string $charsetEncoding - * @return string + * Add a string to the 'internal debug message' (separate from 'user debug message'). + * + * @param string $string + * @return void */ - protected function xml_header($charsetEncoding = '') + protected function debugMsg($string) { - if ($charsetEncoding != '') { - return "\n"; - } else { - return "\n"; - } + $this->debug_info .= $string . "\n"; } /** @@ -820,6 +1030,16 @@ class Server return (strpos($methName, "system.") === 0); } + /** + * @param array $dmap + * @return $this + */ + public function setDispatchMap($dmap) + { + $this->dmap = $dmap; + return $this; + } + /** * @return array[] */ @@ -873,26 +1093,26 @@ class Server ); } - /* Functions that implement system.XXX methods of xmlrpc servers */ - /** * @return array[] */ public function getCapabilities() { $outAr = array( - // xmlrpc spec: always supported + // xml-rpc spec: always supported 'xmlrpc' => array( - 'specUrl' => 'http://www.xmlrpc.com/spec', + 'specUrl' => 'http://www.xmlrpc.com/spec', // NB: the spec sits now at http://xmlrpc.com/spec.md 'specVersion' => 1 ), // if we support system.xxx functions, we always support multicall, too... - // Note that, as of 2006/09/17, the following URL does not respond anymore 'system.multicall' => array( + // Note that, as of 2006/09/17, the following URL does not respond anymore 'specUrl' => 'http://www.xmlrpc.com/discuss/msgReader$1208', 'specVersion' => 1 ), - // introspection: version 2! we support 'mixed', too + // introspection: version 2! we support 'mixed', too. + // note: the php xml-rpc extension says this instead: + // url http://xmlrpc-epi.sourceforge.net/specs/rfc.introspection.php, version 20010516 'introspection' => array( 'specUrl' => 'http://phpxmlrpc.sourceforge.net/doc-2/ch10.html', 'specVersion' => 2, @@ -902,15 +1122,26 @@ class Server // NIL extension if (PhpXmlRpc::$xmlrpc_null_extension) { $outAr['nil'] = array( + // Note that, as of 2023/01, the following URL does not respond anymore 'specUrl' => 'http://www.ontosys.com/xml-rpc/extensions.php', 'specVersion' => 1 ); } + // support for "standard" error codes + if (PhpXmlRpc::$xmlrpcerr['unknown_method'] === Interop::$xmlrpcerr['unknown_method']) { + $outAr['faults_interop'] = array( + 'specUrl' => 'http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php', + 'specVersion' => 20010516 + ); + } + return $outAr; } /** + * @internal handler of a system. method + * * @param Server $server * @param Request $req * @return Response @@ -918,10 +1149,12 @@ class Server public static function _xmlrpcs_getCapabilities($server, $req = null) { $encoder = new Encoder(); - return new Response($encoder->encode($server->getCapabilities())); + return new static::$responseClass($encoder->encode($server->getCapabilities())); } /** + * @internal handler of a system. method + * * @param Server $server * @param Request $req if called in plain php values mode, second param is missing * @return Response @@ -936,20 +1169,22 @@ class Server $outAr[] = new Value($key, 'string'); } - return new Response(new Value($outAr, 'array')); + return new static::$responseClass(new Value($outAr, 'array')); } /** + * @internal handler of a system. method + * * @param Server $server * @param Request $req * @return Response */ public static function _xmlrpcs_methodSignature($server, $req) { - // let accept as parameter both an xmlrpc value or string + // let's accept as parameter either an xml-rpc value or string if (is_object($req)) { $methName = $req->getParam(0); - $methName = $methName->scalarval(); + $methName = $methName->scalarVal(); } else { $methName = $req; } @@ -968,30 +1203,32 @@ class Server } $sigs[] = new Value($curSig, 'array'); } - $r = new Response(new Value($sigs, 'array')); + $r = new static::$responseClass(new Value($sigs, 'array')); } else { // NB: according to the official docs, we should be returning a // "none-array" here, which means not-an-array - $r = new Response(new Value('undef', 'string')); + $r = new static::$responseClass(new Value('undef', 'string')); } } else { - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['introspect_unknown'], PhpXmlRpc::$xmlrpcstr['introspect_unknown']); + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['introspect_unknown'], PhpXmlRpc::$xmlrpcstr['introspect_unknown']); } return $r; } /** + * @internal handler of a system. method + * * @param Server $server * @param Request $req * @return Response */ public static function _xmlrpcs_methodHelp($server, $req) { - // let accept as parameter both an xmlrpc value or string + // let's accept as parameter either an xml-rpc value or string if (is_object($req)) { $methName = $req->getParam(0); - $methName = $methName->scalarval(); + $methName = $methName->scalarVal(); } else { $methName = $req; } @@ -1002,22 +1239,28 @@ class Server } if (isset($dmap[$methName])) { if (isset($dmap[$methName]['docstring'])) { - $r = new Response(new Value($dmap[$methName]['docstring'], 'string')); + $r = new static::$responseClass(new Value($dmap[$methName]['docstring'], 'string')); } else { - $r = new Response(new Value('', 'string')); + $r = new static::$responseClass(new Value('', 'string')); } } else { - $r = new Response(0, PhpXmlRpc::$xmlrpcerr['introspect_unknown'], PhpXmlRpc::$xmlrpcstr['introspect_unknown']); + $r = new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['introspect_unknown'], PhpXmlRpc::$xmlrpcstr['introspect_unknown']); } return $r; } + /** + * @internal this function will become protected in the future + * + * @param $err + * @return Value + */ public static function _xmlrpcs_multicall_error($err) { if (is_string($err)) { - $str = PhpXmlRpc::$xmlrpcstr["multicall_{$err}"]; // TODO: Remove this modification in MDL-76415. - $code = PhpXmlRpc::$xmlrpcerr["multicall_{$err}"]; // TODO: Remove this modification in MDL-76415. + $str = PhpXmlRpc::$xmlrpcstr["multicall_{$err}"]; + $code = PhpXmlRpc::$xmlrpcerr["multicall_{$err}"]; } else { $code = $err->faultCode(); $str = $err->faultString(); @@ -1030,6 +1273,8 @@ class Server } /** + * @internal this function will become protected in the future + * * @param Server $server * @param Value $call * @return Value @@ -1043,10 +1288,10 @@ class Server if (!$methName) { return static::_xmlrpcs_multicall_error('nomethod'); } - if ($methName->kindOf() != 'scalar' || $methName->scalartyp() != 'string') { + if ($methName->kindOf() != 'scalar' || $methName->scalarTyp() != 'string') { return static::_xmlrpcs_multicall_error('notstring'); } - if ($methName->scalarval() == 'system.multicall') { + if ($methName->scalarVal() == 'system.multicall') { return static::_xmlrpcs_multicall_error('recursion'); } @@ -1058,11 +1303,11 @@ class Server return static::_xmlrpcs_multicall_error('notarray'); } - $req = new Request($methName->scalarval()); - foreach($params as $i => $param) { + $req = new Request($methName->scalarVal()); + foreach ($params as $i => $param) { if (!$req->addParam($param)) { $i++; // for error message, we count params from 1 - return static::_xmlrpcs_multicall_error(new Response(0, + return static::_xmlrpcs_multicall_error(new static::$responseClass(0, PhpXmlRpc::$xmlrpcerr['incorrect_params'], PhpXmlRpc::$xmlrpcstr['incorrect_params'] . ": probable xml error in param " . $i)); } @@ -1078,6 +1323,8 @@ class Server } /** + * @internal this function will become protected in the future + * * @param Server $server * @param Value $call * @return Value @@ -1126,6 +1373,8 @@ class Server } /** + * @internal handler of a system. method + * * @param Server $server * @param Request|array $req * @return Response @@ -1133,10 +1382,10 @@ class Server public static function _xmlrpcs_multicall($server, $req) { $result = array(); - // let accept a plain list of php parameters, beside a single xmlrpc msg object + // let's accept a plain list of php parameters, beside a single xml-rpc msg object if (is_object($req)) { $calls = $req->getParam(0); - foreach($calls as $call) { + foreach ($calls as $call) { $result[] = static::_xmlrpcs_multicall_do_call($server, $call); } } else { @@ -1146,16 +1395,18 @@ class Server } } - return new Response(new Value($result, 'array')); + return new static::$responseClass(new Value($result, 'array')); } /** * Error handler used to track errors that occur during server-side execution of PHP code. * This allows to report back to the client whether an internal error has occurred or not - * using an xmlrpc response object, instead of letting the client deal with the html junk + * using an xml-rpc response object, instead of letting the client deal with the html junk * that a PHP execution error on the server generally entails. * * NB: in fact a user defined error handler can only handle WARNING, NOTICE and USER_* errors. + * + * @internal */ public static function _xmlrpcs_errorHandler($errCode, $errString, $filename = null, $lineNo = null, $context = null) { @@ -1164,20 +1415,21 @@ class Server return; } - //if($errCode != E_NOTICE && $errCode != E_WARNING && $errCode != E_USER_NOTICE && $errCode != E_USER_WARNING) + //if ($errCode != E_NOTICE && $errCode != E_WARNING && $errCode != E_USER_NOTICE && $errCode != E_USER_WARNING) if ($errCode != E_STRICT) { - \PhpXmlRpc\Server::error_occurred($errString); + static::error_occurred($errString); } - // Try to avoid as much as possible disruption to the previous error handling - // mechanism in place + + // Try to avoid as much as possible disruption to the previous error handling mechanism in place if (self::$_xmlrpcs_prev_ehandler == '') { - // The previous error handler was the default: all we should do is log error - // to the default error log (if level high enough) + // The previous error handler was the default: all we should do is log error to the default error log + // (if level high enough) if (ini_get('log_errors') && (intval(ini_get('error_reporting')) & $errCode)) { + // we can't use the functionality of LoggerAware, because this is a static method if (self::$logger === null) { self::$logger = Logger::instance(); } - self::$logger->errorLog($errString); + self::$logger->error($errString); } } else { // Pass control on to previous error handler, trying to avoid loops... @@ -1192,4 +1444,168 @@ class Server } } } + + // *** BC layer *** + + /** + * @param string $charsetEncoding + * @return string + * + * @deprecated this method was moved to the Response class + */ + protected function xml_header($charsetEncoding = '') + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + if ($charsetEncoding != '') { + return "\n"; + } else { + return "\n"; + } + } + + // we have to make this return by ref in order to allow calls such as `$resp->_cookies['name'] = ['value' => 'something'];` + public function &__get($name) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION : + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + $this->logDeprecation('Getting property Request::' . $name . ' is deprecated'); + return $this->$name; + case 'accepted_charset_encodings': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Server')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Getting property Request::' . $name . ' is deprecated'); + return $this->accepted_compression; + } else { + trigger_error("Cannot access protected property Server::accepted_charset_encodings in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + $result = null; + return $result; + } + } + + public function __set($name, $value) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION : + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + $this->logDeprecation('Setting property Request::' . $name . ' is deprecated'); + $this->$name = $value; + break; + case 'accepted_charset_encodings': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Server')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Setting property Request::' . $name . ' is deprecated'); + $this->accepted_compression = $value; + } else { + trigger_error("Cannot access protected property Server::accepted_charset_encodings in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } + + public function __isset($name) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION : + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + $this->logDeprecation('Checking property Request::' . $name . ' is deprecated'); + return isset($this->$name); + case 'accepted_charset_encodings': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Server')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Checking property Request::' . $name . ' is deprecated'); + return isset($this->accepted_compression); + } + // break through voluntarily + default: + return false; + } + } + + public function __unset($name) + { + switch ($name) { + case self::OPT_ACCEPTED_COMPRESSION : + case self::OPT_ALLOW_SYSTEM_FUNCS: + case self::OPT_COMPRESS_RESPONSE: + case self::OPT_DEBUG: + case self::OPT_EXCEPTION_HANDLING: + case self::OPT_FUNCTIONS_PARAMETERS_TYPE: + case self::OPT_PHPVALS_ENCODING_OPTIONS: + case self::OPT_RESPONSE_CHARSET_ENCODING: + $this->logDeprecation('Unsetting property Request::' . $name . ' is deprecated'); + unset($this->$name); + break; + case 'accepted_charset_encodings': + // manually implement the 'protected property' behaviour + $canAccess = false; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + if (isset($trace[1]) && isset($trace[1]['class'])) { + if (is_subclass_of($trace[1]['class'], 'PhpXmlRpc\Server')) { + $canAccess = true; + } + } + if ($canAccess) { + $this->logDeprecation('Unsetting property Request::' . $name . ' is deprecated'); + unset($this->accepted_compression); + } else { + trigger_error("Cannot access protected property Server::accepted_charset_encodings in " . __FILE__, E_USER_ERROR); + } + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } } diff --git a/lib/phpxmlrpc/Traits/CharsetEncoderAware.php b/lib/phpxmlrpc/Traits/CharsetEncoderAware.php new file mode 100644 index 00000000000..b51ba17a4b1 --- /dev/null +++ b/lib/phpxmlrpc/Traits/CharsetEncoderAware.php @@ -0,0 +1,27 @@ +getLogger()->warning('XML-RPC Deprecated: ' . $message); + } + + /** + * @param string $callee + * @param string $expectedCaller atm only the method name is supported + * @return void + */ + protected function logDeprecationUnlessCalledBy($expectedCaller) + { + if (PhpXmlRpc::$xmlrpc_silence_deprecations) { + return; + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + /// @todo we should check as well $trace[2]['class'], and make sure that it is a descendent of the class passed in in $expectedCaller + if ($trace[2]['function'] === $expectedCaller) { + return; + } + + $this->getLogger()->warning('XML-RPC Deprecated: ' . $trace[1]['class'] . '::' . $trace[1]['function'] . + ' is only supposed to be called by ' . $expectedCaller); + } +} diff --git a/lib/phpxmlrpc/Traits/LoggerAware.php b/lib/phpxmlrpc/Traits/LoggerAware.php new file mode 100644 index 00000000000..5d47e8ce5a1 --- /dev/null +++ b/lib/phpxmlrpc/Traits/LoggerAware.php @@ -0,0 +1,27 @@ +payload = $payload; + + if ($contentType != '') { + $this->content_type = $contentType; + } + + return $this; + } + + /** + * @return string + */ + public function getPayload() + { + return $this->payload; + } + + /** + * @return string + */ + public function getContentType() + { + return $this->content_type; + } +} diff --git a/lib/phpxmlrpc/Value.php b/lib/phpxmlrpc/Value.php index d7d0026594e..64a9dbeb544 100644 --- a/lib/phpxmlrpc/Value.php +++ b/lib/phpxmlrpc/Value.php @@ -2,14 +2,24 @@ namespace PhpXmlRpc; -use PhpXmlRpc\Helper\Charset; -use PhpXmlRpc\Helper\Logger; +use PhpXmlRpc\Exception\StateErrorException; +use PhpXmlRpc\Exception\TypeErrorException; +use PhpXmlRpc\Exception\ValueErrorException; +use PhpXmlRpc\Traits\CharsetEncoderAware; +use PhpXmlRpc\Traits\DeprecationLogger; /** * This class enables the creation of values for XML-RPC, by encapsulating plain php values. + * + * @property Value[]|mixed $me deprecated - public access left in purely for BC. Access via scalarVal()/__construct() + * @property int $params $mytype - public access left in purely for BC. Access via kindOf()/__construct() + * @property string|null $_php_class deprecated - public access left in purely for BC. */ class Value implements \Countable, \IteratorAggregate, \ArrayAccess { + use CharsetEncoderAware; + use DeprecationLogger; + public static $xmlrpcI4 = "i4"; public static $xmlrpcI8 = "i8"; public static $xmlrpcInt = "int"; @@ -37,56 +47,25 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess "null" => 1, ); - protected static $logger; - protected static $charsetEncoder; - - /// @todo: do these need to be public? /** @var Value[]|mixed */ - public $me = array(); + protected $me = array(); /** - * @var int $mytype - * @internal + * @var int 0 for undef, 1 for scalar, 2 for array, 3 for struct */ - public $mytype = 0; - /** @var string|null $_php_class */ - public $_php_class = null; - - public function getLogger() - { - if (self::$logger === null) { - self::$logger = Logger::instance(); - } - return self::$logger; - } - - public static function setLogger($logger) - { - self::$logger = $logger; - } - - public function getCharsetEncoder() - { - if (self::$charsetEncoder === null) { - self::$charsetEncoder = Charset::instance(); - } - return self::$charsetEncoder; - } - - public function setCharsetEncoder($charsetEncoder) - { - self::$charsetEncoder = $charsetEncoder; - } + protected $mytype = 0; + /** @var string|null */ + protected $_php_class = null; /** - * Build an xmlrpc value. + * Build an xml-rpc value. * * When no value or type is passed in, the value is left uninitialized, and the value can be added later. * * @param Value[]|mixed $val if passing in an array, all array elements should be PhpXmlRpc\Value themselves - * @param string $type any valid xmlrpc type name (lowercase): i4, int, boolean, string, double, dateTime.iso8601, + * @param string $type any valid xml-rpc type name (lowercase): i4, int, boolean, string, double, dateTime.iso8601, * base64, array, struct, null. * If null, 'string' is assumed. - * You should refer to http://www.xmlrpc.com/spec for more information on what each of these mean. + * You should refer to http://xmlrpc.com/spec.md for more information on what each of these mean. */ public function __construct($val = -1, $type = '') { @@ -119,22 +98,27 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess $this->me['struct'] = $val; break; default: - $this->getLogger()->errorLog("XML-RPC: " . __METHOD__ . ": not a known type ($type)"); + $this->getLogger()->error("XML-RPC: " . __METHOD__ . ": not a known type ($type)"); } } } /** - * Add a single php value to an xmlrpc value. + * Add a single php value to an xml-rpc value. * - * If the xmlrpc value is an array, the php value is added as its last element. - * If the xmlrpc value is empty (uninitialized), this method makes it a scalar value, and sets that value. - * Fails if the xmlrpc value is not an array and already initialized. + * If the xml-rpc value is an array, the php value is added as its last element. + * If the xml-rpc value is empty (uninitialized), this method makes it a scalar value, and sets that value. + * Fails if the xml-rpc value is not an array (i.e. a struct or a scalar) and already initialized. * * @param mixed $val * @param string $type allowed values: i4, i8, int, boolean, string, double, dateTime.iso8601, base64, null. - * * @return int 1 or 0 on failure + * + * @todo arguably, as we have addArray to add elements to an Array value, and addStruct to add elements to a Struct + * value, we should not allow this method to add values to an Array. The 'scalar' in the method name refers to + * the expected state of the target object, not to the type of $val. Also, this works differently from + * addScalar/addStruct in that, when adding an element to an array, it wraps it into a new Value + * @todo rename? */ public function addScalar($val, $type = 'string') { @@ -144,13 +128,13 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } if ($typeOf !== 1) { - $this->getLogger()->errorLog("XML-RPC: " . __METHOD__ . ": not a scalar type ($type)"); + $this->getLogger()->error("XML-RPC: " . __METHOD__ . ": not a scalar type ($type)"); return 0; } // coerce booleans into correct values - // NB: we should either do it for datetimes, integers, i8 and doubles, too, - // or just plain remove this check, implemented on booleans only... + /// @todo we should either do it for datetimes, integers, i8 and doubles, too, or just plain remove this check, + /// implemented on booleans only... if ($type == static::$xmlrpcBoolean) { if (strcasecmp($val, 'true') == 0 || $val == 1 || ($val == true && strcasecmp($val, 'false'))) { $val = true; @@ -161,14 +145,16 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess switch ($this->mytype) { case 1: - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': scalar xmlrpc value can have only one value'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': scalar xmlrpc value can have only one value'); return 0; case 3: - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': cannot add anonymous scalar to struct xmlrpc value'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': cannot add anonymous scalar to struct xmlrpc value'); return 0; case 2: // we're adding a scalar value to an array here - $this->me['array'][] = new Value($val, $type); + /// @todo should we try avoiding re-wrapping Value objects? + $class = get_class($this); + $this->me['array'][] = new $class($val, $type); return 1; default: @@ -181,17 +167,17 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } /** - * Add an array of xmlrpc value objects to an xmlrpc value. + * Add an array of xml-rpc value objects to an xml-rpc value. * - * If the xmlrpc value is an array, the elements are appended to the existing ones. - * If the xmlrpc value is empty (uninitialized), this method makes it an array value, and sets that value. + * If the xml-rpc value is an array, the elements are appended to the existing ones. + * If the xml-rpc value is empty (uninitialized), this method makes it an array value, and sets that value. * Fails otherwise. * * @param Value[] $values - * * @return int 1 or 0 on failure * - * @todo add some checking for $values to be an array of xmlrpc values? + * @todo add some checking for $values to be an array of xml-rpc values? + * @todo rename to addToArray? */ public function addArray($values) { @@ -206,23 +192,23 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess return 1; } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': already initialized as a [' . $this->kindOf() . ']'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': already initialized as a [' . $this->kindOf() . ']'); return 0; } } /** - * Merges an array of named xmlrpc value objects into an xmlrpc value. + * Merges an array of named xml-rpc value objects into an xml-rpc value. * - * If the xmlrpc value is a struct, the elements are merged with the existing ones (overwriting existing ones). - * If the xmlrpc value is empty (uninitialized), this method makes it a struct value, and sets that value. + * If the xml-rpc value is a struct, the elements are merged with the existing ones (overwriting existing ones). + * If the xml-rpc value is empty (uninitialized), this method makes it a struct value, and sets that value. * Fails otherwise. * * @param Value[] $values - * * @return int 1 or 0 on failure * - * @todo add some checking for $values to be an array? + * @todo add some checking for $values to be an array of xml-rpc values? + * @todo rename to addToStruct? */ public function addStruct($values) { @@ -237,15 +223,15 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess return 1; } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': already initialized as a [' . $this->kindOf() . ']'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': already initialized as a [' . $this->kindOf() . ']'); return 0; } } /** - * Returns a string containing either "struct", "array", "scalar" or "undef", describing the base type of the value. + * Returns a string describing the base type of the value. * - * @return string + * @return string either "struct", "array", "scalar" or "undef" */ public function kindOf() { @@ -261,187 +247,13 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } } - /** - * @param string $typ - * @param Value[]|mixed $val - * @param string $charsetEncoding - * @return string - */ - protected function serializedata($typ, $val, $charsetEncoding = '') - { - $rs = ''; - - if (!isset(static::$xmlrpcTypes[$typ])) { - return $rs; - } - - switch (static::$xmlrpcTypes[$typ]) { - case 1: - switch ($typ) { - case static::$xmlrpcBase64: - $rs .= "<{$typ}>" . base64_encode($val) . ""; // TODO: Remove this modification in MDL-76415. - break; - case static::$xmlrpcBoolean: - $rs .= "<{$typ}>" . ($val ? '1' : '0') . ""; // TODO: Remove this modification in MDL-76415. - break; - case static::$xmlrpcString: - // Do NOT use htmlentities, since it will produce named html entities, which are invalid xml - $rs .= "<{$typ}>" . $this->getCharsetEncoder()->encodeEntities($val, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . ""; // TODO: Remove this modification in MDL-76415. - break; - case static::$xmlrpcInt: - case static::$xmlrpcI4: - case static::$xmlrpcI8: - $rs .= "<{$typ}>" . (int)$val . ""; // TODO: Remove this modification in MDL-76415. - break; - case static::$xmlrpcDouble: - // avoid using standard conversion of float to string because it is locale-dependent, - // and also because the xmlrpc spec forbids exponential notation. - // sprintf('%F') could be most likely ok but it fails eg. on 2e-14. - // The code below tries its best at keeping max precision while avoiding exp notation, - // but there is of course no limit in the number of decimal places to be used... - $rs .= "<{$typ}>" . preg_replace('/\\.?0+$/', '', number_format((double)$val, PhpXmlRpc::$xmlpc_double_precision, '.', '')) . ""; // TODO: Remove this modification in MDL-76415. - break; - case static::$xmlrpcDateTime: - if (is_string($val)) { - $rs .= "<{$typ}>{$val}"; // TODO: Remove this modification in MDL-76415. - } elseif (is_a($val, 'DateTime') || is_a($val, 'DateTimeInterface')) { - $rs .= "<{$typ}>" . $val->format('Ymd\TH:i:s') . ""; // TODO: Remove this modification in MDL-76415. - } elseif (is_int($val)) { - $rs .= "<{$typ}>" . date('Ymd\TH:i:s', $val) . ""; // TODO: Remove this modification in MDL-76415. - } else { - // not really a good idea here: but what should we output anyway? left for backward compat... - $rs .= "<{$typ}>{$val}"; // TODO: Remove this modification in MDL-76415. - } - break; - case static::$xmlrpcNull: - if (PhpXmlRpc::$xmlrpc_null_apache_encoding) { - $rs .= ""; - } else { - $rs .= ""; - } - break; - default: - // no standard type value should arrive here, but provide a possibility - // for xmlrpc values of unknown type... - $rs .= "<{$typ}>{$val}"; // TODO: Remove this modification in MDL-76415. - } - break; - case 3: - // struct - if ($this->_php_class) { - $rs .= '\n"; - } else { - $rs .= "\n"; - } - $charsetEncoder = $this->getCharsetEncoder(); - /** @var Value $val2 */ - foreach ($val as $key2 => $val2) { - $rs .= '' . $charsetEncoder->encodeEntities($key2, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . "\n"; - //$rs.=$this->serializeval($val2); - $rs .= $val2->serialize($charsetEncoding); - $rs .= "\n"; - } - $rs .= ''; - break; - case 2: - // array - $rs .= "\n\n"; - /** @var Value $element */ - foreach ($val as $element) { - //$rs.=$this->serializeval($val[$i]); - $rs .= $element->serialize($charsetEncoding); - } - $rs .= "\n"; - break; - default: - break; - } - - return $rs; - } /** - * Returns the xml representation of the value. XML prologue not included. - * - * @param string $charsetEncoding the charset to be used for serialization. if null, US-ASCII is assumed - * - * @return string - */ - public function serialize($charsetEncoding = '') - { - $val = reset($this->me); - $typ = key($this->me); - - return '' . $this->serializedata($typ, $val, $charsetEncoding) . "\n"; - } - - /** - * Checks whether a struct member with a given name is present. - * - * Works only on xmlrpc values of type struct. - * - * @param string $key the name of the struct member to be looked up - * - * @return boolean - * - * @deprecated use array access, e.g. isset($val[$key]) - */ - public function structmemexists($key) - { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - - return array_key_exists($key, $this->me['struct']); - } - - /** - * Returns the value of a given struct member (an xmlrpc value object in itself). - * Will raise a php warning if struct member of given name does not exist. - * - * @param string $key the name of the struct member to be looked up - * - * @return Value - * - * @deprecated use array access, e.g. $val[$key] - */ - public function structmem($key) - { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - - return $this->me['struct'][$key]; - } - - /** - * Reset internal pointer for xmlrpc values of type struct. - * @deprecated iterate directly over the object using foreach instead - */ - public function structreset() - { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - - reset($this->me['struct']); - } - - /** - * Return next member element for xmlrpc values of type struct. - * - * @return Value - * @throws \Error starting with php 8.0, this function should not be used, as it will always throw - * - * @deprecated iterate directly over the object using foreach instead - */ - public function structeach() - { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - - return @each($this->me['struct']); - } - - /** - * Returns the value of a scalar xmlrpc value (base 64 decoding is automatically handled here) + * Returns the value of a scalar xml-rpc value (base 64 decoding is automatically handled here) * * @return mixed */ - public function scalarval() + public function scalarVal() { $b = reset($this->me); @@ -449,13 +261,12 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } /** - * Returns the type of the xmlrpc value. + * Returns the type of the xml-rpc value. * - * For integers, 'int' is always returned in place of 'i4'. 'i8' is considered a separate type and returned as such - * - * @return string + * @return string For integers, 'int' is always returned in place of 'i4'. 'i8' is considered a separate type and + * returned as such */ - public function scalartyp() + public function scalarTyp() { reset($this->me); $a = key($this->me); @@ -467,51 +278,123 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } /** - * Returns the m-th member of an xmlrpc value of array type. + * Returns the xml representation of the value. XML prologue not included. * - * @param integer $key the index of the value to be retrieved (zero based) - * - * @return Value - * - * @deprecated use array access, e.g. $val[$key] + * @param string $charsetEncoding the charset to be used for serialization. If null, US-ASCII is assumed + * @return string */ - public function arraymem($key) + public function serialize($charsetEncoding = '') { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); + $val = reset($this->me); + $typ = key($this->me); - return $this->me['array'][$key]; + return '' . $this->serializeData($typ, $val, $charsetEncoding) . "\n"; } /** - * Returns the number of members in an xmlrpc value of array type. + * @param string $typ + * @param Value[]|mixed $val + * @param string $charsetEncoding + * @return string * - * @return integer - * - * @deprecated use count() instead + * @deprecated this should be folded back into serialize() */ - public function arraysize() + protected function serializeData($typ, $val, $charsetEncoding = '') { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); + $this->logDeprecationUnlessCalledBy('serialize'); - return count($this->me['array']); + if (!isset(static::$xmlrpcTypes[$typ])) { + return ''; + } + + switch (static::$xmlrpcTypes[$typ]) { + case 1: + switch ($typ) { + case static::$xmlrpcBase64: + $rs = "<{$typ}>" . base64_encode($val) . ""; + break; + case static::$xmlrpcBoolean: + $rs = "<{$typ}>" . ($val ? '1' : '0') . ""; + break; + case static::$xmlrpcString: + // Do NOT use htmlentities, since it will produce named html entities, which are invalid xml + $rs = "<{$typ}>" . $this->getCharsetEncoder()->encodeEntities($val, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . ""; + break; + case static::$xmlrpcInt: + case static::$xmlrpcI4: + case static::$xmlrpcI8: + $rs = "<{$typ}>" . (int)$val . ""; + break; + case static::$xmlrpcDouble: + // avoid using standard conversion of float to string because it is locale-dependent, + // and also because the xml-rpc spec forbids exponential notation. + // sprintf('%F') could be most likely ok, but it fails e.g. on 2e-14. + // The code below tries its best at keeping max precision while avoiding exp notation, + // but there is of course no limit in the number of decimal places to be used... + $rs = "<{$typ}>" . preg_replace('/\\.?0+$/', '', number_format((double)$val, PhpXmlRpc::$xmlpc_double_precision, '.', '')) . ""; + break; + case static::$xmlrpcDateTime: + if (is_string($val)) { + $rs = "<{$typ}>{$val}"; + // DateTimeInterface is not present in php 5.4... + } elseif (is_a($val, 'DateTimeInterface') || is_a($val, 'DateTime')) { + $rs = "<{$typ}>" . $val->format('Ymd\TH:i:s') . ""; + } elseif (is_int($val)) { + $rs = "<{$typ}>" . date('Ymd\TH:i:s', $val) . ""; + } else { + // not really a good idea here: but what should we output anyway? left for backward compat... + $rs = "<{$typ}>{$val}"; + } + break; + case static::$xmlrpcNull: + if (PhpXmlRpc::$xmlrpc_null_apache_encoding) { + $rs = ""; + } else { + $rs = ""; + } + break; + default: + // no standard type value should arrive here, but provide a possibility + // for xml-rpc values of unknown type... + $rs = "<{$typ}>{$val}"; + } + break; + case 3: + // struct + if ($this->_php_class) { + $rs = '\n"; + } else { + $rs = "\n"; + } + $charsetEncoder = $this->getCharsetEncoder(); + /** @var Value $val2 */ + foreach ($val as $key2 => $val2) { + $rs .= '' . $charsetEncoder->encodeEntities($key2, PhpXmlRpc::$xmlrpc_internalencoding, $charsetEncoding) . "\n"; + $rs .= $val2->serialize($charsetEncoding); + $rs .= "\n"; + } + $rs .= ''; + break; + case 2: + // array + $rs = "\n\n"; + /** @var Value $element */ + foreach ($val as $element) { + $rs .= $element->serialize($charsetEncoding); + } + $rs .= "\n"; + break; + default: + /// @todo log a warning? + $rs = ''; + break; + } + + return $rs; } /** - * Returns the number of members in an xmlrpc value of struct type. - * - * @return integer - * - * @deprecated use count() instead - */ - public function structsize() - { - //trigger_error('Method ' . __METHOD__ . ' is deprecated', E_USER_DEPRECATED); - - return count($this->me['struct']); - } - - /** - * Returns the number of members in an xmlrpc value: + * Returns the number of members in an xml-rpc value: * - 0 for uninitialized values * - 1 for scalar values * - the number of elements for struct and array values @@ -535,9 +418,9 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess /** * Implements the IteratorAggregate interface + * @internal required to be public to implement an Interface * * @return \ArrayIterator - * @internal required to be public to implement an Interface */ #[\ReturnTypeWillChange] public function getIterator() @@ -556,28 +439,31 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess /** * @internal required to be public to implement an Interface + * * @param mixed $offset * @param mixed $value - * @throws \Exception + * @return void + * + * @throws ValueErrorException|TypeErrorException */ #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { switch ($this->mytype) { case 3: - if (!($value instanceof \PhpXmlRpc\Value)) { - throw new \Exception('It is only possible to add Value objects to an XML-RPC Struct'); + if (!($value instanceof Value)) { + throw new TypeErrorException('It is only possible to add Value objects to an XML-RPC Struct'); } if (is_null($offset)) { // disallow struct members with empty names - throw new \Exception('It is not possible to add anonymous members to an XML-RPC Struct'); + throw new ValueErrorException('It is not possible to add anonymous members to an XML-RPC Struct'); } else { $this->me['struct'][$offset] = $value; } return; case 2: - if (!($value instanceof \PhpXmlRpc\Value)) { - throw new \Exception('It is only possible to add Value objects to an XML-RPC Array'); + if (!($value instanceof Value)) { + throw new TypeErrorException('It is only possible to add Value objects to an XML-RPC Array'); } if (is_null($offset)) { $this->me['array'][] = $value; @@ -587,22 +473,24 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess } return; case 1: -// todo: handle i4 vs int + /// @todo: should we handle usage of i4 to retrieve int (in both set/unset/isset)? After all we consider + /// 'int' to be the preferred form, as evidenced in scalarTyp() reset($this->me); $type = key($this->me); - if ($type != $offset) { - throw new \Exception(''); + if ($type != $offset && ($type != 'i4' || $offset != 'int')) { + throw new ValueErrorException('...'); } $this->me[$type] = $value; return; default: - // it would be nice to allow empty values to be be turned into non-empty ones this way, but we miss info to do so - throw new \Exception("XML-RPC Value is of type 'undef' and its value can not be set using array index"); + // it would be nice to allow empty values to be turned into non-empty ones this way, but we miss info to do so + throw new ValueErrorException("XML-RPC Value is of type 'undef' and its value can not be set using array index"); } } /** * @internal required to be public to implement an Interface + * * @param mixed $offset * @return bool */ @@ -615,8 +503,14 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess case 2: return isset($this->me['array'][$offset]); case 1: -// todo: handle i4 vs int - return $offset == $this->scalartyp(); + // handle i4 vs int + if ($offset == 'i4') { + // to be consistent with set and unset, we disallow usage of i4 to check for int + reset($this->me); + return $offset == key($this->me); + } else { + return $offset == $this->scalarTyp(); + } default: return false; } @@ -624,8 +518,11 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess /** * @internal required to be public to implement an Interface + * * @param mixed $offset - * @throws \Exception + * @return void + * + * @throws ValueErrorException|StateErrorException */ #[\ReturnTypeWillChange] public function offsetUnset($offset) @@ -639,17 +536,19 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess return; case 1: // can not remove value from a scalar - throw new \Exception("XML-RPC Value is of type 'scalar' and its value can not be unset using array index"); + /// @todo feature creep - allow this to move back the value to 'undef' state? + throw new StateErrorException("XML-RPC Value is of type 'scalar' and its value can not be unset using array index"); default: - throw new \Exception("XML-RPC Value is of type 'undef' and its value can not be unset using array index"); + throw new StateErrorException("XML-RPC Value is of type 'undef' and its value can not be unset using array index"); } } /** * @internal required to be public to implement an Interface + * * @param mixed $offset * @return mixed|Value|null - * @throws \Exception + * @throws StateErrorException */ #[\ReturnTypeWillChange] public function offsetGet($offset) @@ -660,13 +559,185 @@ class Value implements \Countable, \IteratorAggregate, \ArrayAccess case 2: return isset($this->me['array'][$offset]) ? $this->me['array'][$offset] : null; case 1: -// on bad type: null or exception? + /// @todo what to return on bad type: null or exception? $value = reset($this->me); $type = key($this->me); - return $type == $offset ? $value : null; + return $type == $offset ? $value : (($type == 'i4' && $offset == 'int') ? $value : null); default: -// return null or exception? - throw new \Exception("XML-RPC Value is of type 'undef' and can not be accessed using array index"); + // return null or exception? + throw new StateErrorException("XML-RPC Value is of type 'undef' and can not be accessed using array index"); + } + } + + // *** BC layer *** + + /** + * Checks whether a struct member with a given name is present. + * + * Works only on xml-rpc values of type struct. + * + * @param string $key the name of the struct member to be looked up + * @return boolean + * + * @deprecated use array access, e.g. isset($val[$key]) + */ + public function structMemExists($key) + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + return array_key_exists($key, $this->me['struct']); + } + + /** + * Returns the value of a given struct member (an xml-rpc value object in itself). + * Will raise a php warning if struct member of given name does not exist. + * + * @param string $key the name of the struct member to be looked up + * @return Value + * + * @deprecated use array access, e.g. $val[$key] + */ + public function structMem($key) + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + return $this->me['struct'][$key]; + } + + /** + * Reset internal pointer for xml-rpc values of type struct. + * @return void + * + * @deprecated iterate directly over the object using foreach instead + */ + public function structReset() + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + reset($this->me['struct']); + } + + /** + * Return next member element for xml-rpc values of type struct. + * + * @return array having the same format as PHP's `each` method + * + * @deprecated iterate directly over the object using foreach instead + */ + public function structEach() + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + $key = key($this->me['struct']); + $value = current($this->me['struct']); + next($this->me['struct']); + return array(1 => $value, 'value' => $value, 0 => $key, 'key' => $key); + } + + /** + * Returns the n-th member of an xml-rpc value of array type. + * + * @param integer $key the index of the value to be retrieved (zero based) + * + * @return Value + * + * @deprecated use array access, e.g. $val[$key] + */ + public function arrayMem($key) + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + return $this->me['array'][$key]; + } + + /** + * Returns the number of members in an xml-rpc value of array type. + * + * @return integer + * + * @deprecated use count() instead + */ + public function arraySize() + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + return count($this->me['array']); + } + + /** + * Returns the number of members in an xml-rpc value of struct type. + * + * @return integer + * + * @deprecated use count() instead + */ + public function structSize() + { + $this->logDeprecation('Method ' . __METHOD__ . ' is deprecated'); + + return count($this->me['struct']); + } + + // we have to make this return by ref in order to allow calls such as `$resp->_cookies['name'] = ['value' => 'something'];` + public function &__get($name) + { + switch ($name) { + case 'me': + case 'mytype': + case '_php_class': + $this->logDeprecation('Getting property Value::' . $name . ' is deprecated'); + return $this->$name; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + $result = null; + return $result; + } + } + + public function __set($name, $value) + { + switch ($name) { + case 'me': + case 'mytype': + case '_php_class': + $this->logDeprecation('Setting property Value::' . $name . ' is deprecated'); + $this->$name = $value; + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __set(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); + } + } + + public function __isset($name) + { + switch ($name) { + case 'me': + case 'mytype': + case '_php_class': + $this->logDeprecation('Checking property Value::' . $name . ' is deprecated'); + return isset($this->$name); + default: + return false; + } + } + + public function __unset($name) + { + switch ($name) { + case 'me': + case 'mytype': + case '_php_class': + $this->logDeprecation('Unsetting property Value::' . $name . ' is deprecated'); + unset($this->$name); + break; + default: + /// @todo throw instead? There are very few other places where the lib trigger errors which can potentially reach stdout... + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __unset(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], E_USER_WARNING); } } } diff --git a/lib/phpxmlrpc/Wrapper.php b/lib/phpxmlrpc/Wrapper.php index e000a0630a2..77c638c577c 100644 --- a/lib/phpxmlrpc/Wrapper.php +++ b/lib/phpxmlrpc/Wrapper.php @@ -1,58 +1,52 @@ elements instead of empty strings + * - bool encode_php_objs let php objects be sent to server using the 'improved' xml-rpc notation, so server can deserialize them as php objects * - bool decode_php_objs --- WARNING !!! possible security hazard. only use it with trusted servers --- * - bool suppress_warnings remove from produced xml any warnings generated at runtime by the php function being invoked - * * @return array|false false on error, or an array containing the name of the new php function, * its signature and docs, to be used in the server dispatch map * * @todo decide how to deal with params passed by ref in function definition: bomb out or allow? * @todo finish using phpdoc info to build method sig if all params are named but out of order * @todo add a check for params of 'resource' type - * @todo add some trigger_errors / error_log when returning false? + * @todo add some error logging when returning false? * @todo what to do when the PHP function returns NULL? We are currently returning an empty string value... * @todo add an option to suppress php warnings in invocation of user function, similar to server debug level 3? * @todo add a verbatim_object_copy parameter to allow avoiding usage the same obj instance? @@ -183,7 +174,7 @@ class Wrapper } if (is_array($callable)) { if (count($callable) < 2 || (!is_string($callable[0]) && !is_object($callable[0]))) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': syntax for function to be wrapped is wrong'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': syntax for function to be wrapped is wrong'); return false; } if (is_string($callable[0])) { @@ -195,7 +186,7 @@ class Wrapper } else if ($callable instanceof \Closure) { // we do not support creating code which wraps closures, as php does not allow to serialize them if (!$buildIt) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': a closure can not be wrapped in generated source code'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': a closure can not be wrapped in generated source code'); return false; } @@ -207,7 +198,7 @@ class Wrapper } if (!$exists) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': function to be wrapped is not defined: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': function to be wrapped is not defined: ' . $plainFuncName); return false; } @@ -251,23 +242,23 @@ class Wrapper if (is_array($callable)) { $func = new \ReflectionMethod($callable[0], $callable[1]); if ($func->isPrivate()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': method to be wrapped is private: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': method to be wrapped is private: ' . $plainFuncName); return false; } if ($func->isProtected()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': method to be wrapped is protected: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': method to be wrapped is protected: ' . $plainFuncName); return false; } if ($func->isConstructor()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': method to be wrapped is the constructor: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': method to be wrapped is the constructor: ' . $plainFuncName); return false; } if ($func->isDestructor()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': method to be wrapped is the destructor: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': method to be wrapped is the destructor: ' . $plainFuncName); return false; } if ($func->isAbstract()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': method to be wrapped is abstract: ' . $plainFuncName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': method to be wrapped is abstract: ' . $plainFuncName); return false; } /// @todo add more checks for static vs. nonstatic? @@ -275,9 +266,9 @@ class Wrapper $func = new \ReflectionFunction($callable); } if ($func->isInternal()) { - // Note: from PHP 5.1.0 onward, we will possibly be able to use invokeargs - // instead of getparameters to fully reflect internal php functions ? - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': function to be wrapped is internal: ' . $plainFuncName); + /// @todo from PHP 5.1.0 onward, we should be able to use invokeargs instead of getparameters to fully + /// reflect internal php functions + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': function to be wrapped is internal: ' . $plainFuncName); return false; } @@ -347,12 +338,11 @@ class Wrapper /** * Given the method description given by introspection, create method signature data * + * @param array $funcDesc as generated by self::introspectFunction() + * @return array + * * @todo support better docs with multiple types separated by pipes by creating multiple signatures * (this is questionable, as it might produce a big matrix of possible signatures with many such occurrences) - * - * @param array $funcDesc as generated by self::introspectFunction() - * - * @return array */ protected function buildMethodSignatures($funcDesc) { @@ -415,27 +405,28 @@ class Wrapper /** * Creates a closure that will execute $callable - * @todo validate params? In theory all validation is left to the dispatch map... - * @todo add support for $catchWarnings * * @param $callable * @param array $extraOptions * @param string $plainFuncName * @param array $funcDesc * @return \Closure + * + * @todo validate params? In theory all validation is left to the dispatch map... + * @todo add support for $catchWarnings */ protected function buildWrapFunctionClosure($callable, $extraOptions, $plainFuncName, $funcDesc) { /** * @param Request $req + * * @return mixed */ $function = function($req) use($callable, $extraOptions, $funcDesc) { - $nameSpace = '\\PhpXmlRpc\\'; - $encoderClass = $nameSpace.'Encoder'; - $responseClass = $nameSpace.'Response'; - $valueClass = $nameSpace.'Value'; + $encoderClass = static::$namespace.'Encoder'; + $responseClass = static::$namespace.'Response'; + $valueClass = static::$namespace.'Value'; // validate number of parameters received // this should be optional really, as we assume the server does the validation @@ -463,6 +454,7 @@ class Wrapper $result = call_user_func_array($callable, $params); if (! is_a($result, $responseClass)) { + // q: why not do the same for int, float, bool, string? if ($funcDesc['returns'] == Value::$xmlrpcDateTime || $funcDesc['returns'] == Value::$xmlrpcBase64) { $result = new $valueClass($result, $funcDesc['returns']); } else { @@ -470,6 +462,9 @@ class Wrapper if (isset($extraOptions['encode_php_objs']) && $extraOptions['encode_php_objs']) { $options[] = 'encode_php_objs'; } + if (isset($extraOptions['encode_nulls']) && $extraOptions['encode_nulls']) { + $options[] = 'null_extension'; + } $result = $encoder->encode($result, $options); } @@ -484,7 +479,7 @@ class Wrapper /** * Return a name for a new function, based on $callable, insuring its uniqueness - * @param mixed $callable a php callable, or the name of an xmlrpc method + * @param mixed $callable a php callable, or the name of an xml-rpc method * @param string $newFuncName when not empty, it is used instead of the calculated version * @return string */ @@ -528,13 +523,10 @@ class Wrapper * @param string $plainFuncName * @param array $funcDesc * @return string - * - * @todo add a nice phpdoc block in the generated source */ protected function buildWrapFunctionSource($callable, $newFuncName, $extraOptions, $plainFuncName, $funcDesc) { - $namespace = '\\PhpXmlRpc\\'; - + $encodeNulls = isset($extraOptions['encode_nulls']) ? (bool)$extraOptions['encode_nulls'] : false; $encodePhpObjects = isset($extraOptions['encode_php_objs']) ? (bool)$extraOptions['encode_php_objs'] : false; $decodePhpObjects = isset($extraOptions['decode_php_objs']) ? (bool)$extraOptions['decode_php_objs'] : false; $catchWarnings = isset($extraOptions['suppress_warnings']) && $extraOptions['suppress_warnings'] ? '@' : ''; @@ -550,7 +542,7 @@ class Wrapper $parsVariations[] = $pars; } - $pars[] = "\$p[$i]"; + $pars[] = "\$params[$i]"; $i++; if ($i == $pNum) { // last allowed parameters combination @@ -570,61 +562,80 @@ class Wrapper // build body of new function - $innerCode = "\$paramCount = \$req->getNumParams();\n"; - $innerCode .= "if (\$paramCount < $minPars || \$paramCount > $maxPars) return new {$namespace}Response(0, " . PhpXmlRpc::$xmlrpcerr['incorrect_params'] . ", '" . PhpXmlRpc::$xmlrpcstr['incorrect_params'] . "');\n"; + $innerCode = " \$paramCount = \$req->getNumParams();\n"; + $innerCode .= " if (\$paramCount < $minPars || \$paramCount > $maxPars) return new " . static::$namespace . "Response(0, " . PhpXmlRpc::$xmlrpcerr['incorrect_params'] . ", '" . PhpXmlRpc::$xmlrpcstr['incorrect_params'] . "');\n"; - $innerCode .= "\$encoder = new {$namespace}Encoder();\n"; + $innerCode .= " \$encoder = new " . static::$namespace . "Encoder();\n"; if ($decodePhpObjects) { - $innerCode .= "\$p = \$encoder->decode(\$req, array('decode_php_objs'));\n"; + $innerCode .= " \$params = \$encoder->decode(\$req, array('decode_php_objs'));\n"; } else { - $innerCode .= "\$p = \$encoder->decode(\$req);\n"; + $innerCode .= " \$params = \$encoder->decode(\$req);\n"; } // since we are building source code for later use, if we are given an object instance, // we go out of our way and store a pointer to it in a static class var... if (is_array($callable) && is_object($callable[0])) { - self::$objHolder[$newFuncName] = $callable[0]; - $innerCode .= "\$obj = PhpXmlRpc\\Wrapper::\$objHolder['$newFuncName'];\n"; + static::holdObject($newFuncName, $callable[0]); + $class = get_class($callable[0]); + if ($class[0] !== '\\') { + $class = '\\' . $class; + } + $innerCode .= " /// @var $class \$obj\n"; + $innerCode .= " \$obj = PhpXmlRpc\\Wrapper::getHeldObject('$newFuncName');\n"; $realFuncName = '$obj->' . $callable[1]; } else { $realFuncName = $plainFuncName; } foreach ($parsVariations as $i => $pars) { - $innerCode .= "if (\$paramCount == " . count($pars) . ") \$retval = {$catchWarnings}$realFuncName(" . implode(',', $pars) . ");\n"; + $innerCode .= " if (\$paramCount == " . count($pars) . ") \$retVal = {$catchWarnings}$realFuncName(" . implode(',', $pars) . ");\n"; if ($i < (count($parsVariations) - 1)) - $innerCode .= "else\n"; + $innerCode .= " else\n"; } - $innerCode .= "if (is_a(\$retval, '{$namespace}Response')) return \$retval; else\n"; + $innerCode .= " if (is_a(\$retVal, '" . static::$namespace . "Response'))\n return \$retVal;\n else\n"; + /// q: why not do the same for int, float, bool, string? if ($funcDesc['returns'] == Value::$xmlrpcDateTime || $funcDesc['returns'] == Value::$xmlrpcBase64) { - $innerCode .= "return new {$namespace}Response(new {$namespace}Value(\$retval, '{$funcDesc['returns']}'));"; + $innerCode .= " return new " . static::$namespace . "Response(new " . static::$namespace . "Value(\$retVal, '{$funcDesc['returns']}'));"; } else { + $encodeOptions = array(); + if ($encodeNulls) { + $encodeOptions[] = 'null_extension'; + } if ($encodePhpObjects) { - $innerCode .= "return new {$namespace}Response(\$encoder->encode(\$retval, array('encode_php_objs')));\n"; + $encodeOptions[] = 'encode_php_objs'; + } + + if ($encodeOptions) { + $innerCode .= " return new " . static::$namespace . "Response(\$encoder->encode(\$retVal, array('" . + implode("', '", $encodeOptions) . "')));"; } else { - $innerCode .= "return new {$namespace}Response(\$encoder->encode(\$retval));\n"; + $innerCode .= " return new " . static::$namespace . "Response(\$encoder->encode(\$retVal));"; } } // shall we exclude functions returning by ref? - // if($func->returnsReference()) + // if ($func->returnsReference()) // return false; - $code = "function $newFuncName(\$req) {\n" . $innerCode . "\n}"; + $code = "/**\n * @param \PhpXmlRpc\Request \$req\n * @return \PhpXmlRpc\Response\n * @throws \\Exception\n */\n" . + "function $newFuncName(\$req)\n{\n" . $innerCode . "\n}"; return $code; } /** * Given a user-defined PHP class or php object, map its methods onto a list of - * PHP 'wrapper' functions that can be exposed as xmlrpc methods from an xmlrpc server + * PHP 'wrapper' functions that can be exposed as xml-rpc methods from an xml-rpc server * object and called from remote clients (as well as their corresponding signature info). * - * @param string|object $className the name of the class whose methods are to be exposed as xmlrpc methods, or an object instance of that class - * @param array $extraOptions see the docs for wrapPhpMethod for basic options, plus + * @param string|object $className the name of the class whose methods are to be exposed as xml-rpc methods, or an object instance of that class + * @param array $extraOptions see the docs for wrapPhpFunction for basic options, plus * - string method_type 'static', 'nonstatic', 'all' and 'auto' (default); the latter will switch between static and non-static depending on whether $className is a class name or object instance * - string method_filter a regexp used to filter methods to wrap based on their names - * - string prefix used for the names of the xmlrpc methods created. + * - string prefix used for the names of the xml-rpc methods created. * - string replace_class_name use to completely replace the class name with the prefix in the generated method names. e.g. instead of \Some\Namespace\Class.method use prefixmethod - * @return array|false false on failure + * @return array|false false on failure, or on array useable for the dispatch map + * + * @todo allow the generated function to be able to reuse an external Encoder instance instead of creating one on + * each invocation, for the case where all the generated functions will be saved as methods of a class */ public function wrapPhpClass($className, $extraOptions = array()) { @@ -658,6 +669,9 @@ class Wrapper * @param string $classMethod * @param array $extraOptions * @return string + * + * @todo php allows many more characters in identifiers than the xml-rpc spec does. We should make sure to + * replace those (while trying to make sure we are not running in collisions) */ protected function generateMethodNameForClassMethod($className, $classMethod, $extraOptions = array()) { @@ -674,49 +688,60 @@ class Wrapper } /** - * Given an xmlrpc client and a method name, register a php wrapper function - * that will call it and return results using native php types for both - * params and results. The generated php function will return a Response - * object for failed xmlrpc calls. + * Given an xml-rpc client and a method name, register a php wrapper function that will call it and return results + * using native php types for both arguments and results. The generated php function will return a Response + * object for failed xml-rpc calls. * * Known limitations: - * - server must support system.methodsignature for the wanted xmlrpc method - * - for methods that expose many signatures, only one can be picked (we - * could in principle check if signatures differ only by number of params - * and not by type, but it would be more complication than we can spare time) - * - nested xmlrpc params: the caller of the generated php function has to - * encode on its own the params passed to the php function if these are structs - * or arrays whose (sub)members include values of type datetime or base64 + * - server must support system.methodSignature for the target xml-rpc method + * - for methods that expose many signatures, only one can be picked (we could in principle check if signatures + * differ only by number of params and not by type, but it would be more complication than we can spare time for) + * - nested xml-rpc params: the caller of the generated php function has to encode on its own the params passed to + * the php function if these are structs or arrays whose (sub)members include values of type base64 * - * Notes: the connection properties of the given client will be copied - * and reused for the connection used during the call to the generated - * php function. - * Calling the generated php function 'might' be slow: a new xmlrpc client - * is created on every invocation and an xmlrpc-connection opened+closed. - * An extra 'debug' param is appended to param list of xmlrpc method, useful + * Notes: the connection properties of the given client will be copied and reused for the connection used during + * the call to the generated php function. + * Calling the generated php function 'might' be slightly slow: a new xml-rpc client is created on every invocation + * and an xmlrpc-connection opened+closed. + * An extra 'debug' argument, defaulting to 0, is appended to the argument list of the generated function, useful * for debugging purposes. * + * @param Client $client an xml-rpc client set up correctly to communicate with target server + * @param string $methodName the xml-rpc method to be mapped to a php function + * @param array $extraOptions array of options that specify conversion details. Valid options include + * - integer signum the index of the method signature to use in mapping (if + * method exposes many sigs) + * - integer timeout timeout (in secs) to be used when executing function/calling remote method + * - string protocol 'http' (default), 'http11', 'https', 'h2' or 'h2c' + * - string new_function_name the name of php function to create, when return_source is used. + * If unspecified, lib will pick an appropriate name + * - string return_source if true return php code w. function definition instead of + * the function itself (closure) + * - bool encode_nulls if true, use `` elements instead of empty string xml-rpc + * values for php null values + * - bool encode_php_objs let php objects be sent to server using the 'improved' xml-rpc + * notation, so server can deserialize them as php objects + * - bool decode_php_objs --- WARNING !!! possible security hazard. only use it with + * trusted servers --- + * - mixed return_on_fault a php value to be returned when the xml-rpc call fails/returns + * a fault response (by default the Response object is returned + * in this case). If a string is used, '%faultCode%' and + * '%faultString%' tokens will be substituted with actual error values + * - bool throw_on_fault if true, throw an exception instead of returning a Response + * in case of errors/faults; + * if a string, do the same and assume it is the exception class to throw + * - bool debug set it to 1 or 2 to see debug results of querying server for + * method synopsis + * - int simple_client_copy set it to 1 to have a lightweight copy of the $client object + * made in the generated code (only used when return_source = true) + * @return \Closure|string[]|false false on failure, closure by default and array for return_source = true + * * @todo allow caller to give us the method signature instead of querying for it, or just say 'skip it' * @todo if we can not retrieve method signature, create a php function with varargs - * @todo allow the created function to throw exceptions on method calls failures * @todo if caller did not specify a specific sig, shall we support all of them? * It might be hard (hence slow) to match based on type and number of arguments... - * - * @param Client $client an xmlrpc client set up correctly to communicate with target server - * @param string $methodName the xmlrpc method to be mapped to a php function - * @param array $extraOptions array of options that specify conversion details. Valid options include - * - integer signum the index of the method signature to use in mapping (if method exposes many sigs) - * - integer timeout timeout (in secs) to be used when executing function/calling remote method - * - string protocol 'http' (default), 'http11' or 'https' - * - string new_function_name the name of php function to create, when return_source is used. If unspecified, lib will pick an appropriate name - * - string return_source if true return php code w. function definition instead of function itself (closure) - * - bool encode_php_objs let php objects be sent to server using the 'improved' xmlrpc notation, so server can deserialize them as php objects - * - bool decode_php_objs --- WARNING !!! possible security hazard. only use it with trusted servers --- - * - mixed return_on_fault a php value to be returned when the xmlrpc call fails/returns a fault response (by default the Response object is returned in this case). If a string is used, '%faultCode%' and '%faultString%' tokens will be substituted with actual error values - * - bool debug set it to 1 or 2 to see debug results of querying server for method synopsis - * - int simple_client_copy set it to 1 to have a lightweight copy of the $client object made in the generated code (only used when return_source = true) - * - * @return \closure|string[]|false false on failure, closure by default and array for return_source = true + * @todo when wrapping methods without obj rebuilding, use return_type = 'phpvals' (faster) + * @todo allow creating functions which have an extra `$debug=0` parameter */ public function wrapXmlrpcMethod($client, $methodName, $extraOptions = array()) { @@ -739,10 +764,6 @@ class Wrapper $newFuncName = $this->newFunctionName($methodName, $newFuncName, $extraOptions); $results = $this->buildWrapMethodSource($client, $methodName, $extraOptions, $newFuncName, $mSig, $mDesc); - /* was: $results = $this->build_remote_method_wrapper_code($client, $methodName, - $newFuncName, $mSig, $mDesc, $timeout, $protocol, $simpleClientCopy, - $prefix, $decodePhpObjects, $encodePhpObjects, $decodeFault, - $faultResponse, $namespace);*/ $results['function'] = $newFuncName; @@ -751,7 +772,7 @@ class Wrapper } /** - * Retrieves an xmlrpc method signature from a server which supports system.methodSignature + * Retrieves an xml-rpc method signature from a server which supports system.methodSignature * @param Client $client * @param string $methodName * @param array $extraOptions @@ -759,10 +780,9 @@ class Wrapper */ protected function retrieveMethodSignature($client, $methodName, array $extraOptions = array()) { - $namespace = '\\PhpXmlRpc\\'; - $reqClass = $namespace . 'Request'; - $valClass = $namespace . 'Value'; - $decoderClass = $namespace . 'Encoder'; + $reqClass = static::$namespace . 'Request'; + $valClass = static::$namespace . 'Value'; + $decoderClass = static::$namespace . 'Encoder'; $debug = isset($extraOptions['debug']) ? ($extraOptions['debug']) : 0; $timeout = isset($extraOptions['timeout']) ? (int)$extraOptions['timeout'] : 0; @@ -770,22 +790,26 @@ class Wrapper $sigNum = isset($extraOptions['signum']) ? (int)$extraOptions['signum'] : 0; $req = new $reqClass('system.methodSignature'); - $req->addparam(new $valClass($methodName)); + $req->addParam(new $valClass($methodName)); + $origDebug = $client->getOption(Client::OPT_DEBUG); $client->setDebug($debug); + /// @todo move setting of timeout, protocol to outside the send() call $response = $client->send($req, $timeout, $protocol); + $client->setDebug($origDebug); if ($response->faultCode()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': could not retrieve method signature from remote server for method ' . $methodName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': could not retrieve method signature from remote server for method ' . $methodName); return false; } $mSig = $response->value(); - if ($client->return_type != 'phpvals') { + /// @todo what about return xml? + if ($client->getOption(Client::OPT_RETURN_TYPE) != 'phpvals') { $decoder = new $decoderClass(); $mSig = $decoder->decode($mSig); } if (!is_array($mSig) || count($mSig) <= $sigNum) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': could not retrieve method signature nr.' . $sigNum . ' from remote server for method ' . $methodName); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': could not retrieve method signature nr.' . $sigNum . ' from remote server for method ' . $methodName); return false; } @@ -800,9 +824,8 @@ class Wrapper */ protected function retrieveMethodHelp($client, $methodName, array $extraOptions = array()) { - $namespace = '\\PhpXmlRpc\\'; - $reqClass = $namespace . 'Request'; - $valClass = $namespace . 'Value'; + $reqClass = static::$namespace . 'Request'; + $valClass = static::$namespace . 'Value'; $debug = isset($extraOptions['debug']) ? ($extraOptions['debug']) : 0; $timeout = isset($extraOptions['timeout']) ? (int)$extraOptions['timeout'] : 0; @@ -811,13 +834,16 @@ class Wrapper $mDesc = ''; $req = new $reqClass('system.methodHelp'); - $req->addparam(new $valClass($methodName)); + $req->addParam(new $valClass($methodName)); + $origDebug = $client->getOption(Client::OPT_DEBUG); $client->setDebug($debug); + /// @todo move setting of timeout, protocol to outside the send() call $response = $client->send($req, $timeout, $protocol); + $client->setDebug($origDebug); if (!$response->faultCode()) { $mDesc = $response->value(); - if ($client->return_type != 'phpvals') { - $mDesc = $mDesc->scalarval(); + if ($client->getOption(Client::OPT_RETURN_TYPE) != 'phpvals') { + $mDesc = $mDesc->scalarVal(); } } @@ -827,7 +853,7 @@ class Wrapper /** * @param Client $client * @param string $methodName - * @param array $extraOptions + * @param array $extraOptions @see wrapXmlrpcMethod * @param array $mSig * @return \Closure * @@ -843,23 +869,29 @@ class Wrapper $protocol = isset($extraOptions['protocol']) ? $extraOptions['protocol'] : ''; $encodePhpObjects = isset($extraOptions['encode_php_objs']) ? (bool)$extraOptions['encode_php_objs'] : false; $decodePhpObjects = isset($extraOptions['decode_php_objs']) ? (bool)$extraOptions['decode_php_objs'] : false; - if (isset($extraOptions['return_on_fault'])) { + $encodeNulls = isset($extraOptions['encode_nulls']) ? (bool)$extraOptions['encode_nulls'] : false; + $throwFault = false; + $decodeFault = false; + $faultResponse = null; + if (isset($extraOptions['throw_on_fault'])) { + $throwFault = $extraOptions['throw_on_fault']; + } else if (isset($extraOptions['return_on_fault'])) { $decodeFault = true; $faultResponse = $extraOptions['return_on_fault']; - } else { - $decodeFault = false; } - $namespace = '\\PhpXmlRpc\\'; - $reqClass = $namespace . 'Request'; - $encoderClass = $namespace . 'Encoder'; - $valueClass = $namespace . 'Value'; + $reqClass = static::$namespace . 'Request'; + $encoderClass = static::$namespace . 'Encoder'; + $valueClass = static::$namespace . 'Value'; $encoder = new $encoderClass(); $encodeOptions = array(); if ($encodePhpObjects) { $encodeOptions[] = 'encode_php_objs'; } + if ($encodeNulls) { + $encodeOptions[] = 'null_extension'; + } $decodeOptions = array(); if ($decodePhpObjects) { $decodeOptions[] = 'decode_php_objs'; @@ -876,7 +908,7 @@ class Wrapper } $xmlrpcArgs = array(); - foreach($currentArgs as $i => $arg) { + foreach ($currentArgs as $i => $arg) { if ($i == $maxArgs) { break; } @@ -884,8 +916,8 @@ class Wrapper if ($pType == 'i4' || $pType == 'i8' || $pType == 'int' || $pType == 'boolean' || $pType == 'double' || $pType == 'string' || $pType == 'dateTime.iso8601' || $pType == 'base64' || $pType == 'null' ) { - // by building directly xmlrpc values when type is known and scalar (instead of encode() calls), - // we make sure to honour the xmlrpc signature + // by building directly xml-rpc values when type is known and scalar (instead of encode() calls), + // we make sure to honour the xml-rpc signature $xmlrpcArgs[] = new $valueClass($arg, $pType); } else { $xmlrpcArgs[] = $encoder->encode($arg, $encodeOptions); @@ -894,10 +926,16 @@ class Wrapper $req = new $reqClass($methodName, $xmlrpcArgs); // use this to get the maximum decoding flexibility - $clientClone->return_type = 'xmlrpcvals'; + $clientClone->setOption(Client::OPT_RETURN_TYPE, 'xmlrpcvals'); $resp = $clientClone->send($req, $timeout, $protocol); if ($resp->faultcode()) { - if ($decodeFault) { + if ($throwFault) { + if (is_string($throwFault)) { + throw new $throwFault($resp->faultString(), $resp->faultCode()); + } else { + throw new \PhpXmlRpc\Exception($resp->faultString(), $resp->faultCode()); + } + } else if ($decodeFault) { if (is_string($faultResponse) && ((strpos($faultResponse, '%faultCode%') !== false) || (strpos($faultResponse, '%faultString%') !== false))) { $faultResponse = str_replace(array('%faultCode%', '%faultString%'), @@ -916,9 +954,11 @@ class Wrapper } /** + * @internal made public just for Debugger usage + * * @param Client $client * @param string $methodName - * @param array $extraOptions + * @param array $extraOptions @see wrapXmlrpcMethod * @param string $newFuncName * @param array $mSig * @param string $mDesc @@ -930,23 +970,24 @@ class Wrapper $protocol = isset($extraOptions['protocol']) ? $extraOptions['protocol'] : ''; $encodePhpObjects = isset($extraOptions['encode_php_objs']) ? (bool)$extraOptions['encode_php_objs'] : false; $decodePhpObjects = isset($extraOptions['decode_php_objs']) ? (bool)$extraOptions['decode_php_objs'] : false; + $encodeNulls = isset($extraOptions['encode_nulls']) ? (bool)$extraOptions['encode_nulls'] : false; $clientCopyMode = isset($extraOptions['simple_client_copy']) ? (int)($extraOptions['simple_client_copy']) : 0; $prefix = isset($extraOptions['prefix']) ? $extraOptions['prefix'] : 'xmlrpc'; - if (isset($extraOptions['return_on_fault'])) { + $throwFault = false; + $decodeFault = false; + $faultResponse = null; + if (isset($extraOptions['throw_on_fault'])) { + $throwFault = $extraOptions['throw_on_fault']; + } else if (isset($extraOptions['return_on_fault'])) { $decodeFault = true; $faultResponse = $extraOptions['return_on_fault']; - } else { - $decodeFault = false; - $faultResponse = ''; } - $namespace = '\\PhpXmlRpc\\'; - - $code = "function $newFuncName ("; + $code = "function $newFuncName("; if ($clientCopyMode < 2) { // client copy mode 0 or 1 == full / partial client copy in emitted code $verbatimClientCopy = !$clientCopyMode; - $innerCode = $this->buildClientWrapperCode($client, $verbatimClientCopy, $prefix, $namespace); + $innerCode = ' ' . str_replace("\n", "\n ", $this->buildClientWrapperCode($client, $verbatimClientCopy, $prefix, static::$namespace)); $innerCode .= "\$client->setDebug(\$debug);\n"; $this_ = ''; } else { @@ -954,17 +995,18 @@ class Wrapper $innerCode = ''; $this_ = 'this->'; } - $innerCode .= "\$req = new {$namespace}Request('$methodName');\n"; + $innerCode .= " \$req = new " . static::$namespace . "Request('$methodName');\n"; if ($mDesc != '') { // take care that PHP comment is not terminated unwillingly by method description - $mDesc = "/**\n* " . str_replace('*/', '* /', $mDesc) . "\n"; + /// @todo according to the spec, method desc can have html in it. We should run it through strip_tags... + $mDesc = "/**\n * " . str_replace(array("\n", '*/'), array("\n * ", '* /'), $mDesc) . "\n"; } else { - $mDesc = "/**\nFunction $newFuncName\n"; + $mDesc = "/**\n * Function $newFuncName.\n"; } // param parsing - $innerCode .= "\$encoder = new {$namespace}Encoder();\n"; + $innerCode .= " \$encoder = new " . static::$namespace . "Encoder();\n"; $plist = array(); $pCount = count($mSig); for ($i = 1; $i < $pCount; $i++) { @@ -973,63 +1015,89 @@ class Wrapper if ($pType == 'i4' || $pType == 'i8' || $pType == 'int' || $pType == 'boolean' || $pType == 'double' || $pType == 'string' || $pType == 'dateTime.iso8601' || $pType == 'base64' || $pType == 'null' ) { - // only build directly xmlrpc values when type is known and scalar - $innerCode .= "\$p$i = new {$namespace}Value(\$p$i, '$pType');\n"; + // only build directly xml-rpc values when type is known and scalar + $innerCode .= " \$p$i = new " . static::$namespace . "Value(\$p$i, '$pType');\n"; } else { - if ($encodePhpObjects) { - $innerCode .= "\$p$i = \$encoder->encode(\$p$i, array('encode_php_objs'));\n"; + if ($encodePhpObjects || $encodeNulls) { + $encOpts = array(); + if ($encodePhpObjects) { + $encOpts[] = 'encode_php_objs'; + } + if ($encodeNulls) { + $encOpts[] = 'null_extension'; + } + + $innerCode .= " \$p$i = \$encoder->encode(\$p$i, array( '" . implode("', '", $encOpts) . "'));\n"; } else { - $innerCode .= "\$p$i = \$encoder->encode(\$p$i);\n"; + $innerCode .= " \$p$i = \$encoder->encode(\$p$i);\n"; } } - $innerCode .= "\$req->addparam(\$p$i);\n"; - $mDesc .= '* @param ' . $this->xmlrpc2PhpType($pType) . " \$p$i\n"; + $innerCode .= " \$req->addParam(\$p$i);\n"; + $mDesc .= " * @param " . $this->xmlrpc2PhpType($pType) . " \$p$i\n"; } if ($clientCopyMode < 2) { - $plist[] = '$debug=0'; - $mDesc .= "* @param int \$debug when 1 (or 2) will enable debugging of the underlying {$prefix} call (defaults to 0)\n"; + $plist[] = '$debug = 0'; + $mDesc .= " * @param int \$debug when 1 (or 2) will enable debugging of the underlying {$prefix} call (defaults to 0)\n"; } $plist = implode(', ', $plist); - $mDesc .= '* @return {$namespace}Response|' . $this->xmlrpc2PhpType($mSig[0]) . " (an {$namespace}Response obj instance if call fails)\n*/\n"; + $mDesc .= ' * @return ' . $this->xmlrpc2PhpType($mSig[0]); + if ($throwFault) { + $mDesc .= "\n * @throws " . (is_string($throwFault) ? $throwFault : '\\PhpXmlRpc\\Exception'); + } else if ($decodeFault) { + $mDesc .= '|' . gettype($faultResponse) . " (a " . gettype($faultResponse) . " if call fails)"; + } else { + $mDesc .= '|' . static::$namespace . "Response (a " . static::$namespace . "Response obj instance if call fails)"; + } + $mDesc .= "\n */\n"; - $innerCode .= "\$res = \${$this_}client->send(\$req, $timeout, '$protocol');\n"; - if ($decodeFault) { + /// @todo move setting of timeout, protocol to outside the send() call + $innerCode .= " \$res = \${$this_}client->send(\$req, $timeout, '$protocol');\n"; + if ($throwFault) { + if (!is_string($throwFault)) { + $throwFault = '\\PhpXmlRpc\\Exception'; + } + $respCode = "throw new $throwFault(\$res->faultString(), \$res->faultCode())"; + } else if ($decodeFault) { if (is_string($faultResponse) && ((strpos($faultResponse, '%faultCode%') !== false) || (strpos($faultResponse, '%faultString%') !== false))) { - $respCode = "str_replace(array('%faultCode%', '%faultString%'), array(\$res->faultCode(), \$res->faultString()), '" . str_replace("'", "''", $faultResponse) . "')"; + $respCode = "return str_replace(array('%faultCode%', '%faultString%'), array(\$res->faultCode(), \$res->faultString()), '" . str_replace("'", "''", $faultResponse) . "')"; } else { - $respCode = var_export($faultResponse, true); + $respCode = 'return ' . var_export($faultResponse, true); } } else { - $respCode = '$res'; + $respCode = 'return $res'; } if ($decodePhpObjects) { - $innerCode .= "if (\$res->faultcode()) return $respCode; else return \$encoder->decode(\$res->value(), array('decode_php_objs'));"; + $innerCode .= " if (\$res->faultCode()) $respCode; else return \$encoder->decode(\$res->value(), array('decode_php_objs'));"; } else { - $innerCode .= "if (\$res->faultcode()) return $respCode; else return \$encoder->decode(\$res->value());"; + $innerCode .= " if (\$res->faultCode()) $respCode; else return \$encoder->decode(\$res->value());"; } - $code = $code . $plist . ") {\n" . $innerCode . "\n}\n"; + $code = $code . $plist . ")\n{\n" . $innerCode . "\n}\n"; return array('source' => $code, 'docstring' => $mDesc); } /** - * Similar to wrapXmlrpcMethod, but will generate a php class that wraps - * all xmlrpc methods exposed by the remote server as own methods. - * For more details see wrapXmlrpcMethod. + * Similar to wrapXmlrpcMethod, but will generate a php class that wraps all xml-rpc methods exposed by the remote + * server as own methods. + * For a slimmer alternative, see the code in demo/client/proxy.php. + * Note that unlike wrapXmlrpcMethod, we always have to generate php code here. Since php 7 anon classes exist, but + * we do not support them yet... * - * For a slimmer alternative, see the code in demo/client/proxy.php - * - * Note that unlike wrapXmlrpcMethod, we always have to generate php code here. It seems that php 7 will have anon classes... + * @see wrapXmlrpcMethod for more details. * * @param Client $client the client obj all set to query the desired server - * @param array $extraOptions list of options for wrapped code. See the ones from wrapXmlrpcMethod plus - * - string method_filter regular expression - * - string new_class_name - * - string prefix - * - bool simple_client_copy set it to true to avoid copying all properties of $client into the copy made in the new class + * @param array $extraOptions list of options for wrapped code. See the ones from wrapXmlrpcMethod, plus + * - string method_filter regular expression + * - string new_class_name + * - string prefix + * - bool simple_client_copy set it to true to avoid copying all properties of $client into the copy made in the new class + * @return string|array|false false on error, the name of the created class if all ok or an array with code, class name and comments (if the appropriate option is set in extra_options) * - * @return mixed false on error, the name of the created class if all ok or an array with code, class name and comments (if the appropriate option is set in extra_options) + * @todo add support for anonymous classes in the 'buildIt' case for php > 7 + * @todo add method setDebug() to new class, to enable/disable debugging + * @todo optimization - move the generated Encoder instance to be a property of the created class, instead of creating + * it on every generated method invocation */ public function wrapXmlrpcServer($client, $extraOptions = array()) { @@ -1037,126 +1105,154 @@ class Wrapper $timeout = isset($extraOptions['timeout']) ? (int)$extraOptions['timeout'] : 0; $protocol = isset($extraOptions['protocol']) ? $extraOptions['protocol'] : ''; $newClassName = isset($extraOptions['new_class_name']) ? $extraOptions['new_class_name'] : ''; + $encodeNulls = isset($extraOptions['encode_nulls']) ? (bool)$extraOptions['encode_nulls'] : false; $encodePhpObjects = isset($extraOptions['encode_php_objs']) ? (bool)$extraOptions['encode_php_objs'] : false; $decodePhpObjects = isset($extraOptions['decode_php_objs']) ? (bool)$extraOptions['decode_php_objs'] : false; $verbatimClientCopy = isset($extraOptions['simple_client_copy']) ? !($extraOptions['simple_client_copy']) : true; + $throwOnFault = isset($extraOptions['throw_on_fault']) ? (bool)$extraOptions['throw_on_fault'] : false; $buildIt = isset($extraOptions['return_source']) ? !($extraOptions['return_source']) : true; $prefix = isset($extraOptions['prefix']) ? $extraOptions['prefix'] : 'xmlrpc'; - $namespace = '\\PhpXmlRpc\\'; - $reqClass = $namespace . 'Request'; - $decoderClass = $namespace . 'Encoder'; + $reqClass = static::$namespace . 'Request'; + $decoderClass = static::$namespace . 'Encoder'; + // retrieve the list of methods $req = new $reqClass('system.listMethods'); + /// @todo move setting of timeout, protocol to outside the send() call $response = $client->send($req, $timeout, $protocol); if ($response->faultCode()) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': could not retrieve method list from remote server'); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': could not retrieve method list from remote server'); return false; + } + $mList = $response->value(); + /// @todo what about return_type = xml? + if ($client->getOption(Client::OPT_RETURN_TYPE) != 'phpvals') { + $decoder = new $decoderClass(); + $mList = $decoder->decode($mList); + } + if (!is_array($mList) || !count($mList)) { + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': could not retrieve meaningful method list from remote server'); + + return false; + } + + // pick a suitable name for the new function, avoiding collisions + if ($newClassName != '') { + $xmlrpcClassName = $newClassName; } else { - $mList = $response->value(); - if ($client->return_type != 'phpvals') { - $decoder = new $decoderClass(); - $mList = $decoder->decode($mList); - } - if (!is_array($mList) || !count($mList)) { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': could not retrieve meaningful method list from remote server'); + /// @todo direct access to $client->server is now deprecated + $xmlrpcClassName = $prefix . '_' . preg_replace(array('/\./', '/[^a-zA-Z0-9_\x7f-\xff]/'), array('_', ''), + $client->server) . '_client'; + } + while ($buildIt && class_exists($xmlrpcClassName)) { + $xmlrpcClassName .= 'x'; + } - return false; - } else { - // pick a suitable name for the new function, avoiding collisions - if ($newClassName != '') { - $xmlrpcClassName = $newClassName; - } else { - $xmlrpcClassName = $prefix . '_' . preg_replace(array('/\./', '/[^a-zA-Z0-9_\x7f-\xff]/'), - array('_', ''), $client->server) . '_client'; - } - while ($buildIt && class_exists($xmlrpcClassName)) { - $xmlrpcClassName .= 'x'; - } + $source = "class $xmlrpcClassName\n{\n public \$client;\n\n"; + $source .= " function __construct()\n {\n"; + $source .= ' ' . str_replace("\n", "\n ", $this->buildClientWrapperCode($client, $verbatimClientCopy, $prefix, static::$namespace)); + $source .= "\$this->client = \$client;\n }\n\n"; + $opts = array( + 'return_source' => true, + 'simple_client_copy' => 2, // do not produce code to copy the client object + 'timeout' => $timeout, + 'protocol' => $protocol, + 'encode_nulls' => $encodeNulls, + 'encode_php_objs' => $encodePhpObjects, + 'decode_php_objs' => $decodePhpObjects, + 'throw_on_fault' => $throwOnFault, + 'prefix' => $prefix, + ); + + /// @todo build phpdoc for class definition, too + foreach ($mList as $mName) { + if ($methodFilter == '' || preg_match($methodFilter, $mName)) { + /// @todo this will fail if server exposes 2 methods called f.e. do.something and do_something + $opts['new_function_name'] = preg_replace(array('/\./', '/[^a-zA-Z0-9_\x7f-\xff]/'), + array('_', ''), $mName); + $methodWrap = $this->wrapXmlrpcMethod($client, $mName, $opts); + if ($methodWrap) { + if ($buildIt) { + $source .= $methodWrap['source'] . "\n"; - /// @todo add function setdebug() to new class, to enable/disable debugging - $source = "class $xmlrpcClassName\n{\npublic \$client;\n\n"; - $source .= "function __construct()\n{\n"; - $source .= $this->buildClientWrapperCode($client, $verbatimClientCopy, $prefix, $namespace); - $source .= "\$this->client = \$client;\n}\n\n"; - $opts = array( - 'return_source' => true, - 'simple_client_copy' => 2, // do not produce code to copy the client object - 'timeout' => $timeout, - 'protocol' => $protocol, - 'encode_php_objs' => $encodePhpObjects, - 'decode_php_objs' => $decodePhpObjects, - 'prefix' => $prefix, - ); - /// @todo build phpdoc for class definition, too - foreach ($mList as $mName) { - if ($methodFilter == '' || preg_match($methodFilter, $mName)) { - // note: this will fail if server exposes 2 methods called f.e. do.something and do_something - $opts['new_function_name'] = preg_replace(array('/\./', '/[^a-zA-Z0-9_\x7f-\xff]/'), - array('_', ''), $mName); - $methodWrap = $this->wrapXmlrpcMethod($client, $mName, $opts); - if ($methodWrap) { - if (!$buildIt) { - $source .= $methodWrap['docstring']; - } - $source .= $methodWrap['source'] . "\n"; - } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': will not create class method to wrap remote method ' . $mName); - } - } - } - $source .= "}\n"; - if ($buildIt) { - $allOK = 0; - eval($source . '$allOK=1;'); - if ($allOK) { - return $xmlrpcClassName; } else { - $this->getLogger()->errorLog('XML-RPC: ' . __METHOD__ . ': could not create class ' . $xmlrpcClassName . ' to wrap remote server ' . $client->server); - return false; + $source .= ' ' . str_replace("\n", "\n ", $methodWrap['docstring']); + $source .= str_replace("\n", "\n ", $methodWrap['source']). "\n"; } + } else { - return array('class' => $xmlrpcClassName, 'code' => $source, 'docstring' => ''); + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': will not create class method to wrap remote method ' . $mName); } } } + $source .= "}\n"; + if ($buildIt) { + $allOK = 0; + eval($source . '$allOK=1;'); + if ($allOK) { + return $xmlrpcClassName; + } else { + /// @todo direct access to $client->server is now deprecated + $this->getLogger()->error('XML-RPC: ' . __METHOD__ . ': could not create class ' . $xmlrpcClassName . + ' to wrap remote server ' . $client->server); + return false; + } + } else { + return array('class' => $xmlrpcClassName, 'code' => $source, 'docstring' => ''); + } } /** * Given necessary info, generate php code that will build a client object just like the given one. - * Take care that no full checking of input parameters is done to ensure that - * valid php code is emitted. + * Take care that no full checking of input parameters is done to ensure that valid php code is emitted. * @param Client $client - * @param bool $verbatimClientCopy when true, copy all of the state of the client, except for 'debug' and 'return_type' + * @param bool $verbatimClientCopy when true, copy the whole options of the client, except for 'debug' and 'return_type' * @param string $prefix used for the return_type of the created client * @param string $namespace - * * @return string */ - protected function buildClientWrapperCode($client, $verbatimClientCopy, $prefix = 'xmlrpc', $namespace = '\\PhpXmlRpc\\' ) + protected function buildClientWrapperCode($client, $verbatimClientCopy, $prefix = 'xmlrpc', $namespace = '\\PhpXmlRpc\\') { - $code = "\$client = new {$namespace}Client('" . str_replace("'", "\'", $client->path) . - "', '" . str_replace("'", "\'", $client->server) . "', $client->port);\n"; + $code = "\$client = new {$namespace}Client('" . str_replace(array("\\", "'"), array("\\\\", "\'"), $client->getUrl()) . + "');\n"; // copy all client fields to the client that will be generated runtime // (this provides for future expansion or subclassing of client obj) if ($verbatimClientCopy) { - foreach ($client as $fld => $val) { - /// @todo in php 8.0, curl handles became objects, but they have no __set_state, thus var_export will - /// fail for xmlrpc_curl_handle. So we disabled copying it. - /// We should examine in depth if this change can have side effects - at first sight if the - /// client's curl handle is not set, all curl options are (re)set on each http call, so there - /// should be no loss of state... - if ($fld != 'debug' && $fld != 'return_type' && $fld != 'xmlrpc_curl_handle') { + foreach ($client->getOptions() as $opt => $val) { + if ($opt != 'debug' && $opt != 'return_type') { $val = var_export($val, true); - $code .= "\$client->$fld = $val;\n"; + $code .= "\$client->setOption('$opt', $val);\n"; } } } // only make sure that client always returns the correct data type - $code .= "\$client->return_type = '{$prefix}vals';\n"; - //$code .= "\$client->setDebug(\$debug);\n"; + $code .= "\$client->setOption(\PhpXmlRpc\Client::OPT_RETURN_TYPE, '{$prefix}vals');\n"; return $code; } + + /** + * @param string $index + * @param object $object + * @return void + */ + public static function holdObject($index, $object) + { + self::$objHolder[$index] = $object; + } + + /** + * @param string $index + * @return object + * @throws ValueErrorException + */ + public static function getHeldObject($index) + { + if (isset(self::$objHolder[$index])) { + return self::$objHolder[$index]; + } + + throw new ValueErrorException("No object held for index '$index'"); + } } diff --git a/lib/phpxmlrpc/readme_moodle.txt b/lib/phpxmlrpc/readme_moodle.txt index 2591bde1a59..ea0922deefe 100644 --- a/lib/phpxmlrpc/readme_moodle.txt +++ b/lib/phpxmlrpc/readme_moodle.txt @@ -16,7 +16,7 @@ To update: - Edit this file and update the release and commit details below. - Edit lib/thirdpartylibs.xml and update the information details too. -Current version imported: 4.8.1 (c74cc31) +Current version imported: 4.10.1 (ac18e31) Local changes: * 2023/01/26 - Server.php and Value.php files have minor changes for PHP 8.2 compatibility. See MDL-76415 for more details. diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index c3479d2eb8f..8bf025b8ac7 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -633,7 +633,7 @@ All rights reserved. phpxmlrpc phpxmlrpc A php library for building xml-rpc clients and servers - 4.8.1 + 4.10.1 BSD 3-Clause https://github.com/gggeek/phpxmlrpc diff --git a/mnet/lib.php b/mnet/lib.php index 20a14a59c36..d106ce18418 100644 --- a/mnet/lib.php +++ b/mnet/lib.php @@ -62,20 +62,20 @@ function mnet_get_public_key($uri, $application=null) { new \PhpXmlRpc\Value($application->name), ]; $request = new \PhpXmlRpc\Request('system/keyswap', $params); - $request->request_charset_encoding = 'utf-8'; // Let's create a client to handle the request and the response easily. $client = new \PhpXmlRpc\Client($uri . $application->xmlrpc_server_url); - $client->setUseCurl(\PhpXmlRpc\Client::USE_CURL_ALWAYS); - $client->setUserAgent('Moodle'); + $client->setOption('use_curl', \PhpXmlRpc\Client::USE_CURL_ALWAYS); + $client->setOption('user_agent', 'Moodle'); $client->return_type = 'xmlrpcvals'; // This (keyswap) is not encrypted, so we can expect proper xmlrpc in this case. - $client->request_charset_encoding = 'utf-8'; + $client->setOption('request_charset_encoding', 'utf-8'); + $client->setOption('accepted_charset_encodings', ['utf-8']); // TODO: Link this to DEBUG DEVELOPER or with MNET debugging... // $client->setdebug(1); // See a good number of complete requests and responses. - $client->setSSLVerifyHost(0); - $client->setSSLVerifyPeer(false); + $client->setOption('verifyhost', 0); + $client->setOption('verifypeer', false); // TODO: It's curious that this service (keyswap) that needs // a custom client, different from mnet_xmlrpc_client, because