1<?php
2/**
3 * tmhOAuth
4 *
5 * An OAuth 1.0A library written in PHP.
6 * The library supports file uploading using multipart/form as well as general
7 * REST requests. OAuth authentication is sent using the an Authorization Header.
8 *
9 * @author themattharris
10 * @version 0.7.5
11 *
12 * 20 February 2013
13 */
14class tmhOAuth {
15  const VERSION = '0.7.5';
16
17  var $response = array();
18
19  /**
20   * Creates a new tmhOAuth object
21   *
22   * @param string $config, the configuration to use for this request
23   * @return void
24   */
25  public function __construct($config=array()) {
26    $this->params = array();
27    $this->headers = array();
28    $this->auto_fixed_time = false;
29    $this->buffer = null;
30
31    // default configuration options
32    $this->config = array_merge(
33      array(
34        // leave 'user_agent' blank for default, otherwise set this to
35        // something that clearly identifies your app
36        'user_agent'                 => '',
37        // default timezone for requests
38        'timezone'                   => 'UTC',
39
40        'use_ssl'                    => true,
41        'host'                       => 'api.twitter.com',
42
43        'consumer_key'               => '',
44        'consumer_secret'            => '',
45        'user_token'                 => '',
46        'user_secret'                => '',
47        'force_nonce'                => false,
48        'nonce'                      => false, // used for checking signatures. leave as false for auto
49        'force_timestamp'            => false,
50        'timestamp'                  => false, // used for checking signatures. leave as false for auto
51
52        // oauth signing variables that are not dynamic
53        'oauth_version'              => '1.0',
54        'oauth_signature_method'     => 'HMAC-SHA1',
55
56        // you probably don't want to change any of these curl values
57        'curl_connecttimeout'        => 30,
58        'curl_timeout'               => 10,
59
60        // for security this should always be set to 2.
61        'curl_ssl_verifyhost'        => 2,
62        // for security this should always be set to true.
63        'curl_ssl_verifypeer'        => true,
64
65        // you can get the latest cacert.pem from here http://curl.haxx.se/ca/cacert.pem
66        'curl_cainfo'                => dirname(__FILE__) . DIRECTORY_SEPARATOR . 'cacert.pem',
67        'curl_capath'                => dirname(__FILE__),
68
69        'curl_followlocation'        => false, // whether to follow redirects or not
70
71        // support for proxy servers
72        'curl_proxy'                 => false, // really you don't want to use this if you are using streaming
73        'curl_proxyuserpwd'          => false, // format username:password for proxy, if required
74        'curl_encoding'              => '',    // leave blank for all supported formats, else use gzip, deflate, identity
75
76        // streaming API
77        'is_streaming'               => false,
78        'streaming_eol'              => "\r\n",
79        'streaming_metrics_interval' => 60,
80
81        // header or querystring. You should always use header!
82        // this is just to help me debug other developers implementations
83        'as_header'                  => true,
84        'debug'                      => false,
85      ),
86      $config
87    );
88    $this->set_user_agent();
89    date_default_timezone_set($this->config['timezone']);
90  }
91
92  /**
93   * Sets the useragent for PHP to use
94   * If '$this->config['user_agent']' already has a value it is used instead of one
95   * being generated.
96   *
97   * @return void value is stored to the config array class variable
98   */
99  private function set_user_agent() {
100    if (!empty($this->config['user_agent']))
101      return;
102
103    if ($this->config['curl_ssl_verifyhost'] && $this->config['curl_ssl_verifypeer']) {
104      $ssl = '+SSL';
105    } else {
106      $ssl = '-SSL';
107    }
108
109    $ua = 'tmhOAuth ' . self::VERSION . $ssl . ' - //github.com/themattharris/tmhOAuth';
110    $this->config['user_agent'] = $ua;
111  }
112
113  /**
114   * Generates a random OAuth nonce.
115   * If 'force_nonce' is true a nonce is not generated and the value in the configuration will be retained.
116   *
117   * @param string $length how many characters the nonce should be before MD5 hashing. default 12
118   * @param string $include_time whether to include time at the beginning of the nonce. default true
119   * @return void value is stored to the config array class variable
120   */
121  private function create_nonce($length=12, $include_time=true) {
122    if ($this->config['force_nonce'] == false) {
123      $sequence = array_merge(range(0,9), range('A','Z'), range('a','z'));
124      $length = $length > count($sequence) ? count($sequence) : $length;
125      shuffle($sequence);
126
127      $prefix = $include_time ? microtime() : '';
128      $this->config['nonce'] = md5(substr($prefix . implode('', $sequence), 0, $length));
129    }
130  }
131
132  /**
133   * Generates a timestamp.
134   * If 'force_timestamp' is true a nonce is not generated and the value in the configuration will be retained.
135   *
136   * @return void value is stored to the config array class variable
137   */
138  private function create_timestamp() {
139    $this->config['timestamp'] = ($this->config['force_timestamp'] == false ? time() : $this->config['timestamp']);
140  }
141
142  /**
143   * Encodes the string or array passed in a way compatible with OAuth.
144   * If an array is passed each array value will will be encoded.
145   *
146   * @param mixed $data the scalar or array to encode
147   * @return $data encoded in a way compatible with OAuth
148   */
149  private function safe_encode($data) {
150    if (is_array($data)) {
151      return array_map(array($this, 'safe_encode'), $data);
152    } else if (is_scalar($data)) {
153      return str_ireplace(
154        array('+', '%7E'),
155        array(' ', '~'),
156        rawurlencode($data)
157      );
158    } else {
159      return '';
160    }
161  }
162
163  /**
164   * Decodes the string or array from it's URL encoded form
165   * If an array is passed each array value will will be decoded.
166   *
167   * @param mixed $data the scalar or array to decode
168   * @return string $data decoded from the URL encoded form
169   */
170  private function safe_decode($data) {
171    if (is_array($data)) {
172      return array_map(array($this, 'safe_decode'), $data);
173    } else if (is_scalar($data)) {
174      return rawurldecode($data);
175    } else {
176      return '';
177    }
178  }
179
180  /**
181   * Returns an array of the standard OAuth parameters.
182   *
183   * @return array all required OAuth parameters, safely encoded
184   */
185  private function get_defaults() {
186    $defaults = array(
187      'oauth_version'          => $this->config['oauth_version'],
188      'oauth_nonce'            => $this->config['nonce'],
189      'oauth_timestamp'        => $this->config['timestamp'],
190      'oauth_consumer_key'     => $this->config['consumer_key'],
191      'oauth_signature_method' => $this->config['oauth_signature_method'],
192    );
193
194    // include the user token if it exists
195    if ( $this->config['user_token'] )
196      $defaults['oauth_token'] = $this->config['user_token'];
197
198    // safely encode
199    foreach ($defaults as $k => $v) {
200      $_defaults[$this->safe_encode($k)] = $this->safe_encode($v);
201    }
202
203    return $_defaults;
204  }
205
206  /**
207   * Extracts and decodes OAuth parameters from the passed string
208   *
209   * @param string $body the response body from an OAuth flow method
210   * @return array the response body safely decoded to an array of key => values
211   */
212  public function extract_params($body) {
213    $kvs = explode('&', $body);
214    $decoded = array();
215    foreach ($kvs as $kv) {
216      $kv = explode('=', $kv, 2);
217      $kv[0] = $this->safe_decode($kv[0]);
218      $kv[1] = $this->safe_decode($kv[1]);
219      $decoded[$kv[0]] = $kv[1];
220    }
221    return $decoded;
222  }
223
224  /**
225   * Prepares the HTTP method for use in the base string by converting it to
226   * uppercase.
227   *
228   * @param string $method an HTTP method such as GET or POST
229   * @return void value is stored to the class variable 'method'
230   */
231  private function prepare_method($method) {
232    $this->method = strtoupper($method);
233  }
234
235  /**
236   * Prepares the URL for use in the base string by ripping it apart and
237   * reconstructing it.
238   *
239   * Ref: 3.4.1.2
240   *
241   * @param string $url the request URL
242   * @return void value is stored to the class variable 'url'
243   */
244  private function prepare_url($url) {
245    $parts = parse_url($url);
246
247    $port   = isset($parts['port']) ? $parts['port'] : false;
248    $scheme = $parts['scheme'];
249    $host   = $parts['host'];
250    $path   = isset($parts['path']) ? $parts['path'] : false;
251
252    $port or $port = ($scheme == 'https') ? '443' : '80';
253
254    if (($scheme == 'https' && $port != '443')
255        || ($scheme == 'http' && $port != '80')) {
256      $host = "$host:$port";
257    }
258
259    // the scheme and host MUST be lowercase
260    $this->url = strtolower("$scheme://$host");
261    // but not the path
262    $this->url .= $path;
263  }
264
265  /**
266   * Prepares all parameters for the base string and request.
267   * Multipart parameters are ignored as they are not defined in the specification,
268   * all other types of parameter are encoded for compatibility with OAuth.
269   *
270   * @param array $params the parameters for the request
271   * @return void prepared values are stored in the class variable 'signing_params'
272   */
273  private function prepare_params($params) {
274    // do not encode multipart parameters, leave them alone
275    if ($this->config['multipart']) {
276      $this->request_params = $params;
277      $params = array();
278    }
279
280    // signing parameters are request parameters + OAuth default parameters
281    $this->signing_params = array_merge($this->get_defaults(), (array)$params);
282
283    // Remove oauth_signature if present
284    // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.")
285    if (isset($this->signing_params['oauth_signature'])) {
286      unset($this->signing_params['oauth_signature']);
287    }
288
289    // Parameters are sorted by name, using lexicographical byte value ordering.
290    // Ref: Spec: 9.1.1 (1)
291    uksort($this->signing_params, 'strcmp');
292
293    // encode. Also sort the signed parameters from the POST parameters
294    foreach ($this->signing_params as $k => $v) {
295      $k = $this->safe_encode($k);
296
297      if (is_array($v))
298        $v = implode(',', $v);
299
300      $v = $this->safe_encode($v);
301      $_signing_params[$k] = $v;
302      $kv[] = "{$k}={$v}";
303    }
304
305    // auth params = the default oauth params which are present in our collection of signing params
306    $this->auth_params = array_intersect_key($this->get_defaults(), $_signing_params);
307    if (isset($_signing_params['oauth_callback'])) {
308      $this->auth_params['oauth_callback'] = $_signing_params['oauth_callback'];
309      unset($_signing_params['oauth_callback']);
310    }
311
312    if (isset($_signing_params['oauth_verifier'])) {
313      $this->auth_params['oauth_verifier'] = $_signing_params['oauth_verifier'];
314      unset($_signing_params['oauth_verifier']);
315    }
316
317    // request_params is already set if we're doing multipart, if not we need to set them now
318    if ( ! $this->config['multipart'])
319      $this->request_params = array_diff_key($_signing_params, $this->get_defaults());
320
321    // create the parameter part of the base string
322    $this->signing_params = implode('&', $kv);
323  }
324
325  /**
326   * Prepares the OAuth signing key
327   *
328   * @return void prepared signing key is stored in the class variable 'signing_key'
329   */
330  private function prepare_signing_key() {
331    $this->signing_key = $this->safe_encode($this->config['consumer_secret']) . '&' . $this->safe_encode($this->config['user_secret']);
332  }
333
334  /**
335   * Prepare the base string.
336   * Ref: Spec: 9.1.3 ("Concatenate Request Elements")
337   *
338   * @return void prepared base string is stored in the class variable 'base_string'
339   */
340  private function prepare_base_string() {
341    $url = $this->url;
342
343    # if the host header is set we need to rewrite the basestring to use
344    # that, instead of the request host. otherwise the signature won't match
345    # on the server side
346    if (!empty($this->custom_headers['Host'])) {
347      $url = str_ireplace(
348        $this->config['host'],
349        $this->custom_headers['Host'],
350        $url
351      );
352    }
353
354    $base = array(
355      $this->method,
356      $url,
357      $this->signing_params
358    );
359    $this->base_string = implode('&', $this->safe_encode($base));
360  }
361
362  /**
363   * Prepares the Authorization header
364   *
365   * @return void prepared authorization header is stored in the class variable headers['Authorization']
366   */
367  private function prepare_auth_header() {
368    unset($this->headers['Authorization']);
369
370    uksort($this->auth_params, 'strcmp');
371    if (!$this->config['as_header']) :
372      $this->request_params = array_merge($this->request_params, $this->auth_params);
373      return;
374    endif;
375
376    foreach ($this->auth_params as $k => $v) {
377      $kv[] = "{$k}=\"{$v}\"";
378    }
379    $this->auth_header = 'OAuth ' . implode(', ', $kv);
380    $this->headers['Authorization'] = $this->auth_header;
381  }
382
383  /**
384   * Signs the request and adds the OAuth signature. This runs all the request
385   * parameter preparation methods.
386   *
387   * @param string $method the HTTP method being used. e.g. POST, GET, HEAD etc
388   * @param string $url the request URL without query string parameters
389   * @param array $params the request parameters as an array of key=value pairs
390   * @param string $useauth whether to use authentication when making the request.
391   * @return void
392   */
393  private function sign($method, $url, $params, $useauth) {
394    $this->prepare_method($method);
395    $this->prepare_url($url);
396    $this->prepare_params($params);
397
398    // we don't sign anything is we're not using auth
399    if ($useauth) {
400      $this->prepare_base_string();
401      $this->prepare_signing_key();
402
403      $this->auth_params['oauth_signature'] = $this->safe_encode(
404        base64_encode(
405          hash_hmac(
406            'sha1', $this->base_string, $this->signing_key, true
407      )));
408
409      $this->prepare_auth_header();
410    }
411  }
412
413  /**
414   * Make an HTTP request using this library. This method doesn't return anything.
415   * Instead the response should be inspected directly.
416   *
417   * @param string $method the HTTP method being used. e.g. POST, GET, HEAD etc
418   * @param string $url the request URL without query string parameters
419   * @param array $params the request parameters as an array of key=value pairs. Default empty array
420   * @param string $useauth whether to use authentication when making the request. Default true
421   * @param string $multipart whether this request contains multipart data. Default false
422   * @param array $headers any custom headers to send with the request. Default empty array
423   * @return int the http response code for the request. 0 is returned if a connection could not be made
424   */
425  public function request($method, $url, $params=array(), $useauth=true, $multipart=false, $headers=array()) {
426    // reset the request headers (we don't want to reuse them)
427    $this->headers = array();
428    $this->custom_headers = $headers;
429
430    $this->config['multipart'] = $multipart;
431
432    $this->create_nonce();
433    $this->create_timestamp();
434
435    $this->sign($method, $url, $params, $useauth);
436
437    if (!empty($this->custom_headers))
438      $this->headers = array_merge((array)$this->headers, (array)$this->custom_headers);
439
440    return $this->curlit();
441  }
442
443  /**
444   * Make a long poll HTTP request using this library. This method is
445   * different to the other request methods as it isn't supposed to disconnect
446   *
447   * Using this method expects a callback which will receive the streaming
448   * responses.
449   *
450   * @param string $method the HTTP method being used. e.g. POST, GET, HEAD etc
451   * @param string $url the request URL without query string parameters
452   * @param array $params the request parameters as an array of key=value pairs
453   * @param string $callback the callback function to stream the buffer to.
454   * @return void
455   */
456  public function streaming_request($method, $url, $params=array(), $callback='') {
457    if ( ! empty($callback) ) {
458      if ( ! is_callable($callback) ) {
459        return false;
460      }
461      $this->config['streaming_callback'] = $callback;
462    }
463    $this->metrics['start']          = time();
464    $this->metrics['interval_start'] = $this->metrics['start'];
465    $this->metrics['tweets']         = 0;
466    $this->metrics['last_tweets']    = 0;
467    $this->metrics['bytes']          = 0;
468    $this->metrics['last_bytes']     = 0;
469    $this->config['is_streaming']    = true;
470    $this->request($method, $url, $params);
471  }
472
473  /**
474   * Handles the updating of the current Streaming API metrics.
475   *
476   * @return array the metrics for the streaming api connection
477   */
478  private function update_metrics() {
479    $now = time();
480    if (($this->metrics['interval_start'] + $this->config['streaming_metrics_interval']) > $now)
481      return false;
482
483    $this->metrics['tps'] = round( ($this->metrics['tweets'] - $this->metrics['last_tweets']) / $this->config['streaming_metrics_interval'], 2);
484    $this->metrics['bps'] = round( ($this->metrics['bytes'] - $this->metrics['last_bytes']) / $this->config['streaming_metrics_interval'], 2);
485
486    $this->metrics['last_bytes'] = $this->metrics['bytes'];
487    $this->metrics['last_tweets'] = $this->metrics['tweets'];
488    $this->metrics['interval_start'] = $now;
489    return $this->metrics;
490  }
491
492  /**
493   * Utility function to create the request URL in the requested format
494   *
495   * @param string $request the API method without extension
496   * @param string $format the format of the response. Default json. Set to an empty string to exclude the format
497   * @return string the concatenation of the host, API version, API method and format
498   */
499  public function url($request, $format='json') {
500    $format = strlen($format) > 0 ? ".$format" : '';
501    $proto  = $this->config['use_ssl'] ? 'https:/' : 'http:/';
502
503    // backwards compatibility with v0.1
504    if (isset($this->config['v']))
505      $this->config['host'] = $this->config['host'] . '/' . $this->config['v'];
506
507    $request = ltrim($request, '/');
508
509    $pos = strlen($request) - strlen($format);
510    if (substr($request, $pos) === $format)
511      $request = substr_replace($request, '', $pos);
512
513    return implode('/', array(
514      $proto,
515      $this->config['host'],
516      $request . $format
517    ));
518  }
519
520  /**
521   * Public access to the private safe decode/encode methods
522   *
523   * @param string $text the text to transform
524   * @param string $mode the transformation mode. either encode or decode
525   * @return string $text transformed by the given $mode
526   */
527  public function transformText($text, $mode='encode') {
528    return $this->{"safe_$mode"}($text);
529  }
530
531  /**
532   * Utility function to parse the returned curl headers and store them in the
533   * class array variable.
534   *
535   * @param object $ch curl handle
536   * @param string $header the response headers
537   * @return string the length of the header
538   */
539  private function curlHeader($ch, $header) {
540    $this->response['raw'] .= $header;
541
542    list($key, $value) = array_pad(explode(':', $header, 2), 2, null);
543
544    $key = trim($key);
545    $value = trim($value);
546
547    if ( ! isset($this->response['headers'][$key])) {
548      $this->response['headers'][$key] = $value;
549    } else {
550      if (!is_array($this->response['headers'][$key])) {
551        $this->response['headers'][$key] = array($this->response['headers'][$key]);
552      }
553      $this->response['headers'][$key][] = $value;
554    }
555
556    return strlen($header);
557  }
558
559  /**
560    * Utility function to parse the returned curl buffer and store them until
561    * an EOL is found. The buffer for curl is an undefined size so we need
562    * to collect the content until an EOL is found.
563    *
564    * This function calls the previously defined streaming callback method.
565    *
566    * @param object $ch curl handle
567    * @param string $data the current curl buffer
568    * @return int the length of the data string processed in this function
569    */
570  private function curlWrite($ch, $data) {
571    $l = strlen($data);
572    if (strpos($data, $this->config['streaming_eol']) === false) {
573      $this->buffer .= $data;
574      return $l;
575    }
576
577    $buffered = explode($this->config['streaming_eol'], $data);
578    $content = $this->buffer . $buffered[0];
579
580    $this->metrics['tweets']++;
581    $this->metrics['bytes'] += strlen($content);
582
583    if ( ! is_callable($this->config['streaming_callback']))
584      return 0;
585
586    $metrics = $this->update_metrics();
587    $stop = call_user_func(
588      $this->config['streaming_callback'],
589      $content,
590      strlen($content),
591      $metrics
592    );
593    $this->buffer = $buffered[1];
594    if ($stop)
595      return 0;
596
597    return $l;
598  }
599
600  /**
601   * Makes a curl request. Takes no parameters as all should have been prepared
602   * by the request method
603   *
604   * the response data is stored in the class variable 'response'
605   *
606   * @return int the http response code for the request. 0 is returned if a connection could not be made
607   */
608  private function curlit() {
609    $this->response['raw'] = '';
610
611    // method handling
612    switch ($this->method) {
613      case 'POST':
614        break;
615      default:
616        // GET, DELETE request so convert the parameters to a querystring
617        if ( ! empty($this->request_params)) {
618          foreach ($this->request_params as $k => $v) {
619            // Multipart params haven't been encoded yet.
620            // Not sure why you would do a multipart GET but anyway, here's the support for it
621            if ($this->config['multipart']) {
622              $params[] = $this->safe_encode($k) . '=' . $this->safe_encode($v);
623            } else {
624              $params[] = $k . '=' . $v;
625            }
626          }
627          $qs = implode('&', $params);
628          $this->url = strlen($qs) > 0 ? $this->url . '?' . $qs : $this->url;
629          $this->request_params = array();
630        }
631        break;
632    }
633
634    // configure curl
635    $c = curl_init();
636    curl_setopt_array($c, array(
637      CURLOPT_USERAGENT      => $this->config['user_agent'],
638      CURLOPT_CONNECTTIMEOUT => $this->config['curl_connecttimeout'],
639      CURLOPT_TIMEOUT        => $this->config['curl_timeout'],
640      CURLOPT_RETURNTRANSFER => true,
641      CURLOPT_SSL_VERIFYPEER => $this->config['curl_ssl_verifypeer'],
642      CURLOPT_SSL_VERIFYHOST => $this->config['curl_ssl_verifyhost'],
643
644      CURLOPT_FOLLOWLOCATION => $this->config['curl_followlocation'],
645      CURLOPT_PROXY          => $this->config['curl_proxy'],
646      CURLOPT_ENCODING       => $this->config['curl_encoding'],
647      CURLOPT_URL            => $this->url,
648      // process the headers
649      CURLOPT_HEADERFUNCTION => array($this, 'curlHeader'),
650      CURLOPT_HEADER         => false,
651      CURLINFO_HEADER_OUT    => true,
652    ));
653
654    if ($this->config['curl_cainfo'] !== false)
655      curl_setopt($c, CURLOPT_CAINFO, $this->config['curl_cainfo']);
656
657    if ($this->config['curl_capath'] !== false)
658      curl_setopt($c, CURLOPT_CAPATH, $this->config['curl_capath']);
659
660    if ($this->config['curl_proxyuserpwd'] !== false)
661      curl_setopt($c, CURLOPT_PROXYUSERPWD, $this->config['curl_proxyuserpwd']);
662
663    if ($this->config['is_streaming']) {
664      // process the body
665      $this->response['content-length'] = 0;
666      curl_setopt($c, CURLOPT_TIMEOUT, 0);
667      curl_setopt($c, CURLOPT_WRITEFUNCTION, array($this, 'curlWrite'));
668    }
669
670    switch ($this->method) {
671      case 'GET':
672        break;
673      case 'POST':
674        curl_setopt($c, CURLOPT_POST, true);
675        curl_setopt($c, CURLOPT_POSTFIELDS, $this->request_params);
676        break;
677      default:
678        curl_setopt($c, CURLOPT_CUSTOMREQUEST, $this->method);
679    }
680
681    if ( ! empty($this->request_params) ) {
682      // if not doing multipart we need to implode the parameters
683      if ( ! $this->config['multipart'] ) {
684        foreach ($this->request_params as $k => $v) {
685          $ps[] = "{$k}={$v}";
686        }
687        $this->request_params = implode('&', $ps);
688      }
689      curl_setopt($c, CURLOPT_POSTFIELDS, $this->request_params);
690    }
691
692    if ( ! empty($this->headers)) {
693      foreach ($this->headers as $k => $v) {
694        $headers[] = trim($k . ': ' . $v);
695      }
696      curl_setopt($c, CURLOPT_HTTPHEADER, $headers);
697    }
698
699    if (isset($this->config['prevent_request']) && (true == $this->config['prevent_request']))
700      return 0;
701
702    // do it!
703    $response = curl_exec($c);
704    $code = curl_getinfo($c, CURLINFO_HTTP_CODE);
705    $info = curl_getinfo($c);
706    $error = curl_error($c);
707    $errno = curl_errno($c);
708    curl_close($c);
709
710    // store the response
711    $this->response['code'] = $code;
712    $this->response['response'] = $response;
713    $this->response['info'] = $info;
714    $this->response['error'] = $error;
715    $this->response['errno'] = $errno;
716
717    if (!isset($this->response['raw'])) {
718      $this->response['raw'] = '';
719    }
720    $this->response['raw'] .= $response;
721
722    return $code;
723  }
724}