Subversion-Projekte lars-tiefland.php_share

Revision

Details | Letzte Änderung | Log anzeigen | RSS feed

Revision Autor Zeilennr. Zeile
1 lars 1
<?php
2
/**
3
 * Socket-based adapter for HTTP_Request2
4
 *
5
 * PHP version 5
6
 *
7
 * LICENSE:
8
 *
9
 * Copyright (c) 2008-2011, Alexey Borzov <avb@php.net>
10
 * All rights reserved.
11
 *
12
 * Redistribution and use in source and binary forms, with or without
13
 * modification, are permitted provided that the following conditions
14
 * are met:
15
 *
16
 *    * Redistributions of source code must retain the above copyright
17
 *      notice, this list of conditions and the following disclaimer.
18
 *    * Redistributions in binary form must reproduce the above copyright
19
 *      notice, this list of conditions and the following disclaimer in the
20
 *      documentation and/or other materials provided with the distribution.
21
 *    * The names of the authors may not be used to endorse or promote products
22
 *      derived from this software without specific prior written permission.
23
 *
24
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
25
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
26
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
27
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
28
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
29
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
30
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
31
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
32
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
33
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
34
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35
 *
36
 * @category   HTTP
37
 * @package    HTTP_Request2
38
 * @author     Alexey Borzov <avb@php.net>
39
 * @license    http://opensource.org/licenses/bsd-license.php New BSD License
40
 * @version    SVN: $Id: Socket.php 309921 2011-04-03 16:43:02Z avb $
41
 * @link       http://pear.php.net/package/HTTP_Request2
42
 */
43
 
44
/**
45
 * Base class for HTTP_Request2 adapters
46
 */
47
require_once 'HTTP/Request2/Adapter.php';
48
 
49
/**
50
 * Socket-based adapter for HTTP_Request2
51
 *
52
 * This adapter uses only PHP sockets and will work on almost any PHP
53
 * environment. Code is based on original HTTP_Request PEAR package.
54
 *
55
 * @category    HTTP
56
 * @package     HTTP_Request2
57
 * @author      Alexey Borzov <avb@php.net>
58
 * @version     Release: 2.0.0RC1
59
 */
60
class HTTP_Request2_Adapter_Socket extends HTTP_Request2_Adapter
61
{
62
   /**
63
    * Regular expression for 'token' rule from RFC 2616
64
    */
65
    const REGEXP_TOKEN = '[^\x00-\x1f\x7f-\xff()<>@,;:\\\\"/\[\]?={}\s]+';
66
 
67
   /**
68
    * Regular expression for 'quoted-string' rule from RFC 2616
69
    */
70
    const REGEXP_QUOTED_STRING = '"(?:\\\\.|[^\\\\"])*"';
71
 
72
   /**
73
    * Connected sockets, needed for Keep-Alive support
74
    * @var  array
75
    * @see  connect()
76
    */
77
    protected static $sockets = array();
78
 
79
   /**
80
    * Data for digest authentication scheme
81
    *
82
    * The keys for the array are URL prefixes.
83
    *
84
    * The values are associative arrays with data (realm, nonce, nonce-count,
85
    * opaque...) needed for digest authentication. Stored here to prevent making
86
    * duplicate requests to digest-protected resources after we have already
87
    * received the challenge.
88
    *
89
    * @var  array
90
    */
91
    protected static $challenges = array();
92
 
93
   /**
94
    * Connected socket
95
    * @var  resource
96
    * @see  connect()
97
    */
98
    protected $socket;
99
 
100
   /**
101
    * Challenge used for server digest authentication
102
    * @var  array
103
    */
104
    protected $serverChallenge;
105
 
106
   /**
107
    * Challenge used for proxy digest authentication
108
    * @var  array
109
    */
110
    protected $proxyChallenge;
111
 
112
   /**
113
    * Sum of start time and global timeout, exception will be thrown if request continues past this time
114
    * @var  integer
115
    */
116
    protected $deadline = null;
117
 
118
   /**
119
    * Remaining length of the current chunk, when reading chunked response
120
    * @var  integer
121
    * @see  readChunked()
122
    */
123
    protected $chunkLength = 0;
124
 
125
   /**
126
    * Remaining amount of redirections to follow
127
    *
128
    * Starts at 'max_redirects' configuration parameter and is reduced on each
129
    * subsequent redirect. An Exception will be thrown once it reaches zero.
130
    *
131
    * @var  integer
132
    */
133
    protected $redirectCountdown = null;
134
 
135
   /**
136
    * Sends request to the remote server and returns its response
137
    *
138
    * @param    HTTP_Request2
139
    * @return   HTTP_Request2_Response
140
    * @throws   HTTP_Request2_Exception
141
    */
142
    public function sendRequest(HTTP_Request2 $request)
143
    {
144
        $this->request = $request;
145
 
146
        // Use global request timeout if given, see feature requests #5735, #8964
147
        if ($timeout = $request->getConfig('timeout')) {
148
            $this->deadline = time() + $timeout;
149
        } else {
150
            $this->deadline = null;
151
        }
152
 
153
        try {
154
            $keepAlive = $this->connect();
155
            $headers   = $this->prepareHeaders();
156
            if (false === @fwrite($this->socket, $headers, strlen($headers))) {
157
                throw new HTTP_Request2_MessageException('Error writing request');
158
            }
159
            // provide request headers to the observer, see request #7633
160
            $this->request->setLastEvent('sentHeaders', $headers);
161
            $this->writeBody();
162
 
163
            if ($this->deadline && time() > $this->deadline) {
164
                throw new HTTP_Request2_MessageException(
165
                    'Request timed out after ' .
166
                    $request->getConfig('timeout') . ' second(s)',
167
                    HTTP_Request2_Exception::TIMEOUT
168
                );
169
            }
170
 
171
            $response = $this->readResponse();
172
 
173
            if ($jar = $request->getCookieJar()) {
174
                $jar->addCookiesFromResponse($response, $request->getUrl());
175
            }
176
 
177
            if (!$this->canKeepAlive($keepAlive, $response)) {
178
                $this->disconnect();
179
            }
180
 
181
            if ($this->shouldUseProxyDigestAuth($response)) {
182
                return $this->sendRequest($request);
183
            }
184
            if ($this->shouldUseServerDigestAuth($response)) {
185
                return $this->sendRequest($request);
186
            }
187
            if ($authInfo = $response->getHeader('authentication-info')) {
188
                $this->updateChallenge($this->serverChallenge, $authInfo);
189
            }
190
            if ($proxyInfo = $response->getHeader('proxy-authentication-info')) {
191
                $this->updateChallenge($this->proxyChallenge, $proxyInfo);
192
            }
193
 
194
        } catch (Exception $e) {
195
            $this->disconnect();
196
        }
197
 
198
        unset($this->request, $this->requestBody);
199
 
200
        if (!empty($e)) {
201
            $this->redirectCountdown = null;
202
            throw $e;
203
        }
204
 
205
        if (!$request->getConfig('follow_redirects') || !$response->isRedirect()) {
206
            $this->redirectCountdown = null;
207
            return $response;
208
        } else {
209
            return $this->handleRedirect($request, $response);
210
        }
211
    }
212
 
213
   /**
214
    * Connects to the remote server
215
    *
216
    * @return   bool    whether the connection can be persistent
217
    * @throws   HTTP_Request2_Exception
218
    */
219
    protected function connect()
220
    {
221
        $secure  = 0 == strcasecmp($this->request->getUrl()->getScheme(), 'https');
222
        $tunnel  = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
223
        $headers = $this->request->getHeaders();
224
        $reqHost = $this->request->getUrl()->getHost();
225
        if (!($reqPort = $this->request->getUrl()->getPort())) {
226
            $reqPort = $secure? 443: 80;
227
        }
228
 
229
        if ($host = $this->request->getConfig('proxy_host')) {
230
            if (!($port = $this->request->getConfig('proxy_port'))) {
231
                throw new HTTP_Request2_LogicException(
232
                    'Proxy port not provided',
233
                    HTTP_Request2_Exception::MISSING_VALUE
234
                );
235
            }
236
            $proxy = true;
237
        } else {
238
            $host  = $reqHost;
239
            $port  = $reqPort;
240
            $proxy = false;
241
        }
242
 
243
        if ($tunnel && !$proxy) {
244
            throw new HTTP_Request2_LogicException(
245
                "Trying to perform CONNECT request without proxy",
246
                HTTP_Request2_Exception::MISSING_VALUE
247
            );
248
        }
249
        if ($secure && !in_array('ssl', stream_get_transports())) {
250
            throw new HTTP_Request2_LogicException(
251
                'Need OpenSSL support for https:// requests',
252
                HTTP_Request2_Exception::MISCONFIGURATION
253
            );
254
        }
255
 
256
        // RFC 2068, section 19.7.1: A client MUST NOT send the Keep-Alive
257
        // connection token to a proxy server...
258
        if ($proxy && !$secure &&
259
            !empty($headers['connection']) && 'Keep-Alive' == $headers['connection']
260
        ) {
261
            $this->request->setHeader('connection');
262
        }
263
 
264
        $keepAlive = ('1.1' == $this->request->getConfig('protocol_version') &&
265
                      empty($headers['connection'])) ||
266
                     (!empty($headers['connection']) &&
267
                      'Keep-Alive' == $headers['connection']);
268
        $host = ((!$secure || $proxy)? 'tcp://': 'ssl://') . $host;
269
 
270
        $options = array();
271
        if ($secure || $tunnel) {
272
            foreach ($this->request->getConfig() as $name => $value) {
273
                if ('ssl_' == substr($name, 0, 4) && null !== $value) {
274
                    if ('ssl_verify_host' == $name) {
275
                        if ($value) {
276
                            $options['CN_match'] = $reqHost;
277
                        }
278
                    } else {
279
                        $options[substr($name, 4)] = $value;
280
                    }
281
                }
282
            }
283
            ksort($options);
284
        }
285
 
286
        // Changing SSL context options after connection is established does *not*
287
        // work, we need a new connection if options change
288
        $remote    = $host . ':' . $port;
289
        $socketKey = $remote . (($secure && $proxy)? "->{$reqHost}:{$reqPort}": '') .
290
                     (empty($options)? '': ':' . serialize($options));
291
        unset($this->socket);
292
 
293
        // We use persistent connections and have a connected socket?
294
        // Ensure that the socket is still connected, see bug #16149
295
        if ($keepAlive && !empty(self::$sockets[$socketKey]) &&
296
            !feof(self::$sockets[$socketKey])
297
        ) {
298
            $this->socket =& self::$sockets[$socketKey];
299
 
300
        } elseif ($secure && $proxy && !$tunnel) {
301
            $this->establishTunnel();
302
            $this->request->setLastEvent(
303
                'connect', "ssl://{$reqHost}:{$reqPort} via {$host}:{$port}"
304
            );
305
            self::$sockets[$socketKey] =& $this->socket;
306
 
307
        } else {
308
            // Set SSL context options if doing HTTPS request or creating a tunnel
309
            $context = stream_context_create();
310
            foreach ($options as $name => $value) {
311
                if (!stream_context_set_option($context, 'ssl', $name, $value)) {
312
                    throw new HTTP_Request2_LogicException(
313
                        "Error setting SSL context option '{$name}'"
314
                    );
315
                }
316
            }
317
            $track = @ini_set('track_errors', 1);
318
            $this->socket = @stream_socket_client(
319
                $remote, $errno, $errstr,
320
                $this->request->getConfig('connect_timeout'),
321
                STREAM_CLIENT_CONNECT, $context
322
            );
323
            if (!$this->socket) {
324
                $e = new HTTP_Request2_ConnectionException(
325
                    "Unable to connect to {$remote}. Error: "
326
                     . (empty($errstr)? $php_errormsg: $errstr), 0, $errno
327
                );
328
            }
329
            @ini_set('track_errors', $track);
330
            if (isset($e)) {
331
                throw $e;
332
            }
333
            $this->request->setLastEvent('connect', $remote);
334
            self::$sockets[$socketKey] =& $this->socket;
335
        }
336
        return $keepAlive;
337
    }
338
 
339
   /**
340
    * Establishes a tunnel to a secure remote server via HTTP CONNECT request
341
    *
342
    * This method will fail if 'ssl_verify_peer' is enabled. Probably because PHP
343
    * sees that we are connected to a proxy server (duh!) rather than the server
344
    * that presents its certificate.
345
    *
346
    * @link     http://tools.ietf.org/html/rfc2817#section-5.2
347
    * @throws   HTTP_Request2_Exception
348
    */
349
    protected function establishTunnel()
350
    {
351
        $donor   = new self;
352
        $connect = new HTTP_Request2(
353
            $this->request->getUrl(), HTTP_Request2::METHOD_CONNECT,
354
            array_merge($this->request->getConfig(),
355
                        array('adapter' => $donor))
356
        );
357
        $response = $connect->send();
358
        // Need any successful (2XX) response
359
        if (200 > $response->getStatus() || 300 <= $response->getStatus()) {
360
            throw new HTTP_Request2_ConnectionException(
361
                'Failed to connect via HTTPS proxy. Proxy response: ' .
362
                $response->getStatus() . ' ' . $response->getReasonPhrase()
363
            );
364
        }
365
        $this->socket = $donor->socket;
366
 
367
        $modes = array(
368
            STREAM_CRYPTO_METHOD_TLS_CLIENT,
369
            STREAM_CRYPTO_METHOD_SSLv3_CLIENT,
370
            STREAM_CRYPTO_METHOD_SSLv23_CLIENT,
371
            STREAM_CRYPTO_METHOD_SSLv2_CLIENT
372
        );
373
 
374
        foreach ($modes as $mode) {
375
            if (stream_socket_enable_crypto($this->socket, true, $mode)) {
376
                return;
377
            }
378
        }
379
        throw new HTTP_Request2_ConnectionException(
380
            'Failed to enable secure connection when connecting through proxy'
381
        );
382
    }
383
 
384
   /**
385
    * Checks whether current connection may be reused or should be closed
386
    *
387
    * @param    boolean                 whether connection could be persistent
388
    *                                   in the first place
389
    * @param    HTTP_Request2_Response  response object to check
390
    * @return   boolean
391
    */
392
    protected function canKeepAlive($requestKeepAlive, HTTP_Request2_Response $response)
393
    {
394
        // Do not close socket on successful CONNECT request
395
        if (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() &&
396
            200 <= $response->getStatus() && 300 > $response->getStatus()
397
        ) {
398
            return true;
399
        }
400
 
401
        $lengthKnown = 'chunked' == strtolower($response->getHeader('transfer-encoding'))
402
                       || null !== $response->getHeader('content-length')
403
                       // no body possible for such responses, see also request #17031
404
                       || HTTP_Request2::METHOD_HEAD == $this->request->getMethod()
405
                       || in_array($response->getStatus(), array(204, 304));
406
        $persistent  = 'keep-alive' == strtolower($response->getHeader('connection')) ||
407
                       (null === $response->getHeader('connection') &&
408
                        '1.1' == $response->getVersion());
409
        return $requestKeepAlive && $lengthKnown && $persistent;
410
    }
411
 
412
   /**
413
    * Disconnects from the remote server
414
    */
415
    protected function disconnect()
416
    {
417
        if (is_resource($this->socket)) {
418
            fclose($this->socket);
419
            $this->socket = null;
420
            $this->request->setLastEvent('disconnect');
421
        }
422
    }
423
 
424
   /**
425
    * Handles HTTP redirection
426
    *
427
    * This method will throw an Exception if redirect to a non-HTTP(S) location
428
    * is attempted, also if number of redirects performed already is equal to
429
    * 'max_redirects' configuration parameter.
430
    *
431
    * @param    HTTP_Request2               Original request
432
    * @param    HTTP_Request2_Response      Response containing redirect
433
    * @return   HTTP_Request2_Response      Response from a new location
434
    * @throws   HTTP_Request2_Exception
435
    */
436
    protected function handleRedirect(HTTP_Request2 $request,
437
                                      HTTP_Request2_Response $response)
438
    {
439
        if (is_null($this->redirectCountdown)) {
440
            $this->redirectCountdown = $request->getConfig('max_redirects');
441
        }
442
        if (0 == $this->redirectCountdown) {
443
            $this->redirectCountdown = null;
444
            // Copying cURL behaviour
445
            throw new HTTP_Request2_MessageException (
446
                'Maximum (' . $request->getConfig('max_redirects') . ') redirects followed',
447
                HTTP_Request2_Exception::TOO_MANY_REDIRECTS
448
            );
449
        }
450
        $redirectUrl = new Net_URL2(
451
            $response->getHeader('location'),
452
            array(Net_URL2::OPTION_USE_BRACKETS => $request->getConfig('use_brackets'))
453
        );
454
        // refuse non-HTTP redirect
455
        if ($redirectUrl->isAbsolute()
456
            && !in_array($redirectUrl->getScheme(), array('http', 'https'))
457
        ) {
458
            $this->redirectCountdown = null;
459
            throw new HTTP_Request2_MessageException(
460
                'Refusing to redirect to a non-HTTP URL ' . $redirectUrl->__toString(),
461
                HTTP_Request2_Exception::NON_HTTP_REDIRECT
462
            );
463
        }
464
        // Theoretically URL should be absolute (see http://tools.ietf.org/html/rfc2616#section-14.30),
465
        // but in practice it is often not
466
        if (!$redirectUrl->isAbsolute()) {
467
            $redirectUrl = $request->getUrl()->resolve($redirectUrl);
468
        }
469
        $redirect = clone $request;
470
        $redirect->setUrl($redirectUrl);
471
        if (303 == $response->getStatus() || (!$request->getConfig('strict_redirects')
472
             && in_array($response->getStatus(), array(301, 302)))
473
        ) {
474
            $redirect->setMethod(HTTP_Request2::METHOD_GET);
475
            $redirect->setBody('');
476
        }
477
 
478
        if (0 < $this->redirectCountdown) {
479
            $this->redirectCountdown--;
480
        }
481
        return $this->sendRequest($redirect);
482
    }
483
 
484
   /**
485
    * Checks whether another request should be performed with server digest auth
486
    *
487
    * Several conditions should be satisfied for it to return true:
488
    *   - response status should be 401
489
    *   - auth credentials should be set in the request object
490
    *   - response should contain WWW-Authenticate header with digest challenge
491
    *   - there is either no challenge stored for this URL or new challenge
492
    *     contains stale=true parameter (in other case we probably just failed
493
    *     due to invalid username / password)
494
    *
495
    * The method stores challenge values in $challenges static property
496
    *
497
    * @param    HTTP_Request2_Response  response to check
498
    * @return   boolean whether another request should be performed
499
    * @throws   HTTP_Request2_Exception in case of unsupported challenge parameters
500
    */
501
    protected function shouldUseServerDigestAuth(HTTP_Request2_Response $response)
502
    {
503
        // no sense repeating a request if we don't have credentials
504
        if (401 != $response->getStatus() || !$this->request->getAuth()) {
505
            return false;
506
        }
507
        if (!$challenge = $this->parseDigestChallenge($response->getHeader('www-authenticate'))) {
508
            return false;
509
        }
510
 
511
        $url    = $this->request->getUrl();
512
        $scheme = $url->getScheme();
513
        $host   = $scheme . '://' . $url->getHost();
514
        if ($port = $url->getPort()) {
515
            if ((0 == strcasecmp($scheme, 'http') && 80 != $port) ||
516
                (0 == strcasecmp($scheme, 'https') && 443 != $port)
517
            ) {
518
                $host .= ':' . $port;
519
            }
520
        }
521
 
522
        if (!empty($challenge['domain'])) {
523
            $prefixes = array();
524
            foreach (preg_split('/\\s+/', $challenge['domain']) as $prefix) {
525
                // don't bother with different servers
526
                if ('/' == substr($prefix, 0, 1)) {
527
                    $prefixes[] = $host . $prefix;
528
                }
529
            }
530
        }
531
        if (empty($prefixes)) {
532
            $prefixes = array($host . '/');
533
        }
534
 
535
        $ret = true;
536
        foreach ($prefixes as $prefix) {
537
            if (!empty(self::$challenges[$prefix]) &&
538
                (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
539
            ) {
540
                // probably credentials are invalid
541
                $ret = false;
542
            }
543
            self::$challenges[$prefix] =& $challenge;
544
        }
545
        return $ret;
546
    }
547
 
548
   /**
549
    * Checks whether another request should be performed with proxy digest auth
550
    *
551
    * Several conditions should be satisfied for it to return true:
552
    *   - response status should be 407
553
    *   - proxy auth credentials should be set in the request object
554
    *   - response should contain Proxy-Authenticate header with digest challenge
555
    *   - there is either no challenge stored for this proxy or new challenge
556
    *     contains stale=true parameter (in other case we probably just failed
557
    *     due to invalid username / password)
558
    *
559
    * The method stores challenge values in $challenges static property
560
    *
561
    * @param    HTTP_Request2_Response  response to check
562
    * @return   boolean whether another request should be performed
563
    * @throws   HTTP_Request2_Exception in case of unsupported challenge parameters
564
    */
565
    protected function shouldUseProxyDigestAuth(HTTP_Request2_Response $response)
566
    {
567
        if (407 != $response->getStatus() || !$this->request->getConfig('proxy_user')) {
568
            return false;
569
        }
570
        if (!($challenge = $this->parseDigestChallenge($response->getHeader('proxy-authenticate')))) {
571
            return false;
572
        }
573
 
574
        $key = 'proxy://' . $this->request->getConfig('proxy_host') .
575
               ':' . $this->request->getConfig('proxy_port');
576
 
577
        if (!empty(self::$challenges[$key]) &&
578
            (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
579
        ) {
580
            $ret = false;
581
        } else {
582
            $ret = true;
583
        }
584
        self::$challenges[$key] = $challenge;
585
        return $ret;
586
    }
587
 
588
   /**
589
    * Extracts digest method challenge from (WWW|Proxy)-Authenticate header value
590
    *
591
    * There is a problem with implementation of RFC 2617: several of the parameters
592
    * are defined as quoted-string there and thus may contain backslash escaped
593
    * double quotes (RFC 2616, section 2.2). However, RFC 2617 defines unq(X) as
594
    * just value of quoted-string X without surrounding quotes, it doesn't speak
595
    * about removing backslash escaping.
596
    *
597
    * Now realm parameter is user-defined and human-readable, strange things
598
    * happen when it contains quotes:
599
    *   - Apache allows quotes in realm, but apparently uses realm value without
600
    *     backslashes for digest computation
601
    *   - Squid allows (manually escaped) quotes there, but it is impossible to
602
    *     authorize with either escaped or unescaped quotes used in digest,
603
    *     probably it can't parse the response (?)
604
    *   - Both IE and Firefox display realm value with backslashes in
605
    *     the password popup and apparently use the same value for digest
606
    *
607
    * HTTP_Request2 follows IE and Firefox (and hopefully RFC 2617) in
608
    * quoted-string handling, unfortunately that means failure to authorize
609
    * sometimes
610
    *
611
    * @param    string  value of WWW-Authenticate or Proxy-Authenticate header
612
    * @return   mixed   associative array with challenge parameters, false if
613
    *                   no challenge is present in header value
614
    * @throws   HTTP_Request2_NotImplementedException in case of unsupported challenge parameters
615
    */
616
    protected function parseDigestChallenge($headerValue)
617
    {
618
        $authParam   = '(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
619
                       self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')';
620
        $challenge   = "!(?<=^|\\s|,)Digest ({$authParam}\\s*(,\\s*|$))+!";
621
        if (!preg_match($challenge, $headerValue, $matches)) {
622
            return false;
623
        }
624
 
625
        preg_match_all('!' . $authParam . '!', $matches[0], $params);
626
        $paramsAry   = array();
627
        $knownParams = array('realm', 'domain', 'nonce', 'opaque', 'stale',
628
                             'algorithm', 'qop');
629
        for ($i = 0; $i < count($params[0]); $i++) {
630
            // section 3.2.1: Any unrecognized directive MUST be ignored.
631
            if (in_array($params[1][$i], $knownParams)) {
632
                if ('"' == substr($params[2][$i], 0, 1)) {
633
                    $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
634
                } else {
635
                    $paramsAry[$params[1][$i]] = $params[2][$i];
636
                }
637
            }
638
        }
639
        // we only support qop=auth
640
        if (!empty($paramsAry['qop']) &&
641
            !in_array('auth', array_map('trim', explode(',', $paramsAry['qop'])))
642
        ) {
643
            throw new HTTP_Request2_NotImplementedException(
644
                "Only 'auth' qop is currently supported in digest authentication, " .
645
                "server requested '{$paramsAry['qop']}'"
646
            );
647
        }
648
        // we only support algorithm=MD5
649
        if (!empty($paramsAry['algorithm']) && 'MD5' != $paramsAry['algorithm']) {
650
            throw new HTTP_Request2_NotImplementedException(
651
                "Only 'MD5' algorithm is currently supported in digest authentication, " .
652
                "server requested '{$paramsAry['algorithm']}'"
653
            );
654
        }
655
 
656
        return $paramsAry;
657
    }
658
 
659
   /**
660
    * Parses [Proxy-]Authentication-Info header value and updates challenge
661
    *
662
    * @param    array   challenge to update
663
    * @param    string  value of [Proxy-]Authentication-Info header
664
    * @todo     validate server rspauth response
665
    */
666
    protected function updateChallenge(&$challenge, $headerValue)
667
    {
668
        $authParam   = '!(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
669
                       self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')!';
670
        $paramsAry   = array();
671
 
672
        preg_match_all($authParam, $headerValue, $params);
673
        for ($i = 0; $i < count($params[0]); $i++) {
674
            if ('"' == substr($params[2][$i], 0, 1)) {
675
                $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
676
            } else {
677
                $paramsAry[$params[1][$i]] = $params[2][$i];
678
            }
679
        }
680
        // for now, just update the nonce value
681
        if (!empty($paramsAry['nextnonce'])) {
682
            $challenge['nonce'] = $paramsAry['nextnonce'];
683
            $challenge['nc']    = 1;
684
        }
685
    }
686
 
687
   /**
688
    * Creates a value for [Proxy-]Authorization header when using digest authentication
689
    *
690
    * @param    string  user name
691
    * @param    string  password
692
    * @param    string  request URL
693
    * @param    array   digest challenge parameters
694
    * @return   string  value of [Proxy-]Authorization request header
695
    * @link     http://tools.ietf.org/html/rfc2617#section-3.2.2
696
    */
697
    protected function createDigestResponse($user, $password, $url, &$challenge)
698
    {
699
        if (false !== ($q = strpos($url, '?')) &&
700
            $this->request->getConfig('digest_compat_ie')
701
        ) {
702
            $url = substr($url, 0, $q);
703
        }
704
 
705
        $a1 = md5($user . ':' . $challenge['realm'] . ':' . $password);
706
        $a2 = md5($this->request->getMethod() . ':' . $url);
707
 
708
        if (empty($challenge['qop'])) {
709
            $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $a2);
710
        } else {
711
            $challenge['cnonce'] = 'Req2.' . rand();
712
            if (empty($challenge['nc'])) {
713
                $challenge['nc'] = 1;
714
            }
715
            $nc     = sprintf('%08x', $challenge['nc']++);
716
            $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $nc . ':' .
717
                          $challenge['cnonce'] . ':auth:' . $a2);
718
        }
719
        return 'Digest username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $user) . '", ' .
720
               'realm="' . $challenge['realm'] . '", ' .
721
               'nonce="' . $challenge['nonce'] . '", ' .
722
               'uri="' . $url . '", ' .
723
               'response="' . $digest . '"' .
724
               (!empty($challenge['opaque'])?
725
                ', opaque="' . $challenge['opaque'] . '"':
726
                '') .
727
               (!empty($challenge['qop'])?
728
                ', qop="auth", nc=' . $nc . ', cnonce="' . $challenge['cnonce'] . '"':
729
                '');
730
    }
731
 
732
   /**
733
    * Adds 'Authorization' header (if needed) to request headers array
734
    *
735
    * @param    array   request headers
736
    * @param    string  request host (needed for digest authentication)
737
    * @param    string  request URL (needed for digest authentication)
738
    * @throws   HTTP_Request2_NotImplementedException
739
    */
740
    protected function addAuthorizationHeader(&$headers, $requestHost, $requestUrl)
741
    {
742
        if (!($auth = $this->request->getAuth())) {
743
            return;
744
        }
745
        switch ($auth['scheme']) {
746
            case HTTP_Request2::AUTH_BASIC:
747
                $headers['authorization'] =
748
                    'Basic ' . base64_encode($auth['user'] . ':' . $auth['password']);
749
                break;
750
 
751
            case HTTP_Request2::AUTH_DIGEST:
752
                unset($this->serverChallenge);
753
                $fullUrl = ('/' == $requestUrl[0])?
754
                           $this->request->getUrl()->getScheme() . '://' .
755
                            $requestHost . $requestUrl:
756
                           $requestUrl;
757
                foreach (array_keys(self::$challenges) as $key) {
758
                    if ($key == substr($fullUrl, 0, strlen($key))) {
759
                        $headers['authorization'] = $this->createDigestResponse(
760
                            $auth['user'], $auth['password'],
761
                            $requestUrl, self::$challenges[$key]
762
                        );
763
                        $this->serverChallenge =& self::$challenges[$key];
764
                        break;
765
                    }
766
                }
767
                break;
768
 
769
            default:
770
                throw new HTTP_Request2_NotImplementedException(
771
                    "Unknown HTTP authentication scheme '{$auth['scheme']}'"
772
                );
773
        }
774
    }
775
 
776
   /**
777
    * Adds 'Proxy-Authorization' header (if needed) to request headers array
778
    *
779
    * @param    array   request headers
780
    * @param    string  request URL (needed for digest authentication)
781
    * @throws   HTTP_Request2_NotImplementedException
782
    */
783
    protected function addProxyAuthorizationHeader(&$headers, $requestUrl)
784
    {
785
        if (!$this->request->getConfig('proxy_host') ||
786
            !($user = $this->request->getConfig('proxy_user')) ||
787
            (0 == strcasecmp('https', $this->request->getUrl()->getScheme()) &&
788
             HTTP_Request2::METHOD_CONNECT != $this->request->getMethod())
789
        ) {
790
            return;
791
        }
792
 
793
        $password = $this->request->getConfig('proxy_password');
794
        switch ($this->request->getConfig('proxy_auth_scheme')) {
795
            case HTTP_Request2::AUTH_BASIC:
796
                $headers['proxy-authorization'] =
797
                    'Basic ' . base64_encode($user . ':' . $password);
798
                break;
799
 
800
            case HTTP_Request2::AUTH_DIGEST:
801
                unset($this->proxyChallenge);
802
                $proxyUrl = 'proxy://' . $this->request->getConfig('proxy_host') .
803
                            ':' . $this->request->getConfig('proxy_port');
804
                if (!empty(self::$challenges[$proxyUrl])) {
805
                    $headers['proxy-authorization'] = $this->createDigestResponse(
806
                        $user, $password,
807
                        $requestUrl, self::$challenges[$proxyUrl]
808
                    );
809
                    $this->proxyChallenge =& self::$challenges[$proxyUrl];
810
                }
811
                break;
812
 
813
            default:
814
                throw new HTTP_Request2_NotImplementedException(
815
                    "Unknown HTTP authentication scheme '" .
816
                    $this->request->getConfig('proxy_auth_scheme') . "'"
817
                );
818
        }
819
    }
820
 
821
 
822
   /**
823
    * Creates the string with the Request-Line and request headers
824
    *
825
    * @return   string
826
    * @throws   HTTP_Request2_Exception
827
    */
828
    protected function prepareHeaders()
829
    {
830
        $headers = $this->request->getHeaders();
831
        $url     = $this->request->getUrl();
832
        $connect = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
833
        $host    = $url->getHost();
834
 
835
        $defaultPort = 0 == strcasecmp($url->getScheme(), 'https')? 443: 80;
836
        if (($port = $url->getPort()) && $port != $defaultPort || $connect) {
837
            $host .= ':' . (empty($port)? $defaultPort: $port);
838
        }
839
        // Do not overwrite explicitly set 'Host' header, see bug #16146
840
        if (!isset($headers['host'])) {
841
            $headers['host'] = $host;
842
        }
843
 
844
        if ($connect) {
845
            $requestUrl = $host;
846
 
847
        } else {
848
            if (!$this->request->getConfig('proxy_host') ||
849
 
850
            ) {
851
                $requestUrl = '';
852
            } else {
853
                $requestUrl = $url->getScheme() . '://' . $host;
854
            }
855
            $path        = $url->getPath();
856
            $query       = $url->getQuery();
857
            $requestUrl .= (empty($path)? '/': $path) . (empty($query)? '': '?' . $query);
858
        }
859
 
860
        if ('1.1' == $this->request->getConfig('protocol_version') &&
861
            extension_loaded('zlib') && !isset($headers['accept-encoding'])
862
        ) {
863
            $headers['accept-encoding'] = 'gzip, deflate';
864
        }
865
        if (($jar = $this->request->getCookieJar())
866
            && ($cookies = $jar->getMatching($this->request->getUrl(), true))
867
        ) {
868
            $headers['cookie'] = (empty($headers['cookie'])? '': $headers['cookie'] . '; ') . $cookies;
869
        }
870
 
871
        $this->addAuthorizationHeader($headers, $host, $requestUrl);
872
        $this->addProxyAuthorizationHeader($headers, $requestUrl);
873
        $this->calculateRequestLength($headers);
874
 
875
        $headersStr = $this->request->getMethod() . ' ' . $requestUrl . ' HTTP/' .
876
                      $this->request->getConfig('protocol_version') . "\r\n";
877
        foreach ($headers as $name => $value) {
878
            $canonicalName = implode('-', array_map('ucfirst', explode('-', $name)));
879
            $headersStr   .= $canonicalName . ': ' . $value . "\r\n";
880
        }
881
        return $headersStr . "\r\n";
882
    }
883
 
884
   /**
885
    * Sends the request body
886
    *
887
    * @throws   HTTP_Request2_MessageException
888
    */
889
    protected function writeBody()
890
    {
891
        if (in_array($this->request->getMethod(), self::$bodyDisallowed) ||
892
 
893
        ) {
894
            return;
895
        }
896
 
897
        $position   = 0;
898
        $bufferSize = $this->request->getConfig('buffer_size');
899
        while ($position < $this->contentLength) {
900
            if (is_string($this->requestBody)) {
901
                $str = substr($this->requestBody, $position, $bufferSize);
902
            } elseif (is_resource($this->requestBody)) {
903
                $str = fread($this->requestBody, $bufferSize);
904
            } else {
905
                $str = $this->requestBody->read($bufferSize);
906
            }
907
            if (false === @fwrite($this->socket, $str, strlen($str))) {
908
                throw new HTTP_Request2_MessageException('Error writing request');
909
            }
910
            // Provide the length of written string to the observer, request #7630
911
            $this->request->setLastEvent('sentBodyPart', strlen($str));
912
            $position += strlen($str);
913
        }
914
        $this->request->setLastEvent('sentBody', $this->contentLength);
915
    }
916
 
917
   /**
918
    * Reads the remote server's response
919
    *
920
    * @return   HTTP_Request2_Response
921
    * @throws   HTTP_Request2_Exception
922
    */
923
    protected function readResponse()
924
    {
925
        $bufferSize = $this->request->getConfig('buffer_size');
926
 
927
        do {
928
            $response = new HTTP_Request2_Response(
929
                $this->readLine($bufferSize), true, $this->request->getUrl()
930
            );
931
            do {
932
                $headerLine = $this->readLine($bufferSize);
933
                $response->parseHeaderLine($headerLine);
934
            } while ('' != $headerLine);
935
        } while (in_array($response->getStatus(), array(100, 101)));
936
 
937
        $this->request->setLastEvent('receivedHeaders', $response);
938
 
939
        // No body possible in such responses
940
        if (HTTP_Request2::METHOD_HEAD == $this->request->getMethod() ||
941
            (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() &&
942
             200 <= $response->getStatus() && 300 > $response->getStatus()) ||
943
            in_array($response->getStatus(), array(204, 304))
944
        ) {
945
            return $response;
946
        }
947
 
948
        $chunked = 'chunked' == $response->getHeader('transfer-encoding');
949
        $length  = $response->getHeader('content-length');
950
        $hasBody = false;
951
        if ($chunked || null === $length || 0 < intval($length)) {
952
            // RFC 2616, section 4.4:
953
            // 3. ... If a message is received with both a
954
            // Transfer-Encoding header field and a Content-Length header field,
955
            // the latter MUST be ignored.
956
            $toRead = ($chunked || null === $length)? null: $length;
957
            $this->chunkLength = 0;
958
 
959
            while (!feof($this->socket) && (is_null($toRead) || 0 < $toRead)) {
960
                if ($chunked) {
961
                    $data = $this->readChunked($bufferSize);
962
                } elseif (is_null($toRead)) {
963
                    $data = $this->fread($bufferSize);
964
                } else {
965
                    $data    = $this->fread(min($toRead, $bufferSize));
966
                    $toRead -= strlen($data);
967
                }
968
                if ('' == $data && (!$this->chunkLength || feof($this->socket))) {
969
                    break;
970
                }
971
 
972
                $hasBody = true;
973
                if ($this->request->getConfig('store_body')) {
974
                    $response->appendBody($data);
975
                }
976
                if (!in_array($response->getHeader('content-encoding'), array('identity', null))) {
977
                    $this->request->setLastEvent('receivedEncodedBodyPart', $data);
978
                } else {
979
                    $this->request->setLastEvent('receivedBodyPart', $data);
980
                }
981
            }
982
        }
983
 
984
        if ($hasBody) {
985
            $this->request->setLastEvent('receivedBody', $response);
986
        }
987
        return $response;
988
    }
989
 
990
   /**
991
    * Reads until either the end of the socket or a newline, whichever comes first
992
    *
993
    * Strips the trailing newline from the returned data, handles global
994
    * request timeout. Method idea borrowed from Net_Socket PEAR package.
995
    *
996
    * @param    int     buffer size to use for reading
997
    * @return   Available data up to the newline (not including newline)
998
    * @throws   HTTP_Request2_MessageException     In case of timeout
999
    */
1000
    protected function readLine($bufferSize)
1001
    {
1002
        $line = '';
1003
        while (!feof($this->socket)) {
1004
            if ($this->deadline) {
1005
                stream_set_timeout($this->socket, max($this->deadline - time(), 1));
1006
            }
1007
            $line .= @fgets($this->socket, $bufferSize);
1008
            $info  = stream_get_meta_data($this->socket);
1009
            if ($info['timed_out'] || $this->deadline && time() > $this->deadline) {
1010
                $reason = $this->deadline
1011
                          ? 'after ' . $this->request->getConfig('timeout') . ' second(s)'
1012
                          : 'due to default_socket_timeout php.ini setting';
1013
                throw new HTTP_Request2_MessageException(
1014
                    "Request timed out {$reason}", HTTP_Request2_Exception::TIMEOUT
1015
                );
1016
            }
1017
            if (substr($line, -1) == "\n") {
1018
                return rtrim($line, "\r\n");
1019
            }
1020
        }
1021
        return $line;
1022
    }
1023
 
1024
   /**
1025
    * Wrapper around fread(), handles global request timeout
1026
    *
1027
    * @param    int     Reads up to this number of bytes
1028
    * @return   Data read from socket
1029
    * @throws   HTTP_Request2_MessageException     In case of timeout
1030
    */
1031
    protected function fread($length)
1032
    {
1033
        if ($this->deadline) {
1034
            stream_set_timeout($this->socket, max($this->deadline - time(), 1));
1035
        }
1036
        $data = fread($this->socket, $length);
1037
        $info = stream_get_meta_data($this->socket);
1038
        if ($info['timed_out'] || $this->deadline && time() > $this->deadline) {
1039
            $reason = $this->deadline
1040
                      ? 'after ' . $this->request->getConfig('timeout') . ' second(s)'
1041
                      : 'due to default_socket_timeout php.ini setting';
1042
            throw new HTTP_Request2_MessageException(
1043
                "Request timed out {$reason}", HTTP_Request2_Exception::TIMEOUT
1044
            );
1045
        }
1046
        return $data;
1047
    }
1048
 
1049
   /**
1050
    * Reads a part of response body encoded with chunked Transfer-Encoding
1051
    *
1052
    * @param    int     buffer size to use for reading
1053
    * @return   string
1054
    * @throws   HTTP_Request2_MessageException
1055
    */
1056
    protected function readChunked($bufferSize)
1057
    {
1058
        // at start of the next chunk?
1059
        if (0 == $this->chunkLength) {
1060
            $line = $this->readLine($bufferSize);
1061
            if (!preg_match('/^([0-9a-f]+)/i', $line, $matches)) {
1062
                throw new HTTP_Request2_MessageException(
1063
                    "Cannot decode chunked response, invalid chunk length '{$line}'",
1064
                    HTTP_Request2_Exception::DECODE_ERROR
1065
                );
1066
            } else {
1067
                $this->chunkLength = hexdec($matches[1]);
1068
                // Chunk with zero length indicates the end
1069
                if (0 == $this->chunkLength) {
1070
                    $this->readLine($bufferSize);
1071
                    return '';
1072
                }
1073
            }
1074
        }
1075
        $data = $this->fread(min($this->chunkLength, $bufferSize));
1076
        $this->chunkLength -= strlen($data);
1077
        if (0 == $this->chunkLength) {
1078
            $this->readLine($bufferSize); // Trailing CRLF
1079
        }
1080
        return $data;
1081
    }
1082
}
1083
 
1084
?>