1<?php
2/**
3 * @package Habari
4 *
5 */
6
7/**
8 * Holds the basic RemoteRequest functionality.
9 *
10 * Interface for Request Processors. RemoteRequest uses a RequestProcessor to
11 * do the actual work.
12 *
13 */
14abstract class RequestProcessor
15{
16
17	protected $response_body = '';
18	protected $response_headers = array();
19
20	protected $executed = false;
21
22	protected $can_followlocation = true;
23
24	abstract public function execute( $method, $url, $headers, $body, $config );
25
26	public function get_response_body ( ) {
27
28		if ( !$this->executed ) {
29			throw new Exception( _t( 'Unable to get response body. Request did not yet execute.' ) );
30		}
31
32		return $this->response_body;
33
34	}
35	public function get_response_headers ( ) {
36
37		if ( !$this->executed ) {
38			throw new Exception( _t( 'Unable to get response headers. Request did not yet execute.' ) );
39		}
40
41		return $this->response_headers;
42
43	}
44}
45
46/**
47 * Generic class to make outgoing HTTP requests.
48 *
49 */
50class RemoteRequest
51{
52	private $method = 'GET';
53	private $url;
54	private $params = array();
55	private $headers = array(
56		'Accept' => '*/*',
57	);
58	private $postdata = array();
59	private $files = array();
60	private $body = '';
61	private $processor = null;
62	private $executed = false;
63
64	private $response_body = '';
65	private $response_headers = '';
66
67	private $user_agent = 'Habari';
68
69	// Array of adapter configuration options
70	private $config = array(
71		'connect_timeout'   => 30,
72		'timeout'           => 180,
73		'buffer_size'       => 16384,
74		'follow_redirects'  => true,
75		'max_redirects'     => 5,
76
77		// These are configured in the main config file
78		'proxy' => array(
79			'server' => null,
80			'port' => null,
81			'username' => null,
82			'password' => null,
83			'auth_type' => 'basic',
84			'exceptions' => array(),
85			'type' => 'http',		// http is the default, 'socks' for a SOCKS5 proxy
86		),
87
88		// TODO: These don't apply to SocketRequestProcessor yet
89		'ssl' => array(
90			'verify_peer' => true,
91			'verify_host' => 2,		// 1 = check CN of ssl cert, 2 = check and verify @see http://php.net/curl_setopt
92			'cafile' => null,
93			'capath' => null,
94			'local_cert' => null,
95			'passphrase' => null,
96
97		),
98	);
99
100	/**
101	 * @param string $url URL to request
102	 * @param string $method Request method to use (default 'GET')
103	 * @param int $timeuot Timeout in seconds (default 180)
104	 */
105	public function __construct( $url, $method = 'GET', $timeout = 180 )
106	{
107		$this->method = strtoupper( $method );
108		$this->url = $url;
109		$this->set_timeout( $timeout );
110
111		// load the proxy configuration, if it exists
112		$default = new stdClass();
113		$proxy = Config::get( 'proxy', $default );
114		if ( isset( $proxy->server ) ) {
115			$this->set_config( array( 'proxy' => (array)$proxy ) );
116		}
117
118		// populate the default proxy exceptions list, since we can't up there
119		$this->config['proxy']['exceptions'] = array_merge( $this->config['proxy']['exceptions'], array(
120			'localhost',
121			'127.0.0.1',
122			'::1',		// ipv6 localhost
123		) );
124
125		// these next two could be duplicates of 'localhost' and 127.0.0.1 / ::1 if you're on localhost - that's ok
126		if ( isset( $_SERVER['SERVER_NAME'] ) ) {
127			$this->config['proxy']['exceptions'][] = $_SERVER['SERVER_NAME'];
128		}
129
130		if ( isset( $_SERVER['SERVER_ADDR'] ) ) {
131			$this->config['proxy']['exceptions'][] = $_SERVER['SERVER_ADDR'];
132		}
133
134		$this->user_agent .= '/' . Version::HABARI_VERSION;
135		$this->add_header( array( 'User-Agent' => $this->user_agent ) );
136
137		// if they've manually specified that we should not use curl, use sockets instead
138		if ( Config::get( 'remote_request_processor' ) == 'socket' ) {
139			$this->processor = new SocketRequestProcessor();
140		}
141		else {
142			// otherwise, see if we can use curl and fall back to sockets if not
143			if ( function_exists( 'curl_init' )
144				&& ! ( ini_get( 'safe_mode' ) || ini_get( 'open_basedir' ) ) ) {
145				$this->processor = new CURLRequestProcessor;
146			}
147			else {
148				$this->processor = new SocketRequestProcessor;
149			}
150
151		}
152	}
153
154	/**
155	 * DO NOT USE THIS FUNCTION.
156	 * This function is only to be used by the test case for RemoteRequest!
157	 */
158	public function __set_processor( $processor )
159	{
160		$this->processor = $processor;
161	}
162
163	/**
164	 * Set adapter configuration options
165	 *
166	 * @param mixed			$config An array of options or a string name with a	corresponding $value
167	 * @param mixed			$value
168	 */
169	public function set_config( $config, $value = null )
170	{
171		if ( is_array( $config ) ) {
172			foreach ( $config as $name => $value ) {
173				$this->set_config( $name, $value );
174			}
175		}
176		else {
177			if ( is_array( $value ) ) {
178				$this->config[ $config ] = array_merge( $this->config[ $config ], $value );
179			}
180			else {
181				$this->config[ $config ] = $value;
182			}
183		}
184	}
185
186	/**
187	 * Add a request header.
188	 * @param mixed $header The header to add, either as a string 'Name: Value' or an associative array 'name'=>'value'
189	 */
190	public function add_header( $header )
191	{
192		if ( is_array( $header ) ) {
193			$this->headers = array_merge( $this->headers, $header );
194		}
195		else {
196			list( $k, $v )= explode( ': ', $header );
197			$this->headers[$k] = $v;
198		}
199	}
200
201	/**
202	 * Add a list of headers.
203	 * @param array $headers List of headers to add.
204	 */
205	public function add_headers( $headers )
206	{
207		foreach ( $headers as $header ) {
208			$this->add_header( $header );
209		}
210	}
211
212	/**
213	 * Set the request body.
214	 * Only used with POST requests, will raise a warning if used with GET.
215	 * @param string $body The request body.
216	 */
217	public function set_body( $body )
218	{
219		if ( $this->method == 'GET' ) {
220			throw new Exception( _t( 'Trying to add a request body to a GET request.' ) );
221		}
222
223		$this->body = $body;
224	}
225
226	/**
227	 * Set the request query parameters (i.e., the URI's query string).
228	 * Will be merged with existing query info from the URL.
229	 * @param array $params
230	 */
231	public function set_params( $params )
232	{
233		if ( ! is_array( $params ) )
234			$params = parse_str( $params );
235
236		$this->params = $params;
237	}
238
239	/**
240	 * Set the timeout.
241	 * @param int $timeout Timeout in seconds
242	 */
243	public function set_timeout( $timeout )
244	{
245		$this->config['timeout'] = $timeout;
246		return $this->config['timeout'];
247	}
248
249	/**
250	 * set postdata
251	 *
252	 * @access public
253	 * @param mixed $name
254	 * @param string $value
255	 */
256	public function set_postdata( $name, $value = null )
257	{
258		if ( is_array( $name ) ) {
259			$this->postdata = array_merge( $this->postdata, $name );
260		}
261		else {
262			$this->postdata[$name] = $value;
263		}
264	}
265
266	/**
267	 * set file
268	 *
269	 * @access public
270	 * @param string $name
271	 * @param string $filename
272	 * @param string $content_type
273	 */
274	public function set_file( $name, $filename, $content_type = null, $override_filename = null )
275	{
276		if ( !file_exists( $filename ) ) {
277			throw new Exception( _t( 'File %s not found.', array( $filename ) ) );
278		}
279		if ( empty( $content_type ) ) $content_type = 'application/octet-stream';
280		$this->files[$name] = array( 'filename' => $filename, 'content_type' => $content_type, 'override_filename' => $override_filename );
281		$this->headers['Content-Type'] = 'multipart/form-data';
282	}
283
284	/**
285	 * A little housekeeping.
286	 */
287	private function prepare()
288	{
289		// remove anchors (#foo) from the URL
290		$this->url = $this->strip_anchors( $this->url );
291		// merge query params from the URL with params given
292		$this->url = $this->merge_query_params( $this->url, $this->params );
293
294		if ( $this->method === 'POST' ) {
295			if ( !isset( $this->headers['Content-Type'] ) || ( $this->headers['Content-Type'] == 'application/x-www-form-urlencoded' ) ) {
296				// TODO should raise a warning
297				$this->add_header( array( 'Content-Type' => 'application/x-www-form-urlencoded' ) );
298
299				if ( $this->body != '' && count( $this->postdata ) > 0 ) {
300					$this->body .= '&';
301				}
302				$this->body .= http_build_query( $this->postdata, '', '&' );
303			}
304			elseif ( $this->headers['Content-Type'] == 'multipart/form-data' ) {
305				$boundary = md5( Utils::nonce() );
306				$this->headers['Content-Type'] .= '; boundary=' . $boundary;
307
308				$parts = array();
309				if ( $this->postdata && is_array( $this->postdata ) ) {
310					reset( $this->postdata );
311					while ( list( $name, $value ) = each( $this->postdata ) ) {
312						$parts[] = "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n{$value}\r\n";
313					}
314				}
315
316				if ( $this->files && is_array( $this->files ) ) {
317					reset( $this->files );
318					while ( list( $name, $fileinfo ) = each( $this->files ) ) {
319						$filename = basename( $fileinfo['filename'] );
320						if ( !empty( $fileinfo['override_filename'] ) ) {
321							$filename = $fileinfo['override_filename'];
322						}
323						$part = "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$filename}\"\r\n";
324						$part .= "Content-Type: {$fileinfo['content_type']}\r\n\r\n";
325						$part .= file_get_contents( $fileinfo['filename'] ) . "\r\n";
326						$parts[] = $part;
327					}
328				}
329
330				if ( !empty( $parts ) ) {
331					$this->body = "--{$boundary}\r\n" . join( "--{$boundary}\r\n", $parts ) . "--{$boundary}--\r\n";
332				}
333			}
334			$this->add_header( array( 'Content-Length' => strlen( $this->body ) ) );
335		}
336	}
337
338	/**
339	 * Actually execute the request.
340	 * On success, returns true and populates the response_body and response_headers fields.
341	 * On failure, throws Exception.
342	 *
343	 * @throws Exception
344	 */
345	public function execute()
346	{
347		$this->prepare();
348		$result = $this->processor->execute( $this->method, $this->url, $this->headers, $this->body, $this->config );
349
350		if ( $result ) { // XXX exceptions?
351			$this->response_headers = $this->processor->get_response_headers();
352			$this->response_body = $this->processor->get_response_body();
353			$this->executed = true;
354
355			return true;
356		}
357		else {
358			// processor->execute should throw an Exception which would bubble up
359			$this->executed = false;
360
361			return $result;
362		}
363	}
364
365	public function executed()
366	{
367		return $this->executed;
368	}
369
370	/**
371	 * Return the response headers. Raises a warning and returns '' if the request wasn't executed yet.
372	 * @todo This should probably just call the selected processor's method, which throws its own error.
373	 */
374	public function get_response_headers()
375	{
376		if ( !$this->executed ) {
377			throw new Exception( _t( 'Unable to fetch response headers for a pending request.' ) );
378		}
379
380		return $this->response_headers;
381	}
382
383	/**
384	 * Return the response body. Raises a warning and returns '' if the request wasn't executed yet.
385	 * @todo This should probably just call the selected processor's method, which throws its own error.
386	 */
387	public function get_response_body()
388	{
389		if ( !$this->executed ) {
390			throw new Exception( _t( 'Unable to fetch response body for a pending request.' ) );
391		}
392
393		return $this->response_body;
394	}
395
396	/**
397	 * Remove anchors (#foo) from given URL.
398	 */
399	private function strip_anchors( $url )
400	{
401		return preg_replace( '/(#.*?)?$/', '', $url );
402	}
403
404	/**
405	 * Call the filter hook.
406	 */
407	private function __filter( $data, $url )
408	{
409		return Plugins::filter( 'remoterequest', $data, $url );
410	}
411
412	/**
413	 * Merge query params from the URL with given params.
414	 * @param string $url The URL
415	 * @param string $params An associative array of parameters.
416	 */
417	private function merge_query_params( $url, $params )
418	{
419		$urlparts = InputFilter::parse_url( $url );
420
421		if ( ! isset( $urlparts['query'] ) ) {
422			$urlparts['query'] = '';
423		}
424
425		if ( ! is_array( $params ) ) {
426			parse_str( $params, $params );
427		}
428
429		$urlparts['query'] = http_build_query( array_merge( Utils::get_params( $urlparts['query'] ), $params ), '', '&' );
430
431		return InputFilter::glue_url( $urlparts );
432	}
433
434	/**
435	 * Static helper function to quickly fetch an URL, with semantics similar to
436	 * PHP's file_get_contents. Does not support
437	 *
438	 * Returns the content on success or false if an error occurred.
439	 *
440	 * @param string $url The URL to fetch
441	 * @param bool $use_include_path whether to search the PHP include path first (unsupported)
442	 * @param resource $context a stream context to use (unsupported)
443	 * @param int $offset how many bytes to skip from the beginning of the result
444	 * @param int $maxlen how many bytes to return
445	 * @return string description
446	 */
447	public static function get_contents( $url, $use_include_path = false, $context = null, $offset = 0, $maxlen = -1 )
448	{
449		try {
450			$rr = new RemoteRequest( $url );
451			if ( $rr->execute() === true ) {
452				return ( $maxlen != -1
453					? MultiByte::substr( $rr->get_response_body(), $offset, $maxlen )
454					: MultiByte::substr( $rr->get_response_body(), $offset ) );
455			}
456			else {
457				return false;
458			}
459		}
460		catch ( Exception $e ) {
461			// catch any exceptions to try and emulate file_get_contents() as closely as possible.
462			// if you want more control over the errors, instantiate RemoteRequest manually
463			return false;
464		}
465	}
466
467}
468
469class RemoteRequest_Timeout extends Exception { }
470
471?>
472