1<?php
2/**
3 * Copyright 2011 Facebook, Inc.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License"); you may
6 * not use this file except in compliance with the License. You may obtain
7 * a copy of the License at
8 *
9 *     http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14 * License for the specific language governing permissions and limitations
15 * under the License.
16 */
17
18if (!function_exists('curl_init')) {
19  throw new Exception('Facebook needs the CURL PHP extension.');
20}
21if (!function_exists('json_decode')) {
22  throw new Exception('Facebook needs the JSON PHP extension.');
23}
24
25/**
26 * Thrown when an API call returns an exception.
27 *
28 * @author Naitik Shah <naitik@facebook.com>
29 */
30class FacebookApiException extends Exception
31{
32  /**
33   * The result from the API server that represents the exception information.
34   */
35  protected $result;
36
37  /**
38   * Make a new API Exception with the given result.
39   *
40   * @param array $result The result from the API server
41   */
42  public function __construct($result) {
43    $this->result = $result;
44
45    $code = isset($result['error_code']) ? $result['error_code'] : 0;
46
47    if (isset($result['error_description'])) {
48      // OAuth 2.0 Draft 10 style
49      $msg = $result['error_description'];
50    } else if (isset($result['error']) && is_array($result['error'])) {
51      // OAuth 2.0 Draft 00 style
52      $msg = $result['error']['message'];
53    } else if (isset($result['error_msg'])) {
54      // Rest server style
55      $msg = $result['error_msg'];
56    } else {
57      $msg = 'Unknown Error. Check getResult()';
58    }
59
60    parent::__construct($msg, $code);
61  }
62
63  /**
64   * Return the associated result object returned by the API server.
65   *
66   * @return array The result from the API server
67   */
68  public function getResult() {
69    return $this->result;
70  }
71
72  /**
73   * Returns the associated type for the error. This will default to
74   * 'Exception' when a type is not available.
75   *
76   * @return string
77   */
78  public function getType() {
79    if (isset($this->result['error'])) {
80      $error = $this->result['error'];
81      if (is_string($error)) {
82        // OAuth 2.0 Draft 10 style
83        return $error;
84      } else if (is_array($error)) {
85        // OAuth 2.0 Draft 00 style
86        if (isset($error['type'])) {
87          return $error['type'];
88        }
89      }
90    }
91
92    return 'Exception';
93  }
94
95  /**
96   * To make debugging easier.
97   *
98   * @return string The string representation of the error
99   */
100  public function __toString() {
101    $str = $this->getType() . ': ';
102    if ($this->code != 0) {
103      $str .= $this->code . ': ';
104    }
105    return $str . $this->message;
106  }
107}
108
109/**
110 * Provides access to the Facebook Platform.  This class provides
111 * a majority of the functionality needed, but the class is abstract
112 * because it is designed to be sub-classed.  The subclass must
113 * implement the four abstract methods listed at the bottom of
114 * the file.
115 *
116 * @author Naitik Shah <naitik@facebook.com>
117 */
118abstract class BaseFacebook
119{
120  /**
121   * Version.
122   */
123  const VERSION = '3.2.2';
124
125  /**
126   * Signed Request Algorithm.
127   */
128  const SIGNED_REQUEST_ALGORITHM = 'HMAC-SHA256';
129
130  /**
131   * Default options for curl.
132   */
133  public static $CURL_OPTS = array(
134    CURLOPT_CONNECTTIMEOUT => 10,
135    CURLOPT_RETURNTRANSFER => true,
136    CURLOPT_TIMEOUT        => 60,
137    CURLOPT_USERAGENT      => 'facebook-php-3.2',
138  );
139
140  /**
141   * List of query parameters that get automatically dropped when rebuilding
142   * the current URL.
143   */
144  protected static $DROP_QUERY_PARAMS = array(
145    'code',
146    'state',
147    'signed_request',
148  );
149
150  /**
151   * Maps aliases to Facebook domains.
152   */
153  public static $DOMAIN_MAP = array(
154    'api'         => 'https://api.facebook.com/',
155    'api_video'   => 'https://api-video.facebook.com/',
156    'api_read'    => 'https://api-read.facebook.com/',
157    'graph'       => 'https://graph.facebook.com/',
158    'graph_video' => 'https://graph-video.facebook.com/',
159    'www'         => 'https://www.facebook.com/',
160  );
161
162  /**
163   * The Application ID.
164   *
165   * @var string
166   */
167  protected $appId;
168
169  /**
170   * The Application App Secret.
171   *
172   * @var string
173   */
174  protected $appSecret;
175
176  /**
177   * The ID of the Facebook user, or 0 if the user is logged out.
178   *
179   * @var integer
180   */
181  protected $user;
182
183  /**
184   * The data from the signed_request token.
185   */
186  protected $signedRequest;
187
188  /**
189   * A CSRF state variable to assist in the defense against CSRF attacks.
190   */
191  protected $state;
192
193  /**
194   * The OAuth access token received in exchange for a valid authorization
195   * code.  null means the access token has yet to be determined.
196   *
197   * @var string
198   */
199  protected $accessToken = null;
200
201  /**
202   * Indicates if the CURL based @ syntax for file uploads is enabled.
203   *
204   * @var boolean
205   */
206  protected $fileUploadSupport = false;
207
208  /**
209   * Indicates if we trust HTTP_X_FORWARDED_* headers.
210   *
211   * @var boolean
212   */
213  protected $trustForwarded = false;
214
215  /**
216   * Initialize a Facebook Application.
217   *
218   * The configuration:
219   * - appId: the application ID
220   * - secret: the application secret
221   * - fileUpload: (optional) boolean indicating if file uploads are enabled
222   *
223   * @param array $config The application configuration
224   */
225  public function __construct($config) {
226    $this->setAppId($config['appId']);
227    $this->setAppSecret($config['secret']);
228    if (isset($config['fileUpload'])) {
229      $this->setFileUploadSupport($config['fileUpload']);
230    }
231    if (isset($config['trustForwarded']) && $config['trustForwarded']) {
232      $this->trustForwarded = true;
233    }
234    $state = $this->getPersistentData('state');
235    if (!empty($state)) {
236      $this->state = $state;
237    }
238  }
239
240  /**
241   * Set the Application ID.
242   *
243   * @param string $appId The Application ID
244   * @return BaseFacebook
245   */
246  public function setAppId($appId) {
247    $this->appId = $appId;
248    return $this;
249  }
250
251  /**
252   * Get the Application ID.
253   *
254   * @return string the Application ID
255   */
256  public function getAppId() {
257    return $this->appId;
258  }
259
260  /**
261   * Set the App Secret.
262   *
263   * @param string $apiSecret The App Secret
264   * @return BaseFacebook
265   * @deprecated
266   */
267  public function setApiSecret($apiSecret) {
268    $this->setAppSecret($apiSecret);
269    return $this;
270  }
271
272  /**
273   * Set the App Secret.
274   *
275   * @param string $appSecret The App Secret
276   * @return BaseFacebook
277   */
278  public function setAppSecret($appSecret) {
279    $this->appSecret = $appSecret;
280    return $this;
281  }
282
283  /**
284   * Get the App Secret.
285   *
286   * @return string the App Secret
287   * @deprecated
288   */
289  public function getApiSecret() {
290    return $this->getAppSecret();
291  }
292
293  /**
294   * Get the App Secret.
295   *
296   * @return string the App Secret
297   */
298  public function getAppSecret() {
299    return $this->appSecret;
300  }
301
302  /**
303   * Set the file upload support status.
304   *
305   * @param boolean $fileUploadSupport The file upload support status.
306   * @return BaseFacebook
307   */
308  public function setFileUploadSupport($fileUploadSupport) {
309    $this->fileUploadSupport = $fileUploadSupport;
310    return $this;
311  }
312
313  /**
314   * Get the file upload support status.
315   *
316   * @return boolean true if and only if the server supports file upload.
317   */
318  public function getFileUploadSupport() {
319    return $this->fileUploadSupport;
320  }
321
322  /**
323   * DEPRECATED! Please use getFileUploadSupport instead.
324   *
325   * Get the file upload support status.
326   *
327   * @return boolean true if and only if the server supports file upload.
328   */
329  public function useFileUploadSupport() {
330    return $this->getFileUploadSupport();
331  }
332
333  /**
334   * Sets the access token for api calls.  Use this if you get
335   * your access token by other means and just want the SDK
336   * to use it.
337   *
338   * @param string $access_token an access token.
339   * @return BaseFacebook
340   */
341  public function setAccessToken($access_token) {
342    $this->accessToken = $access_token;
343    return $this;
344  }
345
346  /**
347   * Extend an access token, while removing the short-lived token that might
348   * have been generated via client-side flow. Thanks to http://bit.ly/b0Pt0H
349   * for the workaround.
350   */
351  public function setExtendedAccessToken() {
352    try {
353      // need to circumvent json_decode by calling _oauthRequest
354      // directly, since response isn't JSON format.
355      $access_token_response = $this->_oauthRequest(
356        $this->getUrl('graph', '/oauth/access_token'),
357        $params = array(
358          'client_id' => $this->getAppId(),
359          'client_secret' => $this->getAppSecret(),
360          'grant_type' => 'fb_exchange_token',
361          'fb_exchange_token' => $this->getAccessToken(),
362        )
363      );
364    }
365    catch (FacebookApiException $e) {
366      // most likely that user very recently revoked authorization.
367      // In any event, we don't have an access token, so say so.
368      return false;
369    }
370
371    if (empty($access_token_response)) {
372      return false;
373    }
374
375    $response_params = array();
376    parse_str($access_token_response, $response_params);
377
378    if (!isset($response_params['access_token'])) {
379      return false;
380    }
381
382    $this->destroySession();
383
384    $this->setPersistentData(
385      'access_token', $response_params['access_token']
386    );
387  }
388
389  /**
390   * Determines the access token that should be used for API calls.
391   * The first time this is called, $this->accessToken is set equal
392   * to either a valid user access token, or it's set to the application
393   * access token if a valid user access token wasn't available.  Subsequent
394   * calls return whatever the first call returned.
395   *
396   * @return string The access token
397   */
398  public function getAccessToken() {
399    if ($this->accessToken !== null) {
400      // we've done this already and cached it.  Just return.
401      return $this->accessToken;
402    }
403
404    // first establish access token to be the application
405    // access token, in case we navigate to the /oauth/access_token
406    // endpoint, where SOME access token is required.
407    $this->setAccessToken($this->getApplicationAccessToken());
408    $user_access_token = $this->getUserAccessToken();
409    if ($user_access_token) {
410      $this->setAccessToken($user_access_token);
411    }
412
413    return $this->accessToken;
414  }
415
416  /**
417   * Determines and returns the user access token, first using
418   * the signed request if present, and then falling back on
419   * the authorization code if present.  The intent is to
420   * return a valid user access token, or false if one is determined
421   * to not be available.
422   *
423   * @return string A valid user access token, or false if one
424   *                could not be determined.
425   */
426  protected function getUserAccessToken() {
427    // first, consider a signed request if it's supplied.
428    // if there is a signed request, then it alone determines
429    // the access token.
430    $signed_request = $this->getSignedRequest();
431    if ($signed_request) {
432      // apps.facebook.com hands the access_token in the signed_request
433      if (array_key_exists('oauth_token', $signed_request)) {
434        $access_token = $signed_request['oauth_token'];
435        $this->setPersistentData('access_token', $access_token);
436        return $access_token;
437      }
438
439      // the JS SDK puts a code in with the redirect_uri of ''
440      if (array_key_exists('code', $signed_request)) {
441        $code = $signed_request['code'];
442        if ($code && $code == $this->getPersistentData('code')) {
443          // short-circuit if the code we have is the same as the one presented
444          return $this->getPersistentData('access_token');
445        }
446
447        $access_token = $this->getAccessTokenFromCode($code, '');
448        if ($access_token) {
449          $this->setPersistentData('code', $code);
450          $this->setPersistentData('access_token', $access_token);
451          return $access_token;
452        }
453      }
454
455      // signed request states there's no access token, so anything
456      // stored should be cleared.
457      $this->clearAllPersistentData();
458      return false; // respect the signed request's data, even
459                    // if there's an authorization code or something else
460    }
461
462    $code = $this->getCode();
463    if ($code && $code != $this->getPersistentData('code')) {
464      $access_token = $this->getAccessTokenFromCode($code);
465      if ($access_token) {
466        $this->setPersistentData('code', $code);
467        $this->setPersistentData('access_token', $access_token);
468        return $access_token;
469      }
470
471      // code was bogus, so everything based on it should be invalidated.
472      $this->clearAllPersistentData();
473      return false;
474    }
475
476    // as a fallback, just return whatever is in the persistent
477    // store, knowing nothing explicit (signed request, authorization
478    // code, etc.) was present to shadow it (or we saw a code in $_REQUEST,
479    // but it's the same as what's in the persistent store)
480    return $this->getPersistentData('access_token');
481  }
482
483  /**
484   * Retrieve the signed request, either from a request parameter or,
485   * if not present, from a cookie.
486   *
487   * @return string the signed request, if available, or null otherwise.
488   */
489  public function getSignedRequest() {
490    if (!$this->signedRequest) {
491      if (!empty($_REQUEST['signed_request'])) {
492        $this->signedRequest = $this->parseSignedRequest(
493          $_REQUEST['signed_request']);
494      } else if (!empty($_COOKIE[$this->getSignedRequestCookieName()])) {
495        $this->signedRequest = $this->parseSignedRequest(
496          $_COOKIE[$this->getSignedRequestCookieName()]);
497      }
498    }
499    return $this->signedRequest;
500  }
501
502  /**
503   * Get the UID of the connected user, or 0
504   * if the Facebook user is not connected.
505   *
506   * @return string the UID if available.
507   */
508  public function getUser() {
509    if ($this->user !== null) {
510      // we've already determined this and cached the value.
511      return $this->user;
512    }
513
514    return $this->user = $this->getUserFromAvailableData();
515  }
516
517  /**
518   * Determines the connected user by first examining any signed
519   * requests, then considering an authorization code, and then
520   * falling back to any persistent store storing the user.
521   *
522   * @return integer The id of the connected Facebook user,
523   *                 or 0 if no such user exists.
524   */
525  protected function getUserFromAvailableData() {
526    // if a signed request is supplied, then it solely determines
527    // who the user is.
528    $signed_request = $this->getSignedRequest();
529    if ($signed_request) {
530      if (array_key_exists('user_id', $signed_request)) {
531        $user = $signed_request['user_id'];
532        $this->setPersistentData('user_id', $signed_request['user_id']);
533        return $user;
534      }
535
536      // if the signed request didn't present a user id, then invalidate
537      // all entries in any persistent store.
538      $this->clearAllPersistentData();
539      return 0;
540    }
541
542    $user = $this->getPersistentData('user_id', $default = 0);
543    $persisted_access_token = $this->getPersistentData('access_token');
544
545    // use access_token to fetch user id if we have a user access_token, or if
546    // the cached access token has changed.
547    $access_token = $this->getAccessToken();
548    if ($access_token &&
549        $access_token != $this->getApplicationAccessToken() &&
550        !($user && $persisted_access_token == $access_token)) {
551      $user = $this->getUserFromAccessToken();
552      if ($user) {
553        $this->setPersistentData('user_id', $user);
554      } else {
555        $this->clearAllPersistentData();
556      }
557    }
558
559    return $user;
560  }
561
562  /**
563   * Get a Login URL for use with redirects. By default, full page redirect is
564   * assumed. If you are using the generated URL with a window.open() call in
565   * JavaScript, you can pass in display=popup as part of the $params.
566   *
567   * The parameters:
568   * - redirect_uri: the url to go to after a successful login
569   * - scope: comma separated list of requested extended perms
570   *
571   * @param array $params Provide custom parameters
572   * @return string The URL for the login flow
573   */
574  public function getLoginUrl($params=array()) {
575    $this->establishCSRFTokenState();
576    $currentUrl = $this->getCurrentUrl();
577
578    // if 'scope' is passed as an array, convert to comma separated list
579    $scopeParams = isset($params['scope']) ? $params['scope'] : null;
580    if ($scopeParams && is_array($scopeParams)) {
581      $params['scope'] = implode(',', $scopeParams);
582    }
583
584    return $this->getUrl(
585      'www',
586      'dialog/oauth',
587      array_merge(array(
588                    'client_id' => $this->getAppId(),
589                    'redirect_uri' => $currentUrl, // possibly overwritten
590                    'state' => $this->state),
591                  $params));
592  }
593
594  /**
595   * Get a Logout URL suitable for use with redirects.
596   *
597   * The parameters:
598   * - next: the url to go to after a successful logout
599   *
600   * @param array $params Provide custom parameters
601   * @return string The URL for the logout flow
602   */
603  public function getLogoutUrl($params=array()) {
604    return $this->getUrl(
605      'www',
606      'logout.php',
607      array_merge(array(
608        'next' => $this->getCurrentUrl(),
609        'access_token' => $this->getUserAccessToken(),
610      ), $params)
611    );
612  }
613
614  /**
615   * Get a login status URL to fetch the status from Facebook.
616   *
617   * The parameters:
618   * - ok_session: the URL to go to if a session is found
619   * - no_session: the URL to go to if the user is not connected
620   * - no_user: the URL to go to if the user is not signed into facebook
621   *
622   * @param array $params Provide custom parameters
623   * @return string The URL for the logout flow
624   */
625  public function getLoginStatusUrl($params=array()) {
626    return $this->getUrl(
627      'www',
628      'extern/login_status.php',
629      array_merge(array(
630        'api_key' => $this->getAppId(),
631        'no_session' => $this->getCurrentUrl(),
632        'no_user' => $this->getCurrentUrl(),
633        'ok_session' => $this->getCurrentUrl(),
634        'session_version' => 3,
635      ), $params)
636    );
637  }
638
639  /**
640   * Make an API call.
641   *
642   * @return mixed The decoded response
643   */
644  public function api(/* polymorphic */) {
645    $args = func_get_args();
646    if (is_array($args[0])) {
647      return $this->_restserver($args[0]);
648    } else {
649      return call_user_func_array(array($this, '_graph'), $args);
650    }
651  }
652
653  /**
654   * Constructs and returns the name of the cookie that
655   * potentially houses the signed request for the app user.
656   * The cookie is not set by the BaseFacebook class, but
657   * it may be set by the JavaScript SDK.
658   *
659   * @return string the name of the cookie that would house
660   *         the signed request value.
661   */
662  protected function getSignedRequestCookieName() {
663    return 'fbsr_'.$this->getAppId();
664  }
665
666  /**
667   * Constructs and returns the name of the coookie that potentially contain
668   * metadata. The cookie is not set by the BaseFacebook class, but it may be
669   * set by the JavaScript SDK.
670   *
671   * @return string the name of the cookie that would house metadata.
672   */
673  protected function getMetadataCookieName() {
674    return 'fbm_'.$this->getAppId();
675  }
676
677  /**
678   * Get the authorization code from the query parameters, if it exists,
679   * and otherwise return false to signal no authorization code was
680   * discoverable.
681   *
682   * @return mixed The authorization code, or false if the authorization
683   *               code could not be determined.
684   */
685  protected function getCode() {
686    if (isset($_REQUEST['code'])) {
687      if ($this->state !== null &&
688          isset($_REQUEST['state']) &&
689          $this->state === $_REQUEST['state']) {
690
691        // CSRF state has done its job, so clear it
692        $this->state = null;
693        $this->clearPersistentData('state');
694        return $_REQUEST['code'];
695      } else {
696        self::errorLog('CSRF state token does not match one provided.');
697        return false;
698      }
699    }
700
701    return false;
702  }
703
704  /**
705   * Retrieves the UID with the understanding that
706   * $this->accessToken has already been set and is
707   * seemingly legitimate.  It relies on Facebook's Graph API
708   * to retrieve user information and then extract
709   * the user ID.
710   *
711   * @return integer Returns the UID of the Facebook user, or 0
712   *                 if the Facebook user could not be determined.
713   */
714  protected function getUserFromAccessToken() {
715    try {
716      $user_info = $this->api('/me');
717      return $user_info['id'];
718    } catch (FacebookApiException $e) {
719      return 0;
720    }
721  }
722
723  /**
724   * Returns the access token that should be used for logged out
725   * users when no authorization code is available.
726   *
727   * @return string The application access token, useful for gathering
728   *                public information about users and applications.
729   */
730  protected function getApplicationAccessToken() {
731    return $this->appId.'|'.$this->appSecret;
732  }
733
734  /**
735   * Lays down a CSRF state token for this process.
736   *
737   * @return void
738   */
739  protected function establishCSRFTokenState() {
740    if ($this->state === null) {
741      $this->state = md5(uniqid(mt_rand(), true));
742      $this->setPersistentData('state', $this->state);
743    }
744  }
745
746  /**
747   * Retrieves an access token for the given authorization code
748   * (previously generated from www.facebook.com on behalf of
749   * a specific user).  The authorization code is sent to graph.facebook.com
750   * and a legitimate access token is generated provided the access token
751   * and the user for which it was generated all match, and the user is
752   * either logged in to Facebook or has granted an offline access permission.
753   *
754   * @param string $code An authorization code.
755   * @return mixed An access token exchanged for the authorization code, or
756   *               false if an access token could not be generated.
757   */
758  protected function getAccessTokenFromCode($code, $redirect_uri = null) {
759    if (empty($code)) {
760      return false;
761    }
762
763    if ($redirect_uri === null) {
764      $redirect_uri = $this->getCurrentUrl();
765    }
766
767    try {
768      // need to circumvent json_decode by calling _oauthRequest
769      // directly, since response isn't JSON format.
770      $access_token_response =
771        $this->_oauthRequest(
772          $this->getUrl('graph', '/oauth/access_token'),
773          $params = array('client_id' => $this->getAppId(),
774                          'client_secret' => $this->getAppSecret(),
775                          'redirect_uri' => $redirect_uri,
776                          'code' => $code));
777    } catch (FacebookApiException $e) {
778      // most likely that user very recently revoked authorization.
779      // In any event, we don't have an access token, so say so.
780      return false;
781    }
782
783    if (empty($access_token_response)) {
784      return false;
785    }
786
787    $response_params = array();
788    parse_str($access_token_response, $response_params);
789    if (!isset($response_params['access_token'])) {
790      return false;
791    }
792
793    return $response_params['access_token'];
794  }
795
796  /**
797   * Invoke the old restserver.php endpoint.
798   *
799   * @param array $params Method call object
800   *
801   * @return mixed The decoded response object
802   * @throws FacebookApiException
803   */
804  protected function _restserver($params) {
805    // generic application level parameters
806    $params['api_key'] = $this->getAppId();
807    $params['format'] = 'json-strings';
808
809    $result = json_decode($this->_oauthRequest(
810      $this->getApiUrl($params['method']),
811      $params
812    ), true);
813
814    // results are returned, errors are thrown
815    if (is_array($result) && isset($result['error_code'])) {
816      $this->throwAPIException($result);
817      // @codeCoverageIgnoreStart
818    }
819    // @codeCoverageIgnoreEnd
820
821    $method = strtolower($params['method']);
822    if ($method === 'auth.expiresession' ||
823        $method === 'auth.revokeauthorization') {
824      $this->destroySession();
825    }
826
827    return $result;
828  }
829
830  /**
831   * Return true if this is video post.
832   *
833   * @param string $path The path
834   * @param string $method The http method (default 'GET')
835   *
836   * @return boolean true if this is video post
837   */
838  protected function isVideoPost($path, $method = 'GET') {
839    if ($method == 'POST' && preg_match("/^(\/)(.+)(\/)(videos)$/", $path)) {
840      return true;
841    }
842    return false;
843  }
844
845  /**
846   * Invoke the Graph API.
847   *
848   * @param string $path The path (required)
849   * @param string $method The http method (default 'GET')
850   * @param array $params The query/post data
851   *
852   * @return mixed The decoded response object
853   * @throws FacebookApiException
854   */
855  protected function _graph($path, $method = 'GET', $params = array()) {
856    if (is_array($method) && empty($params)) {
857      $params = $method;
858      $method = 'GET';
859    }
860    $params['method'] = $method; // method override as we always do a POST
861
862    if ($this->isVideoPost($path, $method)) {
863      $domainKey = 'graph_video';
864    } else {
865      $domainKey = 'graph';
866    }
867
868    $result = json_decode($this->_oauthRequest(
869      $this->getUrl($domainKey, $path),
870      $params
871    ), true);
872
873    // results are returned, errors are thrown
874    if (is_array($result) && isset($result['error'])) {
875      $this->throwAPIException($result);
876      // @codeCoverageIgnoreStart
877    }
878    // @codeCoverageIgnoreEnd
879
880    return $result;
881  }
882
883  /**
884   * Make a OAuth Request.
885   *
886   * @param string $url The path (required)
887   * @param array $params The query/post data
888   *
889   * @return string The decoded response object
890   * @throws FacebookApiException
891   */
892  protected function _oauthRequest($url, $params) {
893    if (!isset($params['access_token'])) {
894      $params['access_token'] = $this->getAccessToken();
895    }
896
897    // json_encode all params values that are not strings
898    foreach ($params as $key => $value) {
899      if (!is_string($value)) {
900        $params[$key] = json_encode($value);
901      }
902    }
903
904    return $this->makeRequest($url, $params);
905  }
906
907  /**
908   * Makes an HTTP request. This method can be overridden by subclasses if
909   * developers want to do fancier things or use something other than curl to
910   * make the request.
911   *
912   * @param string $url The URL to make the request to
913   * @param array $params The parameters to use for the POST body
914   * @param CurlHandler $ch Initialized curl handle
915   *
916   * @return string The response text
917   */
918  protected function makeRequest($url, $params, $ch=null) {
919    if (!$ch) {
920      $ch = curl_init();
921    }
922
923    $opts = self::$CURL_OPTS;
924    if ($this->getFileUploadSupport()) {
925      $opts[CURLOPT_POSTFIELDS] = $params;
926    } else {
927      $opts[CURLOPT_POSTFIELDS] = http_build_query($params, null, '&');
928    }
929    $opts[CURLOPT_URL] = $url;
930
931    // disable the 'Expect: 100-continue' behaviour. This causes CURL to wait
932    // for 2 seconds if the server does not support this header.
933    if (isset($opts[CURLOPT_HTTPHEADER])) {
934      $existing_headers = $opts[CURLOPT_HTTPHEADER];
935      $existing_headers[] = 'Expect:';
936      $opts[CURLOPT_HTTPHEADER] = $existing_headers;
937    } else {
938      $opts[CURLOPT_HTTPHEADER] = array('Expect:');
939    }
940
941    curl_setopt_array($ch, $opts);
942    $result = curl_exec($ch);
943
944    if (curl_errno($ch) == 60) { // CURLE_SSL_CACERT
945      self::errorLog('Invalid or no certificate authority found, '.
946                     'using bundled information');
947      curl_setopt($ch, CURLOPT_CAINFO,
948                  dirname(__FILE__) . '/fb_ca_chain_bundle.crt');
949      $result = curl_exec($ch);
950    }
951
952    // With dual stacked DNS responses, it's possible for a server to
953    // have IPv6 enabled but not have IPv6 connectivity.  If this is
954    // the case, curl will try IPv4 first and if that fails, then it will
955    // fall back to IPv6 and the error EHOSTUNREACH is returned by the
956    // operating system.
957    if ($result === false && empty($opts[CURLOPT_IPRESOLVE])) {
958        $matches = array();
959        $regex = '/Failed to connect to ([^:].*): Network is unreachable/';
960        if (preg_match($regex, curl_error($ch), $matches)) {
961          if (strlen(@inet_pton($matches[1])) === 16) {
962            self::errorLog('Invalid IPv6 configuration on server, '.
963                           'Please disable or get native IPv6 on your server.');
964            self::$CURL_OPTS[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4;
965            curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
966            $result = curl_exec($ch);
967          }
968        }
969    }
970
971    if ($result === false) {
972      $e = new FacebookApiException(array(
973        'error_code' => curl_errno($ch),
974        'error' => array(
975        'message' => curl_error($ch),
976        'type' => 'CurlException',
977        ),
978      ));
979      curl_close($ch);
980      throw $e;
981    }
982    curl_close($ch);
983    return $result;
984  }
985
986  /**
987   * Parses a signed_request and validates the signature.
988   *
989   * @param string $signed_request A signed token
990   * @return array The payload inside it or null if the sig is wrong
991   */
992  protected function parseSignedRequest($signed_request) {
993    list($encoded_sig, $payload) = explode('.', $signed_request, 2);
994
995    // decode the data
996    $sig = self::base64UrlDecode($encoded_sig);
997    $data = json_decode(self::base64UrlDecode($payload), true);
998
999    if (strtoupper($data['algorithm']) !== self::SIGNED_REQUEST_ALGORITHM) {
1000      self::errorLog(
1001        'Unknown algorithm. Expected ' . self::SIGNED_REQUEST_ALGORITHM);
1002      return null;
1003    }
1004
1005    // check sig
1006    $expected_sig = hash_hmac('sha256', $payload,
1007                              $this->getAppSecret(), $raw = true);
1008    if ($sig !== $expected_sig) {
1009      self::errorLog('Bad Signed JSON signature!');
1010      return null;
1011    }
1012
1013    return $data;
1014  }
1015
1016  /**
1017   * Makes a signed_request blob using the given data.
1018   *
1019   * @param array The data array.
1020   * @return string The signed request.
1021   */
1022  protected function makeSignedRequest($data) {
1023    if (!is_array($data)) {
1024      throw new InvalidArgumentException(
1025        'makeSignedRequest expects an array. Got: ' . print_r($data, true));
1026    }
1027    $data['algorithm'] = self::SIGNED_REQUEST_ALGORITHM;
1028    $data['issued_at'] = time();
1029    $json = json_encode($data);
1030    $b64 = self::base64UrlEncode($json);
1031    $raw_sig = hash_hmac('sha256', $b64, $this->getAppSecret(), $raw = true);
1032    $sig = self::base64UrlEncode($raw_sig);
1033    return $sig.'.'.$b64;
1034  }
1035
1036  /**
1037   * Build the URL for api given parameters.
1038   *
1039   * @param $method String the method name.
1040   * @return string The URL for the given parameters
1041   */
1042  protected function getApiUrl($method) {
1043    static $READ_ONLY_CALLS =
1044      array('admin.getallocation' => 1,
1045            'admin.getappproperties' => 1,
1046            'admin.getbannedusers' => 1,
1047            'admin.getlivestreamvialink' => 1,
1048            'admin.getmetrics' => 1,
1049            'admin.getrestrictioninfo' => 1,
1050            'application.getpublicinfo' => 1,
1051            'auth.getapppublickey' => 1,
1052            'auth.getsession' => 1,
1053            'auth.getsignedpublicsessiondata' => 1,
1054            'comments.get' => 1,
1055            'connect.getunconnectedfriendscount' => 1,
1056            'dashboard.getactivity' => 1,
1057            'dashboard.getcount' => 1,
1058            'dashboard.getglobalnews' => 1,
1059            'dashboard.getnews' => 1,
1060            'dashboard.multigetcount' => 1,
1061            'dashboard.multigetnews' => 1,
1062            'data.getcookies' => 1,
1063            'events.get' => 1,
1064            'events.getmembers' => 1,
1065            'fbml.getcustomtags' => 1,
1066            'feed.getappfriendstories' => 1,
1067            'feed.getregisteredtemplatebundlebyid' => 1,
1068            'feed.getregisteredtemplatebundles' => 1,
1069            'fql.multiquery' => 1,
1070            'fql.query' => 1,
1071            'friends.arefriends' => 1,
1072            'friends.get' => 1,
1073            'friends.getappusers' => 1,
1074            'friends.getlists' => 1,
1075            'friends.getmutualfriends' => 1,
1076            'gifts.get' => 1,
1077            'groups.get' => 1,
1078            'groups.getmembers' => 1,
1079            'intl.gettranslations' => 1,
1080            'links.get' => 1,
1081            'notes.get' => 1,
1082            'notifications.get' => 1,
1083            'pages.getinfo' => 1,
1084            'pages.isadmin' => 1,
1085            'pages.isappadded' => 1,
1086            'pages.isfan' => 1,
1087            'permissions.checkavailableapiaccess' => 1,
1088            'permissions.checkgrantedapiaccess' => 1,
1089            'photos.get' => 1,
1090            'photos.getalbums' => 1,
1091            'photos.gettags' => 1,
1092            'profile.getinfo' => 1,
1093            'profile.getinfooptions' => 1,
1094            'stream.get' => 1,
1095            'stream.getcomments' => 1,
1096            'stream.getfilters' => 1,
1097            'users.getinfo' => 1,
1098            'users.getloggedinuser' => 1,
1099            'users.getstandardinfo' => 1,
1100            'users.hasapppermission' => 1,
1101            'users.isappuser' => 1,
1102            'users.isverified' => 1,
1103            'video.getuploadlimits' => 1);
1104    $name = 'api';
1105    if (isset($READ_ONLY_CALLS[strtolower($method)])) {
1106      $name = 'api_read';
1107    } else if (strtolower($method) == 'video.upload') {
1108      $name = 'api_video';
1109    }
1110    return self::getUrl($name, 'restserver.php');
1111  }
1112
1113  /**
1114   * Build the URL for given domain alias, path and parameters.
1115   *
1116   * @param $name string The name of the domain
1117   * @param $path string Optional path (without a leading slash)
1118   * @param $params array Optional query parameters
1119   *
1120   * @return string The URL for the given parameters
1121   */
1122  protected function getUrl($name, $path='', $params=array()) {
1123    $url = self::$DOMAIN_MAP[$name];
1124    if ($path) {
1125      if ($path[0] === '/') {
1126        $path = substr($path, 1);
1127      }
1128      $url .= $path;
1129    }
1130    if ($params) {
1131      $url .= '?' . http_build_query($params, null, '&');
1132    }
1133
1134    return $url;
1135  }
1136
1137  protected function getHttpHost() {
1138    if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_HOST'])) {
1139      return $_SERVER['HTTP_X_FORWARDED_HOST'];
1140    }
1141    return $_SERVER['HTTP_HOST'];
1142  }
1143
1144  protected function getHttpProtocol() {
1145    if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
1146      if ($_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
1147        return 'https';
1148      }
1149      return 'http';
1150    }
1151    /*apache + variants specific way of checking for https*/
1152    if (isset($_SERVER['HTTPS']) &&
1153        ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] == 1)) {
1154      return 'https';
1155    }
1156    /*nginx way of checking for https*/
1157    if (isset($_SERVER['SERVER_PORT']) &&
1158        ($_SERVER['SERVER_PORT'] === '443')) {
1159      return 'https';
1160    }
1161    return 'http';
1162  }
1163
1164  /**
1165   * Get the base domain used for the cookie.
1166   */
1167  protected function getBaseDomain() {
1168    // The base domain is stored in the metadata cookie if not we fallback
1169    // to the current hostname
1170    $metadata = $this->getMetadataCookie();
1171    if (array_key_exists('base_domain', $metadata) &&
1172        !empty($metadata['base_domain'])) {
1173      return trim($metadata['base_domain'], '.');
1174    }
1175    return $this->getHttpHost();
1176  }
1177
1178  /**
1179
1180  /**
1181   * Returns the Current URL, stripping it of known FB parameters that should
1182   * not persist.
1183   *
1184   * @return string The current URL
1185   */
1186  protected function getCurrentUrl() {
1187    $protocol = $this->getHttpProtocol() . '://';
1188    $host = $this->getHttpHost();
1189    $currentUrl = $protocol.$host.$_SERVER['REQUEST_URI'];
1190    $parts = parse_url($currentUrl);
1191
1192    $query = '';
1193    if (!empty($parts['query'])) {
1194      // drop known fb params
1195      $params = explode('&', $parts['query']);
1196      $retained_params = array();
1197      foreach ($params as $param) {
1198        if ($this->shouldRetainParam($param)) {
1199          $retained_params[] = $param;
1200        }
1201      }
1202
1203      if (!empty($retained_params)) {
1204        $query = '?'.implode($retained_params, '&');
1205      }
1206    }
1207
1208    // use port if non default
1209    $port =
1210      isset($parts['port']) &&
1211      (($protocol === 'http://' && $parts['port'] !== 80) ||
1212       ($protocol === 'https://' && $parts['port'] !== 443))
1213      ? ':' . $parts['port'] : '';
1214
1215    // rebuild
1216    return $protocol . $parts['host'] . $port . $parts['path'] . $query;
1217  }
1218
1219  /**
1220   * Returns true if and only if the key or key/value pair should
1221   * be retained as part of the query string.  This amounts to
1222   * a brute-force search of the very small list of Facebook-specific
1223   * params that should be stripped out.
1224   *
1225   * @param string $param A key or key/value pair within a URL's query (e.g.
1226   *                     'foo=a', 'foo=', or 'foo'.
1227   *
1228   * @return boolean
1229   */
1230  protected function shouldRetainParam($param) {
1231    foreach (self::$DROP_QUERY_PARAMS as $drop_query_param) {
1232      if (strpos($param, $drop_query_param.'=') === 0) {
1233        return false;
1234      }
1235    }
1236
1237    return true;
1238  }
1239
1240  /**
1241   * Analyzes the supplied result to see if it was thrown
1242   * because the access token is no longer valid.  If that is
1243   * the case, then we destroy the session.
1244   *
1245   * @param $result array A record storing the error message returned
1246   *                      by a failed API call.
1247   */
1248  protected function throwAPIException($result) {
1249    $e = new FacebookApiException($result);
1250    switch ($e->getType()) {
1251      // OAuth 2.0 Draft 00 style
1252      case 'OAuthException':
1253        // OAuth 2.0 Draft 10 style
1254      case 'invalid_token':
1255        // REST server errors are just Exceptions
1256      case 'Exception':
1257        $message = $e->getMessage();
1258        if ((strpos($message, 'Error validating access token') !== false) ||
1259            (strpos($message, 'Invalid OAuth access token') !== false) ||
1260            (strpos($message, 'An active access token must be used') !== false)
1261        ) {
1262          $this->destroySession();
1263        }
1264        break;
1265    }
1266
1267    throw $e;
1268  }
1269
1270
1271  /**
1272   * Prints to the error log if you aren't in command line mode.
1273   *
1274   * @param string $msg Log message
1275   */
1276  protected static function errorLog($msg) {
1277    // disable error log if we are running in a CLI environment
1278    // @codeCoverageIgnoreStart
1279    if (php_sapi_name() != 'cli') {
1280      error_log($msg);
1281    }
1282    // uncomment this if you want to see the errors on the page
1283    // print 'error_log: '.$msg."\n";
1284    // @codeCoverageIgnoreEnd
1285  }
1286
1287  /**
1288   * Base64 encoding that doesn't need to be urlencode()ed.
1289   * Exactly the same as base64_encode except it uses
1290   *   - instead of +
1291   *   _ instead of /
1292   *   No padded =
1293   *
1294   * @param string $input base64UrlEncoded string
1295   * @return string
1296   */
1297  protected static function base64UrlDecode($input) {
1298    return base64_decode(strtr($input, '-_', '+/'));
1299  }
1300
1301  /**
1302   * Base64 encoding that doesn't need to be urlencode()ed.
1303   * Exactly the same as base64_encode except it uses
1304   *   - instead of +
1305   *   _ instead of /
1306   *
1307   * @param string $input string
1308   * @return string base64Url encoded string
1309   */
1310  protected static function base64UrlEncode($input) {
1311    $str = strtr(base64_encode($input), '+/', '-_');
1312    $str = str_replace('=', '', $str);
1313    return $str;
1314  }
1315
1316  /**
1317   * Destroy the current session
1318   */
1319  public function destroySession() {
1320    $this->accessToken = null;
1321    $this->signedRequest = null;
1322    $this->user = null;
1323    $this->clearAllPersistentData();
1324
1325    // Javascript sets a cookie that will be used in getSignedRequest that we
1326    // need to clear if we can
1327    $cookie_name = $this->getSignedRequestCookieName();
1328    if (array_key_exists($cookie_name, $_COOKIE)) {
1329      unset($_COOKIE[$cookie_name]);
1330      if (!headers_sent()) {
1331        $base_domain = $this->getBaseDomain();
1332        setcookie($cookie_name, '', 1, '/', '.'.$base_domain);
1333      } else {
1334        // @codeCoverageIgnoreStart
1335        self::errorLog(
1336          'There exists a cookie that we wanted to clear that we couldn\'t '.
1337          'clear because headers was already sent. Make sure to do the first '.
1338          'API call before outputing anything.'
1339        );
1340        // @codeCoverageIgnoreEnd
1341      }
1342    }
1343  }
1344
1345  /**
1346   * Parses the metadata cookie that our Javascript API set
1347   *
1348   * @return  an array mapping key to value
1349   */
1350  protected function getMetadataCookie() {
1351    $cookie_name = $this->getMetadataCookieName();
1352    if (!array_key_exists($cookie_name, $_COOKIE)) {
1353      return array();
1354    }
1355
1356    // The cookie value can be wrapped in "-characters so remove them
1357    $cookie_value = trim($_COOKIE[$cookie_name], '"');
1358
1359    if (empty($cookie_value)) {
1360      return array();
1361    }
1362
1363    $parts = explode('&', $cookie_value);
1364    $metadata = array();
1365    foreach ($parts as $part) {
1366      $pair = explode('=', $part, 2);
1367      if (!empty($pair[0])) {
1368        $metadata[urldecode($pair[0])] =
1369          (count($pair) > 1) ? urldecode($pair[1]) : '';
1370      }
1371    }
1372
1373    return $metadata;
1374  }
1375
1376  protected static function isAllowedDomain($big, $small) {
1377    if ($big === $small) {
1378      return true;
1379    }
1380    return self::endsWith($big, '.'.$small);
1381  }
1382
1383  protected static function endsWith($big, $small) {
1384    $len = strlen($small);
1385    if ($len === 0) {
1386      return true;
1387    }
1388    return substr($big, -$len) === $small;
1389  }
1390
1391  /**
1392   * Each of the following four methods should be overridden in
1393   * a concrete subclass, as they are in the provided Facebook class.
1394   * The Facebook class uses PHP sessions to provide a primitive
1395   * persistent store, but another subclass--one that you implement--
1396   * might use a database, memcache, or an in-memory cache.
1397   *
1398   * @see Facebook
1399   */
1400
1401  /**
1402   * Stores the given ($key, $value) pair, so that future calls to
1403   * getPersistentData($key) return $value. This call may be in another request.
1404   *
1405   * @param string $key
1406   * @param array $value
1407   *
1408   * @return void
1409   */
1410  abstract protected function setPersistentData($key, $value);
1411
1412  /**
1413   * Get the data for $key, persisted by BaseFacebook::setPersistentData()
1414   *
1415   * @param string $key The key of the data to retrieve
1416   * @param boolean $default The default value to return if $key is not found
1417   *
1418   * @return mixed
1419   */
1420  abstract protected function getPersistentData($key, $default = false);
1421
1422  /**
1423   * Clear the data with $key from the persistent storage
1424   *
1425   * @param string $key
1426   * @return void
1427   */
1428  abstract protected function clearPersistentData($key);
1429
1430  /**
1431   * Clear all data from the persistent storage
1432   *
1433   * @return void
1434   */
1435  abstract protected function clearAllPersistentData();
1436}
1437