PageRenderTime 46ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/src/TwitterOAuth.php

http://github.com/abraham/twitteroauth
PHP | 764 lines | 455 code | 54 blank | 255 comment | 27 complexity | a74115f548f706cf1bc9e730441d153c MD5 | raw file
  1. <?php
  2. /**
  3. * The most popular PHP library for use with the Twitter OAuth REST API.
  4. *
  5. * @license MIT
  6. */
  7. declare(strict_types=1);
  8. namespace Abraham\TwitterOAuth;
  9. use Abraham\TwitterOAuth\Util\JsonDecoder;
  10. use Composer\CaBundle\CaBundle;
  11. /**
  12. * TwitterOAuth class for interacting with the Twitter API.
  13. *
  14. * @author Abraham Williams <abraham@abrah.am>
  15. */
  16. class TwitterOAuth extends Config
  17. {
  18. private const API_HOST = 'https://api.twitter.com';
  19. private const UPLOAD_HOST = 'https://upload.twitter.com';
  20. /** @var Response details about the result of the last request */
  21. private $response;
  22. /** @var string|null Application bearer token */
  23. private $bearer;
  24. /** @var Consumer Twitter application details */
  25. private $consumer;
  26. /** @var Token|null User access token details */
  27. private $token;
  28. /** @var HmacSha1 OAuth 1 signature type used by Twitter */
  29. private $signatureMethod;
  30. /** @var int Number of attempts we made for the request */
  31. private $attempts = 0;
  32. /**
  33. * Constructor
  34. *
  35. * @param string $consumerKey The Application Consumer Key
  36. * @param string $consumerSecret The Application Consumer Secret
  37. * @param string|null $oauthToken The Client Token (optional)
  38. * @param string|null $oauthTokenSecret The Client Token Secret (optional)
  39. */
  40. public function __construct(
  41. string $consumerKey,
  42. string $consumerSecret,
  43. ?string $oauthToken = null,
  44. ?string $oauthTokenSecret = null
  45. ) {
  46. $this->resetLastResponse();
  47. $this->signatureMethod = new HmacSha1();
  48. $this->consumer = new Consumer($consumerKey, $consumerSecret);
  49. if (!empty($oauthToken) && !empty($oauthTokenSecret)) {
  50. $this->setOauthToken($oauthToken, $oauthTokenSecret);
  51. }
  52. if (empty($oauthToken) && !empty($oauthTokenSecret)) {
  53. $this->setBearer($oauthTokenSecret);
  54. }
  55. }
  56. /**
  57. * @param string $oauthToken
  58. * @param string $oauthTokenSecret
  59. */
  60. public function setOauthToken(
  61. string $oauthToken,
  62. string $oauthTokenSecret
  63. ): void {
  64. $this->token = new Token($oauthToken, $oauthTokenSecret);
  65. $this->bearer = null;
  66. }
  67. /**
  68. * @param string $oauthTokenSecret
  69. */
  70. public function setBearer(string $oauthTokenSecret): void
  71. {
  72. $this->bearer = $oauthTokenSecret;
  73. $this->token = null;
  74. }
  75. /**
  76. * @return string|null
  77. */
  78. public function getLastApiPath(): ?string
  79. {
  80. return $this->response->getApiPath();
  81. }
  82. /**
  83. * @return int
  84. */
  85. public function getLastHttpCode(): int
  86. {
  87. return $this->response->getHttpCode();
  88. }
  89. /**
  90. * @return array
  91. */
  92. public function getLastXHeaders(): array
  93. {
  94. return $this->response->getXHeaders();
  95. }
  96. /**
  97. * @return array|object|null
  98. */
  99. public function getLastBody()
  100. {
  101. return $this->response->getBody();
  102. }
  103. /**
  104. * Resets the last response cache.
  105. */
  106. public function resetLastResponse(): void
  107. {
  108. $this->response = new Response();
  109. }
  110. /**
  111. * Resets the attempts number.
  112. */
  113. private function resetAttemptsNumber(): void
  114. {
  115. $this->attempts = 0;
  116. }
  117. /**
  118. * Delays the retries when they're activated.
  119. */
  120. private function sleepIfNeeded(): void
  121. {
  122. if ($this->maxRetries && $this->attempts) {
  123. sleep($this->retriesDelay);
  124. }
  125. }
  126. /**
  127. * Make URLs for user browser navigation.
  128. *
  129. * @param string $path
  130. * @param array $parameters
  131. *
  132. * @return string
  133. */
  134. public function url(string $path, array $parameters): string
  135. {
  136. $this->resetLastResponse();
  137. $this->response->setApiPath($path);
  138. $query = http_build_query($parameters);
  139. return sprintf('%s/%s?%s', self::API_HOST, $path, $query);
  140. }
  141. /**
  142. * Make /oauth/* requests to the API.
  143. *
  144. * @param string $path
  145. * @param array $parameters
  146. *
  147. * @return array
  148. * @throws TwitterOAuthException
  149. */
  150. public function oauth(string $path, array $parameters = []): array
  151. {
  152. $response = [];
  153. $this->resetLastResponse();
  154. $this->response->setApiPath($path);
  155. $url = sprintf('%s/%s', self::API_HOST, $path);
  156. $result = $this->oAuthRequest($url, 'POST', $parameters);
  157. if ($this->getLastHttpCode() != 200) {
  158. throw new TwitterOAuthException($result);
  159. }
  160. parse_str($result, $response);
  161. $this->response->setBody($response);
  162. return $response;
  163. }
  164. /**
  165. * Make /oauth2/* requests to the API.
  166. *
  167. * @param string $path
  168. * @param array $parameters
  169. *
  170. * @return array|object
  171. */
  172. public function oauth2(string $path, array $parameters = [])
  173. {
  174. $method = 'POST';
  175. $this->resetLastResponse();
  176. $this->response->setApiPath($path);
  177. $url = sprintf('%s/%s', self::API_HOST, $path);
  178. $request = Request::fromConsumerAndToken(
  179. $this->consumer,
  180. $this->token,
  181. $method,
  182. $url,
  183. $parameters,
  184. );
  185. $authorization =
  186. 'Authorization: Basic ' .
  187. $this->encodeAppAuthorization($this->consumer);
  188. $result = $this->request(
  189. $request->getNormalizedHttpUrl(),
  190. $method,
  191. $authorization,
  192. $parameters,
  193. );
  194. $response = JsonDecoder::decode($result, $this->decodeJsonAsArray);
  195. $this->response->setBody($response);
  196. return $response;
  197. }
  198. /**
  199. * Make GET requests to the API.
  200. *
  201. * @param string $path
  202. * @param array $parameters
  203. *
  204. * @return array|object
  205. */
  206. public function get(string $path, array $parameters = [])
  207. {
  208. return $this->http('GET', self::API_HOST, $path, $parameters, false);
  209. }
  210. /**
  211. * Make POST requests to the API.
  212. *
  213. * @param string $path
  214. * @param array $parameters
  215. * @param bool $json
  216. *
  217. * @return array|object
  218. */
  219. public function post(
  220. string $path,
  221. array $parameters = [],
  222. bool $json = false
  223. ) {
  224. return $this->http('POST', self::API_HOST, $path, $parameters, $json);
  225. }
  226. /**
  227. * Make DELETE requests to the API.
  228. *
  229. * @param string $path
  230. * @param array $parameters
  231. *
  232. * @return array|object
  233. */
  234. public function delete(string $path, array $parameters = [])
  235. {
  236. return $this->http('DELETE', self::API_HOST, $path, $parameters, false);
  237. }
  238. /**
  239. * Make PUT requests to the API.
  240. *
  241. * @param string $path
  242. * @param array $parameters
  243. *
  244. * @return array|object
  245. */
  246. public function put(string $path, array $parameters = [])
  247. {
  248. return $this->http('PUT', self::API_HOST, $path, $parameters, false);
  249. }
  250. /**
  251. * Upload media to upload.twitter.com.
  252. *
  253. * @param string $path
  254. * @param array $parameters
  255. * @param boolean $chunked
  256. *
  257. * @return array|object
  258. */
  259. public function upload(
  260. string $path,
  261. array $parameters = [],
  262. bool $chunked = false
  263. ) {
  264. if ($chunked) {
  265. return $this->uploadMediaChunked($path, $parameters);
  266. } else {
  267. return $this->uploadMediaNotChunked($path, $parameters);
  268. }
  269. }
  270. /**
  271. * Progression of media upload
  272. *
  273. * @param string $media_id
  274. *
  275. * @return array|object
  276. */
  277. public function mediaStatus(string $media_id)
  278. {
  279. return $this->http(
  280. 'GET',
  281. self::UPLOAD_HOST,
  282. 'media/upload',
  283. [
  284. 'command' => 'STATUS',
  285. 'media_id' => $media_id,
  286. ],
  287. false,
  288. );
  289. }
  290. /**
  291. * Private method to upload media (not chunked) to upload.twitter.com.
  292. *
  293. * @param string $path
  294. * @param array $parameters
  295. *
  296. * @return array|object
  297. */
  298. private function uploadMediaNotChunked(string $path, array $parameters)
  299. {
  300. if (
  301. !is_readable($parameters['media']) ||
  302. ($file = file_get_contents($parameters['media'])) === false
  303. ) {
  304. throw new \InvalidArgumentException(
  305. 'You must supply a readable file',
  306. );
  307. }
  308. $parameters['media'] = base64_encode($file);
  309. return $this->http(
  310. 'POST',
  311. self::UPLOAD_HOST,
  312. $path,
  313. $parameters,
  314. false,
  315. );
  316. }
  317. /**
  318. * Private method to upload media (chunked) to upload.twitter.com.
  319. *
  320. * @param string $path
  321. * @param array $parameters
  322. *
  323. * @return array|object
  324. */
  325. private function uploadMediaChunked(string $path, array $parameters)
  326. {
  327. $init = $this->http(
  328. 'POST',
  329. self::UPLOAD_HOST,
  330. $path,
  331. $this->mediaInitParameters($parameters),
  332. false,
  333. );
  334. // Append
  335. $segmentIndex = 0;
  336. $media = fopen($parameters['media'], 'rb');
  337. while (!feof($media)) {
  338. $this->http(
  339. 'POST',
  340. self::UPLOAD_HOST,
  341. 'media/upload',
  342. [
  343. 'command' => 'APPEND',
  344. 'media_id' => $init->media_id_string,
  345. 'segment_index' => $segmentIndex++,
  346. 'media_data' => base64_encode(
  347. fread($media, $this->chunkSize),
  348. ),
  349. ],
  350. false,
  351. );
  352. }
  353. fclose($media);
  354. // Finalize
  355. $finalize = $this->http(
  356. 'POST',
  357. self::UPLOAD_HOST,
  358. 'media/upload',
  359. [
  360. 'command' => 'FINALIZE',
  361. 'media_id' => $init->media_id_string,
  362. ],
  363. false,
  364. );
  365. return $finalize;
  366. }
  367. /**
  368. * Private method to get params for upload media chunked init.
  369. * Twitter docs: https://dev.twitter.com/rest/reference/post/media/upload-init.html
  370. *
  371. * @param array $parameters
  372. *
  373. * @return array
  374. */
  375. private function mediaInitParameters(array $parameters): array
  376. {
  377. $allowed_keys = [
  378. 'media_type',
  379. 'additional_owners',
  380. 'media_category',
  381. 'shared',
  382. ];
  383. $base = [
  384. 'command' => 'INIT',
  385. 'total_bytes' => filesize($parameters['media']),
  386. ];
  387. $allowed_parameters = array_intersect_key(
  388. $parameters,
  389. array_flip($allowed_keys),
  390. );
  391. return array_merge($base, $allowed_parameters);
  392. }
  393. /**
  394. * Cleanup any parameters that are known not to work.
  395. *
  396. * @param array $parameters
  397. *
  398. * @return array
  399. */
  400. private function cleanUpParameters(array $parameters)
  401. {
  402. foreach ($parameters as $key => $value) {
  403. // PHP coerces `true` to `"1"` which some Twitter APIs don't like.
  404. if (is_bool($value)) {
  405. $parameters[$key] = var_export($value, true);
  406. }
  407. }
  408. return $parameters;
  409. }
  410. /**
  411. * Get URL extension for current API Version.
  412. *
  413. * @return string
  414. */
  415. private function extension()
  416. {
  417. return [
  418. '1.1' => '.json',
  419. '2' => '',
  420. ][$this->apiVersion];
  421. }
  422. /**
  423. * @param string $method
  424. * @param string $host
  425. * @param string $path
  426. * @param array $parameters
  427. * @param bool $json
  428. *
  429. * @return array|object
  430. */
  431. private function http(
  432. string $method,
  433. string $host,
  434. string $path,
  435. array $parameters,
  436. bool $json
  437. ) {
  438. $this->resetLastResponse();
  439. $this->resetAttemptsNumber();
  440. $this->response->setApiPath($path);
  441. if (!$json) {
  442. $parameters = $this->cleanUpParameters($parameters);
  443. }
  444. return $this->makeRequests(
  445. $this->apiUrl($host, $path),
  446. $method,
  447. $parameters,
  448. $json,
  449. );
  450. }
  451. /**
  452. * Generate API URL.
  453. *
  454. * Overriding this function is not supported and may cause unintended issues.
  455. *
  456. * @param string $host
  457. * @param string $path
  458. *
  459. * @return string
  460. */
  461. protected function apiUrl(string $host, string $path)
  462. {
  463. return sprintf(
  464. '%s/%s/%s%s',
  465. $host,
  466. $this->apiVersion,
  467. $path,
  468. $this->extension(),
  469. );
  470. }
  471. /**
  472. *
  473. * Make requests and retry them (if enabled) in case of Twitter's problems.
  474. *
  475. * @param string $method
  476. * @param string $url
  477. * @param string $method
  478. * @param array $parameters
  479. * @param bool $json
  480. *
  481. * @return array|object
  482. */
  483. private function makeRequests(
  484. string $url,
  485. string $method,
  486. array $parameters,
  487. bool $json
  488. ) {
  489. do {
  490. $this->sleepIfNeeded();
  491. $result = $this->oAuthRequest($url, $method, $parameters, $json);
  492. $response = JsonDecoder::decode($result, $this->decodeJsonAsArray);
  493. $this->response->setBody($response);
  494. $this->attempts++;
  495. // Retry up to our $maxRetries number if we get errors greater than 500 (over capacity etc)
  496. } while ($this->requestsAvailable());
  497. return $response;
  498. }
  499. /**
  500. * Checks if we have to retry request if API is down.
  501. *
  502. * @return bool
  503. */
  504. private function requestsAvailable(): bool
  505. {
  506. return $this->maxRetries &&
  507. $this->attempts <= $this->maxRetries &&
  508. $this->getLastHttpCode() >= 500;
  509. }
  510. /**
  511. * Format and sign an OAuth / API request
  512. *
  513. * @param string $url
  514. * @param string $method
  515. * @param array $parameters
  516. * @param bool $json
  517. *
  518. * @return string
  519. * @throws TwitterOAuthException
  520. */
  521. private function oAuthRequest(
  522. string $url,
  523. string $method,
  524. array $parameters,
  525. bool $json = false
  526. ) {
  527. $request = Request::fromConsumerAndToken(
  528. $this->consumer,
  529. $this->token,
  530. $method,
  531. $url,
  532. $parameters,
  533. $json,
  534. );
  535. if (array_key_exists('oauth_callback', $parameters)) {
  536. // Twitter doesn't like oauth_callback as a parameter.
  537. unset($parameters['oauth_callback']);
  538. }
  539. if ($this->bearer === null) {
  540. $request->signRequest(
  541. $this->signatureMethod,
  542. $this->consumer,
  543. $this->token,
  544. );
  545. $authorization = $request->toHeader();
  546. if (array_key_exists('oauth_verifier', $parameters)) {
  547. // Twitter doesn't always work with oauth in the body and in the header
  548. // and it's already included in the $authorization header
  549. unset($parameters['oauth_verifier']);
  550. }
  551. } else {
  552. $authorization = 'Authorization: Bearer ' . $this->bearer;
  553. }
  554. return $this->request(
  555. $request->getNormalizedHttpUrl(),
  556. $method,
  557. $authorization,
  558. $parameters,
  559. $json,
  560. );
  561. }
  562. /**
  563. * Set Curl options.
  564. *
  565. * @return array
  566. */
  567. private function curlOptions(): array
  568. {
  569. $bundlePath = CaBundle::getSystemCaRootBundlePath();
  570. $options = [
  571. // CURLOPT_VERBOSE => true,
  572. CURLOPT_CONNECTTIMEOUT => $this->connectionTimeout,
  573. CURLOPT_HEADER => true,
  574. CURLOPT_RETURNTRANSFER => true,
  575. CURLOPT_SSL_VERIFYHOST => 2,
  576. CURLOPT_SSL_VERIFYPEER => true,
  577. CURLOPT_TIMEOUT => $this->timeout,
  578. CURLOPT_USERAGENT => $this->userAgent,
  579. $this->curlCaOpt($bundlePath) => $bundlePath,
  580. ];
  581. if ($this->gzipEncoding) {
  582. $options[CURLOPT_ENCODING] = 'gzip';
  583. }
  584. if (!empty($this->proxy)) {
  585. $options[CURLOPT_PROXY] = $this->proxy['CURLOPT_PROXY'];
  586. $options[CURLOPT_PROXYUSERPWD] =
  587. $this->proxy['CURLOPT_PROXYUSERPWD'];
  588. $options[CURLOPT_PROXYPORT] = $this->proxy['CURLOPT_PROXYPORT'];
  589. $options[CURLOPT_PROXYAUTH] = CURLAUTH_BASIC;
  590. $options[CURLOPT_PROXYTYPE] = CURLPROXY_HTTP;
  591. }
  592. return $options;
  593. }
  594. /**
  595. * Make an HTTP request
  596. *
  597. * @param string $url
  598. * @param string $method
  599. * @param string $authorization
  600. * @param array $postfields
  601. * @param bool $json
  602. *
  603. * @return string
  604. * @throws TwitterOAuthException
  605. */
  606. private function request(
  607. string $url,
  608. string $method,
  609. string $authorization,
  610. array $postfields,
  611. bool $json = false
  612. ): string {
  613. $options = $this->curlOptions();
  614. $options[CURLOPT_URL] = $url;
  615. $options[CURLOPT_HTTPHEADER] = [
  616. 'Accept: application/json',
  617. $authorization,
  618. 'Expect:',
  619. ];
  620. switch ($method) {
  621. case 'GET':
  622. break;
  623. case 'POST':
  624. $options[CURLOPT_POST] = true;
  625. if ($json) {
  626. $options[CURLOPT_HTTPHEADER][] =
  627. 'Content-type: application/json';
  628. $options[CURLOPT_POSTFIELDS] = json_encode($postfields);
  629. } else {
  630. $options[CURLOPT_POSTFIELDS] = Util::buildHttpQuery(
  631. $postfields,
  632. );
  633. }
  634. break;
  635. case 'DELETE':
  636. $options[CURLOPT_CUSTOMREQUEST] = 'DELETE';
  637. break;
  638. case 'PUT':
  639. $options[CURLOPT_CUSTOMREQUEST] = 'PUT';
  640. break;
  641. }
  642. if (
  643. in_array($method, ['GET', 'PUT', 'DELETE']) &&
  644. !empty($postfields)
  645. ) {
  646. $options[CURLOPT_URL] .= '?' . Util::buildHttpQuery($postfields);
  647. }
  648. $curlHandle = curl_init();
  649. curl_setopt_array($curlHandle, $options);
  650. $response = curl_exec($curlHandle);
  651. // Throw exceptions on cURL errors.
  652. if (curl_errno($curlHandle) > 0) {
  653. $error = curl_error($curlHandle);
  654. $errorNo = curl_errno($curlHandle);
  655. curl_close($curlHandle);
  656. throw new TwitterOAuthException($error, $errorNo);
  657. }
  658. $this->response->setHttpCode(
  659. curl_getinfo($curlHandle, CURLINFO_HTTP_CODE),
  660. );
  661. $parts = explode("\r\n\r\n", $response);
  662. $responseBody = array_pop($parts);
  663. $responseHeader = array_pop($parts);
  664. $this->response->setHeaders($this->parseHeaders($responseHeader));
  665. curl_close($curlHandle);
  666. return $responseBody;
  667. }
  668. /**
  669. * Get the header info to store.
  670. *
  671. * @param string $header
  672. *
  673. * @return array
  674. */
  675. private function parseHeaders(string $header): array
  676. {
  677. $headers = [];
  678. foreach (explode("\r\n", $header) as $line) {
  679. if (strpos($line, ':') !== false) {
  680. [$key, $value] = explode(': ', $line);
  681. $key = str_replace('-', '_', strtolower($key));
  682. $headers[$key] = trim($value);
  683. }
  684. }
  685. return $headers;
  686. }
  687. /**
  688. * Encode application authorization header with base64.
  689. *
  690. * @param Consumer $consumer
  691. *
  692. * @return string
  693. */
  694. private function encodeAppAuthorization(Consumer $consumer): string
  695. {
  696. $key = rawurlencode($consumer->key);
  697. $secret = rawurlencode($consumer->secret);
  698. return base64_encode($key . ':' . $secret);
  699. }
  700. /**
  701. * Get Curl CA option based on whether the given path is a directory or file.
  702. *
  703. * @param string $path
  704. * @return int
  705. */
  706. private function curlCaOpt(string $path): int
  707. {
  708. return is_dir($path) ? CURLOPT_CAPATH : CURLOPT_CAINFO;
  709. }
  710. }