1 <?php
  2 
  3 /**
  4  * Class for sockets.
  5  *
  6  * Example:
  7  * <code>
  8  *     try {
  9  *         $socket = rex_socket::factory('www.example.com');
 10  *         $socket->setPath('/url/to/my/resource?param=1');
 11  *         $response = $socket->doGet();
 12  *         if($response->isOk()) {
 13  *             $body = $response->getBody();
 14  *         }
 15  *     } catch(rex_socket_exception $e) {
 16  *         // error message: $e->getMessage()
 17  *     }
 18  * </code>
 19  *
 20  * @author gharlan
 21  *
 22  * @package redaxo\core
 23  */
 24 class rex_socket
 25 {
 26     protected $host;
 27     protected $port;
 28     protected $ssl;
 29     protected $path = '/';
 30     protected $timeout = 15;
 31     protected $followRedirects = false;
 32     protected $headers = [];
 33     protected $stream;
 34 
 35     /**
 36      * Constructor.
 37      *
 38      * @param string $host Host name
 39      * @param int    $port Port number
 40      * @param bool   $ssl  SSL flag
 41      */
 42     protected function __construct($host, $port = 80, $ssl = false)
 43     {
 44         $this->host = $host;
 45         $this->port = $port;
 46         $this->ssl = $ssl;
 47 
 48         $this->addHeader('Host', $this->host);
 49         $this->addHeader('User-Agent', 'REDAXO/' . rex::getVersion());
 50         $this->addHeader('Connection', 'Close');
 51     }
 52 
 53     /**
 54      * Factory method.
 55      *
 56      * @param string $host Host name
 57      * @param int    $port Port number
 58      * @param bool   $ssl  SSL flag
 59      *
 60      * @return static Socket instance
 61      *
 62      * @see rex_socket::factoryUrl()
 63      */
 64     public static function factory($host, $port = 80, $ssl = false)
 65     {
 66         if (static::class === self::class && ($proxy = rex::getProperty('socket_proxy'))) {
 67             return rex_socket_proxy::factoryUrl($proxy)->setDestination($host, $port, $ssl);
 68         }
 69 
 70         return new static($host, $port, $ssl);
 71     }
 72 
 73     /**
 74      * Creates a socket by a full URL.
 75      *
 76      * @param string $url URL
 77      *
 78      * @throws rex_socket_exception
 79      *
 80      * @return static Socket instance
 81      *
 82      * @see rex_socket::factory()
 83      */
 84     public static function factoryUrl($url)
 85     {
 86         $parts = self::parseUrl($url);
 87 
 88         return static::factory($parts['host'], $parts['port'], $parts['ssl'])->setPath($parts['path']);
 89     }
 90 
 91     /**
 92      * Sets the path.
 93      *
 94      * @param string $path
 95      *
 96      * @return $this Current socket
 97      */
 98     public function setPath($path)
 99     {
100         $this->path = $path;
101 
102         return $this;
103     }
104 
105     /**
106      * Adds a header to the current request.
107      *
108      * @param string $key
109      * @param string $value
110      *
111      * @return $this Current socket
112      */
113     public function addHeader($key, $value)
114     {
115         $this->headers[$key] = $value;
116 
117         return $this;
118     }
119 
120     /**
121      * Adds the basic authorization header to the current request.
122      *
123      * @param string $user
124      * @param string $password
125      *
126      * @return $this Current socket
127      */
128     public function addBasicAuthorization($user, $password)
129     {
130         $this->addHeader('Authorization', 'Basic ' . base64_encode($user . ':' . $password));
131 
132         return $this;
133     }
134 
135     /**
136      * Sets the timeout for the connection.
137      *
138      * @param int $timeout Timeout
139      *
140      * @return $this Current socket
141      */
142     public function setTimeout($timeout)
143     {
144         $this->timeout = $timeout;
145 
146         return $this;
147     }
148 
149     /**
150      * Sets number of redirects that should be followed automatically.
151      *
152      * The method only affects GET requests.
153      *
154      * @param false|int $redirects Number of max redirects
155      *
156      * @return $this Current socket
157      */
158     public function followRedirects($redirects)
159     {
160         if ($redirects < 0) {
161             throw new InvalidArgumentException(sprintf('$redirects must be `null` or an int >= 0, given "%s".', $redirects));
162         }
163 
164         $this->followRedirects = $redirects;
165 
166         return $this;
167     }
168 
169     /**
170      * Makes a GET request.
171      *
172      * @return rex_socket_response Response
173      *
174      * @throws rex_socket_exception
175      */
176     public function doGet()
177     {
178         return $this->doRequest('GET');
179     }
180 
181     /**
182      * Makes a POST request.
183      *
184      * @param string|array|callable $data  Body data as string or array (POST parameters) or a callback for writing the body
185      * @param array                 $files Files array, e.g. `array('myfile' => array('path' => $path, 'type' => 'image/png'))`
186      *
187      * @return rex_socket_response Response
188      *
189      * @throws rex_socket_exception
190      */
191     public function doPost($data = '', array $files = [])
192     {
193         if (is_array($data) && !empty($files)) {
194             $data = function ($stream) use ($data, $files) {
195                 $boundary = '----------6n2Yd9bk2liD6piRHb5xF6';
196                 $eol = "\r\n";
197                 fwrite($stream, 'Content-Type: multipart/form-data; boundary=' . $boundary . $eol);
198                 $dataFormat = '--' . $boundary . $eol . 'Content-Disposition: form-data; name="%s"' . $eol . $eol;
199                 $fileFormat = '--' . $boundary . $eol . 'Content-Disposition: form-data; name="%s"; filename="%s"' . $eol . 'Content-Type: %s' . $eol . $eol;
200                 $end = '--' . $boundary . '--' . $eol;
201                 $length = 0;
202                 $temp = explode('&', rex_string::buildQuery($data));
203                 $data = [];
204                 $partLength = rex_string::size(sprintf($dataFormat, '') . $eol);
205                 foreach ($temp as $t) {
206                     list($key, $value) = array_map('urldecode', explode('=', $t, 2));
207                     $data[$key] = $value;
208                     $length += $partLength + rex_string::size($key) + rex_string::size($value);
209                 }
210                 $partLength = rex_string::size(sprintf($fileFormat, '', '', '') . $eol);
211                 foreach ($files as $key => $file) {
212                     $length += $partLength + rex_string::size($key) + rex_string::size(basename($file['path'])) + rex_string::size($file['type']) + filesize($file['path']);
213                 }
214                 $length += rex_string::size($end);
215                 fwrite($stream, 'Content-Length: ' . $length . $eol . $eol);
216                 foreach ($data as $key => $value) {
217                     fwrite($stream, sprintf($dataFormat, $key) . $value . $eol);
218                 }
219                 foreach ($files as $key => $file) {
220                     fwrite($stream, sprintf($fileFormat, $key, basename($file['path']), $file['type']));
221                     $file = fopen($file['path'], 'r');
222                     while (!feof($file)) {
223                         fwrite($stream, fread($file, 1024));
224                     }
225                     fclose($file);
226                     fwrite($stream, $eol);
227                 }
228                 fwrite($stream, $end);
229             };
230         } elseif (!is_callable($data)) {
231             if (is_array($data)) {
232                 $data = rex_string::buildQuery($data);
233                 $this->addHeader('Content-Type', 'application/x-www-form-urlencoded');
234             }
235         }
236         return $this->doRequest('POST', $data);
237     }
238 
239     /**
240      * Makes a DELETE request.
241      *
242      * @return rex_socket_response Response
243      *
244      * @throws rex_socket_exception
245      */
246     public function doDelete()
247     {
248         return $this->doRequest('DELETE');
249     }
250 
251     /**
252      * Makes a request.
253      *
254      * @param string          $method HTTP method, e.g. "GET"
255      * @param string|callable $data   Body data as string or a callback for writing the body
256      *
257      * @return rex_socket_response Response
258      *
259      * @throws InvalidArgumentException
260      */
261     public function doRequest($method, $data = '')
262     {
263         return rex_timer::measure(__METHOD__, function () use ($method, $data) {
264             if (!is_string($data) && !is_callable($data)) {
265                 throw new InvalidArgumentException(sprintf('Expecting $data to be a string or a callable, but %s given!', gettype($data)));
266             }
267 
268             if (!$this->ssl) {
269                 rex_logger::logError(E_WARNING, 'You should not use non-secure socket connections while connecting to "'. $this->host .'"!', __FILE__, __LINE__);
270             }
271 
272             $this->openConnection();
273             $response = $this->writeRequest($method, $this->path, $this->headers, $data);
274 
275             if ('GET' !== $method || !$this->followRedirects || !$response->isRedirection()) {
276                 return $response;
277             }
278 
279             $location = $response->getHeader('location');
280 
281             if (!$location) {
282                 return $response;
283             }
284 
285             if (false === strpos($location, '//')) {
286                 $socket = self::factory($this->host, $this->port, $this->ssl)->setPath($location);
287             } else {
288                 $socket = self::factoryUrl($location);
289 
290                 if ($this->ssl && !$socket->ssl) {
291                     return $response;
292                 }
293             }
294 
295             $socket->setTimeout($this->timeout);
296             $socket->followRedirects($this->followRedirects - 1);
297 
298             foreach ($this->headers as $key => $value) {
299                 if ('Host' !== $key) {
300                     $socket->addHeader($key, $value);
301                 }
302             }
303 
304             return $socket->doGet();
305         });
306     }
307 
308     /**
309      * Opens the socket connection.
310      *
311      * @throws rex_socket_exception
312      */
313     protected function openConnection()
314     {
315         $host = ($this->ssl ? 'ssl://' : '') . $this->host;
316 
317         $prevError = null;
318         set_error_handler(function ($errno, $errstr) use (&$prevError) {
319             if (null === $prevError) {
320                 $prevError = $errstr;
321             }
322         });
323 
324         try {
325             $this->stream = @fsockopen($host, $this->port, $errno, $errstr);
326         } finally {
327             restore_error_handler();
328         }
329 
330         if ($this->stream) {
331             stream_set_timeout($this->stream, $this->timeout);
332 
333             return;
334         }
335 
336         if ($errstr) {
337             throw new rex_socket_exception($errstr . ' (' . $errno . ')');
338         }
339 
340         if ($prevError) {
341             throw new rex_socket_exception($prevError);
342         }
343 
344         throw new rex_socket_exception('Unknown error.');
345     }
346 
347     /**
348      * Writes a request to the opened connection.
349      *
350      * @param string          $method  HTTP method, e.g. "GET"
351      * @param string          $path    Path
352      * @param array           $headers Headers
353      * @param string|callable $data    Body data as string or a callback for writing the body
354      *
355      * @throws rex_socket_exception
356      *
357      * @return rex_socket_response Response
358      */
359     protected function writeRequest($method, $path, array $headers = [], $data = '')
360     {
361         $eol = "\r\n";
362         $headerStrings = [];
363         $headerStrings[] = strtoupper($method) . ' ' . $path . ' HTTP/1.1';
364         foreach ($headers as $key => $value) {
365             $headerStrings[] = $key . ': ' . $value;
366         }
367         foreach ($headerStrings as $header) {
368             fwrite($this->stream, str_replace(["\r", "\n"], '', $header) . $eol);
369         }
370         if (!is_callable($data)) {
371             fwrite($this->stream, 'Content-Length: ' . rex_string::size($data) . $eol);
372             fwrite($this->stream, $eol . $data);
373         } else {
374             call_user_func($data, $this->stream);
375         }
376 
377         $meta = stream_get_meta_data($this->stream);
378         if (isset($meta['timed_out']) && $meta['timed_out']) {
379             throw new rex_socket_exception('Timeout!');
380         }
381 
382         return new rex_socket_response($this->stream);
383     }
384 
385     /**
386      * Parses a full URL and returns an array with the keys "host", "port", "ssl" and "path".
387      *
388      * @param string $url Full URL
389      *
390      * @return array URL parts
391      *
392      * @throws rex_socket_exception
393      */
394     protected static function parseUrl($url)
395     {
396         $parts = parse_url($url);
397         if ($parts !== false && !isset($parts['host']) && strpos($url, 'http') !== 0) {
398             $parts = parse_url('http://' . $url);
399         }
400         if ($parts === false || !isset($parts['host'])) {
401             throw new rex_socket_exception('It isn\'t possible to parse the URL "' . $url . '"!');
402         }
403 
404         $port = 80;
405         $ssl = false;
406         if (isset($parts['scheme'])) {
407             $supportedProtocols = ['http', 'https'];
408             if (!in_array($parts['scheme'], $supportedProtocols)) {
409                 throw new rex_socket_exception('Unsupported protocol "' . $parts['scheme'] . '". Supported protocols are ' . implode(', ', $supportedProtocols) . '.');
410             }
411             if ($parts['scheme'] == 'https') {
412                 $ssl = true;
413                 $port = 443;
414             }
415         }
416         $port = isset($parts['port']) ? (int) $parts['port'] : $port;
417 
418         $path = (isset($parts['path']) ? $parts['path'] : '/')
419             . (isset($parts['query']) ? '?' . $parts['query'] : '')
420             . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
421 
422         return [
423             'host' => $parts['host'],
424             'port' => $port,
425             'ssl' => $ssl,
426             'path' => $path,
427         ];
428     }
429 }
430 
431 /**
432  * Socket exception.
433  *
434  * @see rex_socket
435  *
436  * @package redaxo\core
437  */
438 class rex_socket_exception extends rex_exception
439 {
440 }
441