SigningHelper.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885
  1. <?php
  2. /**
  3. * Copyright 2019 Google LLC
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain 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,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. namespace Google\Cloud\Storage;
  18. use Google\Auth\CredentialsLoader;
  19. use Google\Auth\SignBlobInterface;
  20. use Google\Cloud\Core\ArrayTrait;
  21. use Google\Cloud\Core\JsonTrait;
  22. use Google\Cloud\Core\Timestamp;
  23. use Google\Cloud\Storage\Connection\ConnectionInterface;
  24. /**
  25. * Provides common methods for signing storage URLs.
  26. *
  27. * @internal
  28. */
  29. class SigningHelper
  30. {
  31. use ArrayTrait;
  32. use JsonTrait;
  33. const DEFAULT_URL_SIGNING_VERSION = 'v2';
  34. const DEFAULT_DOWNLOAD_HOST = 'storage.googleapis.com';
  35. const V4_ALGO_NAME = 'GOOG4-RSA-SHA256';
  36. const V4_TIMESTAMP_FORMAT = 'Ymd\THis\Z';
  37. const V4_DATESTAMP_FORMAT = 'Ymd';
  38. /**
  39. * Create or fetch a SigningHelper instance.
  40. *
  41. * @return SigningHelper
  42. */
  43. public static function getHelper()
  44. {
  45. static $helper;
  46. if (!$helper) {
  47. $helper = new static;
  48. }
  49. return $helper;
  50. }
  51. /**
  52. * Sign using the version inferred from `$options.version`.
  53. *
  54. * @param ConnectionInterface $connection A connection to the Cloud Storage
  55. * API. This object is created by StorageClient,
  56. * and should not be instantiated outside of this client.
  57. * @param Timestamp|\DateTimeInterface|int $expires The signed URL
  58. * expiration.
  59. * @param string $resource The URI to the storage resource, preceded by a
  60. * leading slash.
  61. * @param int|null $generation The resource generation.
  62. * @param array $options Configuration options. See
  63. * {@see StorageObject::signedUrl()} for
  64. * details.
  65. * @return string
  66. * @throws \InvalidArgumentException
  67. * @throws \RuntimeException If required data could not be gathered from
  68. * credentials.
  69. * @throws \RuntimeException If OpenSSL signing is required by user input
  70. * and OpenSSL is not available.
  71. */
  72. public function sign(ConnectionInterface $connection, $expires, $resource, $generation, array $options)
  73. {
  74. $version = $options['version'] ?? self::DEFAULT_URL_SIGNING_VERSION;
  75. unset($options['version']);
  76. switch (strtolower($version)) {
  77. case 'v2':
  78. $method = 'v2Sign';
  79. break;
  80. case 'v4':
  81. $method = 'v4Sign';
  82. break;
  83. default:
  84. throw new \InvalidArgumentException('Invalid signing version.');
  85. }
  86. return call_user_func_array([$this, $method], [
  87. $connection,
  88. $expires,
  89. $resource,
  90. $generation,
  91. $options
  92. ]);
  93. }
  94. /**
  95. * Sign a URL using Google Signed URLs v2.
  96. *
  97. * This method will be deprecated in the future.
  98. *
  99. * @param ConnectionInterface $connection A connection to the Cloud Storage
  100. * API. This object is created by StorageClient,
  101. * and should not be instantiated outside of this client.
  102. * @param Timestamp|\DateTimeInterface|int $expires The signed URL
  103. * expiration.
  104. * @param string $resource The URI to the storage resource, preceded by a
  105. * leading slash.
  106. * @param int|null $generation The resource generation.
  107. * @param array $options Configuration options. See
  108. * {@see StorageObject::signedUrl()} for
  109. * details.
  110. * @return string
  111. * @throws \InvalidArgumentException
  112. * @throws \RuntimeException If required data could not be gathered from
  113. * credentials.
  114. * @throws \RuntimeException If OpenSSL signing is required by user input
  115. * and OpenSSL is not available.
  116. */
  117. public function v2Sign(ConnectionInterface $connection, $expires, $resource, $generation, array $options)
  118. {
  119. list($credentials, $options) = $this->getSigningCredentials($connection, $options);
  120. $expires = $this->normalizeExpiration($expires);
  121. list($resource, $bucket) = $this->normalizeResource($resource);
  122. $options = $this->normalizeOptions($options);
  123. $headers = $this->normalizeHeaders($options['headers']);
  124. if ($options['virtualHostedStyle']) {
  125. $options['bucketBoundHostname'] = sprintf(
  126. '%s.storage.googleapis.com',
  127. $bucket
  128. );
  129. }
  130. // Make sure disallowed headers are not included.
  131. $illegalHeaders = [
  132. 'x-goog-encryption-key',
  133. 'x-goog-encryption-key-sha256'
  134. ];
  135. if ($illegal = array_intersect_key(array_flip($illegalHeaders), $headers)) {
  136. throw new \InvalidArgumentException(sprintf(
  137. '%s %s not allowed in Signed URL headers.',
  138. implode(' and ', array_keys($illegal)),
  139. count($illegal) === 1 ? 'is' : 'are'
  140. ));
  141. }
  142. // Sort headers by name.
  143. ksort($headers);
  144. $toSign = [
  145. $options['method'],
  146. $options['contentMd5'],
  147. $options['contentType'],
  148. $expires,
  149. ];
  150. $signedHeaders = [];
  151. foreach ($headers as $name => $value) {
  152. $signedHeaders[] = $name .':'. $value;
  153. }
  154. // Push the headers onto the end of the signing string.
  155. if ($signedHeaders) {
  156. $toSign = array_merge($toSign, $signedHeaders);
  157. }
  158. $toSign[] = $resource;
  159. $stringToSign = $this->createV2CanonicalRequest($toSign);
  160. $signature = $credentials->signBlob($stringToSign, [
  161. 'forceOpenssl' => $options['forceOpenssl']
  162. ]);
  163. // Start with user-provided query params and add required parameters.
  164. $params = $options['queryParams'];
  165. $params['GoogleAccessId'] = $credentials->getClientName();
  166. $params['Expires'] = $expires;
  167. $params['Signature'] = $signature;
  168. // urlencode parameter values
  169. foreach ($params as &$value) {
  170. $value = rawurlencode($value);
  171. }
  172. $params = $this->addCommonParams($generation, $params, $options);
  173. $queryString = $this->buildQueryString($params);
  174. $resource = $this->normalizeUriPath($options['bucketBoundHostname'], $resource);
  175. return 'https://' . $options['bucketBoundHostname'] . $resource . '?' . $queryString;
  176. }
  177. /**
  178. * Sign a storage URL using Google Signed URLs v4.
  179. *
  180. * @param ConnectionInterface $connection A connection to the Cloud Storage
  181. * API. This object is created by StorageClient,
  182. * and should not be instantiated outside of this client.
  183. * @param Timestamp|\DateTimeInterface|int $expires The signed URL
  184. * expiration.
  185. * @param string $resource The URI to the storage resource, preceded by a
  186. * leading slash.
  187. * @param int|null $generation The resource generation.
  188. * @param array $options Configuration options. See
  189. * {@see StorageObject::signedUrl()} for
  190. * details.
  191. * @return string
  192. * @throws \InvalidArgumentException
  193. * @throws \RuntimeException If required data could not be gathered from
  194. * credentials.
  195. * @throws \RuntimeException If OpenSSL signing is required by user input
  196. * and OpenSSL is not available.
  197. */
  198. public function v4Sign(ConnectionInterface $connection, $expires, $resource, $generation, array $options)
  199. {
  200. list($credentials, $options) = $this->getSigningCredentials($connection, $options);
  201. $expires = $this->normalizeExpiration($expires);
  202. list($resource, $bucket) = $this->normalizeResource($resource);
  203. $options = $this->normalizeOptions($options);
  204. $time = $options['timestamp'];
  205. $requestTimestamp = $time->format(self::V4_TIMESTAMP_FORMAT);
  206. $requestDatestamp = $time->format(self::V4_DATESTAMP_FORMAT);
  207. $timeSeconds = $time->format('U');
  208. $expireLimit = $timeSeconds + 604800;
  209. if ($expires > $expireLimit) {
  210. throw new \InvalidArgumentException(
  211. 'V4 Signed URLs may not have an expiration greater than seven days in the future.'
  212. );
  213. }
  214. $clientEmail = $credentials->getClientName();
  215. $credentialScope = sprintf('%s/auto/storage/goog4_request', $requestDatestamp);
  216. $credential = sprintf('%s/%s', $clientEmail, $credentialScope);
  217. if ($options['virtualHostedStyle']) {
  218. $options['bucketBoundHostname'] = sprintf(
  219. '%s.storage.googleapis.com',
  220. $bucket
  221. );
  222. }
  223. // Add headers and query params based on provided options.
  224. $params = $options['queryParams'];
  225. $headers = $options['headers'] + [
  226. 'host' => $options['bucketBoundHostname']
  227. ];
  228. if ($options['contentType']) {
  229. $headers['content-type'] = $options['contentType'];
  230. }
  231. if ($options['contentMd5']) {
  232. $headers['content-md5'] = $options['contentMd5'];
  233. }
  234. $params = $this->addCommonParams($generation, $params, $options);
  235. $headers = $this->normalizeHeaders($headers);
  236. // sort headers by name
  237. ksort($headers, SORT_NATURAL | SORT_FLAG_CASE);
  238. // Canonical headers are a list, newline separated, of keys and values,
  239. // comma separated.
  240. // Signed headers are a list of keys, separated by a semicolon.
  241. $canonicalHeaders = [];
  242. $signedHeaders = [];
  243. foreach ($headers as $key => $val) {
  244. $canonicalHeaders[] = sprintf('%s:%s', $key, $val);
  245. $signedHeaders[] = $key;
  246. }
  247. $canonicalHeaders = implode("\n", $canonicalHeaders) . "\n";
  248. $signedHeaders = implode(';', $signedHeaders);
  249. // Add required query parameters.
  250. $params = [
  251. 'X-Goog-Algorithm' => self::V4_ALGO_NAME,
  252. 'X-Goog-Credential' => $credential,
  253. 'X-Goog-Date' => $requestTimestamp,
  254. 'X-Goog-Expires' => $expires - $timeSeconds,
  255. 'X-Goog-SignedHeaders' => $signedHeaders,
  256. ] + $params;
  257. $paramNames = [];
  258. foreach ($params as $key => $val) {
  259. $paramNames[] = $key;
  260. }
  261. sort($paramNames, SORT_REGULAR);
  262. $sortedParams = [];
  263. foreach ($paramNames as $name) {
  264. $sortedParams[rawurlencode($name)] = rawurlencode($params[$name]);
  265. }
  266. $canonicalQueryString = $this->buildQueryString($sortedParams);
  267. $canonicalResource = $this->normalizeCanonicalRequestResource(
  268. $resource,
  269. $options['bucketBoundHostname'],
  270. $options['virtualHostedStyle']
  271. );
  272. $canonicalRequest = [
  273. $options['method'],
  274. $canonicalResource,
  275. $canonicalQueryString,
  276. $canonicalHeaders,
  277. $signedHeaders,
  278. $this->getPayloadHash($headers)
  279. ];
  280. $requestHash = $this->createV4CanonicalRequest($canonicalRequest);
  281. // Construct the string to sign.
  282. $stringToSign = implode("\n", [
  283. self::V4_ALGO_NAME,
  284. $requestTimestamp,
  285. $credentialScope,
  286. $requestHash
  287. ]);
  288. $signature = bin2hex(base64_decode($credentials->signBlob($stringToSign, [
  289. 'forceOpenssl' => $options['forceOpenssl']
  290. ])));
  291. // Construct the modified resource name. If a custom hostname is provided,
  292. // this will remove the bucket name from the resource.
  293. $resource = $this->normalizeUriPath($options['bucketBoundHostname'], $resource);
  294. $scheme = $this->chooseScheme(
  295. $options['scheme'],
  296. $options['bucketBoundHostname'],
  297. $options['virtualHostedStyle']
  298. );
  299. return sprintf(
  300. '%s://%s%s?%s&X-Goog-Signature=%s',
  301. $scheme,
  302. $options['bucketBoundHostname'],
  303. $resource,
  304. $canonicalQueryString,
  305. $signature
  306. );
  307. }
  308. /**
  309. * Create an HTTP POST policy using v4 signing.
  310. *
  311. * @param ConnectionInterface $connection A Connection to Google Cloud Storage.
  312. * This object is created by StorageClient,
  313. * and should not be instantiated outside of this client.
  314. * @param Timestamp|\DateTimeInterface|int $expires The signed URL
  315. * expiration.
  316. * @param string $resource The URI to the storage resource, preceded by a
  317. * leading slash.
  318. * @param array $options Configuration options. See
  319. * {@see Bucket::generateSignedPostPolicyV4()} for details.
  320. * @return array An associative array, containing (string) `uri` and
  321. * (array) `fields` keys.
  322. */
  323. public function v4PostPolicy(
  324. ConnectionInterface $connection,
  325. $expires,
  326. $resource,
  327. array $options = []
  328. ) {
  329. list($credentials, $options) = $this->getSigningCredentials($connection, $options);
  330. $expires = $this->normalizeExpiration($expires);
  331. list($resource, $bucket, $object) = $this->normalizeResource($resource, false);
  332. $object = trim($object, '/');
  333. $options = $this->normalizeOptions($options) + [
  334. 'fields' => [],
  335. 'conditions' => [],
  336. 'successActionRedirect' => null,
  337. 'successActionStatus' => null
  338. ];
  339. $time = $options['timestamp'];
  340. $requestTimestamp = $time->format(self::V4_TIMESTAMP_FORMAT);
  341. $requestDatestamp = $time->format(self::V4_DATESTAMP_FORMAT);
  342. $expiration = \DateTimeImmutable::createFromFormat('U', (string) $expires);
  343. $expirationTimestamp = str_replace(
  344. '+00:00',
  345. 'Z',
  346. $expiration->format(\DateTime::RFC3339)
  347. );
  348. $clientEmail = $credentials->getClientName();
  349. $credentialScope = sprintf('%s/auto/storage/goog4_request', $requestDatestamp);
  350. $credential = sprintf('%s/%s', $clientEmail, $credentialScope);
  351. if ($options['virtualHostedStyle']) {
  352. $options['bucketBoundHostname'] = sprintf(
  353. '%s.storage.googleapis.com',
  354. $bucket
  355. );
  356. }
  357. $fields = array_merge($options['fields'], [
  358. 'key' => $object,
  359. 'x-goog-algorithm' => self::V4_ALGO_NAME,
  360. 'x-goog-credential' => $credential,
  361. 'x-goog-date' => $requestTimestamp
  362. ]);
  363. $conditions = $options['conditions'];
  364. foreach ($options['fields'] as $key => $value) {
  365. $conditions[] = [$key => $value];
  366. }
  367. foreach ($conditions as $key => $value) {
  368. $key = $key;
  369. $value = $value;
  370. $conditions[$key] = $value;
  371. }
  372. $conditions = array_merge($conditions, [
  373. ['bucket' => $bucket],
  374. ['key' => $object],
  375. ['x-goog-date' => $requestTimestamp],
  376. ['x-goog-credential' => $credential],
  377. ['x-goog-algorithm' => self::V4_ALGO_NAME],
  378. ]);
  379. $policy = [
  380. 'conditions' => $conditions,
  381. 'expiration' => $expirationTimestamp
  382. ];
  383. $json = str_replace('\\\u', '\\u', json_encode($policy, JSON_UNESCAPED_SLASHES));
  384. $stringToSign = base64_encode($json);
  385. $signature = bin2hex(base64_decode($credentials->signBlob($stringToSign, [
  386. 'forceOpenssl' => $options['forceOpenssl']
  387. ])));
  388. $fields['x-goog-signature'] = $signature;
  389. $fields['policy'] = $stringToSign;
  390. // Construct the modified resource name. If a custom hostname is provided,
  391. // this will remove the bucket name from the resource.
  392. $resource = $this->normalizeUriPath($options['bucketBoundHostname'], '/' . $bucket, true);
  393. $scheme = $this->chooseScheme(
  394. $options['scheme'],
  395. $options['bucketBoundHostname'],
  396. $options['virtualHostedStyle']
  397. );
  398. return [
  399. 'url' => sprintf(
  400. '%s://%s%s',
  401. $scheme,
  402. $options['bucketBoundHostname'],
  403. $resource
  404. ),
  405. 'fields' => $fields
  406. ];
  407. }
  408. /**
  409. * Creates a canonical request hash for a V4 Signed URL.
  410. *
  411. * NOTE: While in most cases `PHP_EOL` is preferable to a system-specific
  412. * character, in this case `\n` is required.
  413. *
  414. * @param array $canonicalRequest The canonical request, with each element
  415. * representing a line in the request.
  416. * @return string
  417. */
  418. private function createV4CanonicalRequest(array $canonicalRequest)
  419. {
  420. $canonicalRequestString = implode("\n", $canonicalRequest);
  421. return bin2hex(hash('sha256', $canonicalRequestString, true));
  422. }
  423. /**
  424. * Creates a canonical request for a V2 Signed URL.
  425. *
  426. * NOTE: While in most cases `PHP_EOL` is preferable to a system-specific
  427. * character, in this case `\n` is required.
  428. *
  429. * @param array $canonicalRequest The canonical request, with each element
  430. * representing a line in the request.
  431. * @return string
  432. */
  433. private function createV2CanonicalRequest(array $canonicalRequest)
  434. {
  435. return implode("\n", $canonicalRequest);
  436. }
  437. /**
  438. * Choose the correct URL scheme.
  439. *
  440. * @param string $scheme The scheme provided by the user or defaults.
  441. * @param string $bucketBoundHostname The bucketBoundHostname provided by the user or defaults.
  442. * @param bool $virtualHostedStyle Whether virtual host style is enabled.
  443. * @return string
  444. */
  445. private function chooseScheme($scheme, $bucketBoundHostname, $virtualHostedStyle = false)
  446. {
  447. // bucketBoundHostname not used -- always https.
  448. if ($bucketBoundHostname === self::DEFAULT_DOWNLOAD_HOST) {
  449. return 'https';
  450. }
  451. // virtualHostedStyle enabled -- always https.
  452. if ($virtualHostedStyle) {
  453. return 'https';
  454. }
  455. // not virtual hosted style, and custom hostname -- use default (http) or user choice.
  456. return $scheme;
  457. }
  458. /**
  459. * If `X-Goog-Content-SHA256` header is provided, use that as the payload.
  460. * Otherwise, `UNSIGNED-PAYLOAD`.
  461. *
  462. * @param array $headers
  463. * @return string
  464. */
  465. private function getPayloadHash(array $headers)
  466. {
  467. if (!isset($headers['x-goog-content-sha256'])) {
  468. return 'UNSIGNED-PAYLOAD';
  469. }
  470. return $headers['x-goog-content-sha256'];
  471. }
  472. /**
  473. * Normalizes and validates an expiration.
  474. *
  475. * @param Timestamp|\DateTimeInterface|int $expires The expiration
  476. * @return int
  477. * @throws \InvalidArgumentException If an invalid value is given.
  478. */
  479. private function normalizeExpiration($expires)
  480. {
  481. if ($expires instanceof Timestamp) {
  482. $seconds = $expires->get()->format('U');
  483. } elseif ($expires instanceof \DateTimeInterface) {
  484. $seconds = $expires->format('U');
  485. } elseif (is_numeric($expires)) {
  486. $seconds = (int) $expires;
  487. } else {
  488. throw new \InvalidArgumentException('Invalid expiration.');
  489. }
  490. return $seconds;
  491. }
  492. /**
  493. * Normalizes and encodes the resource identifier.
  494. *
  495. * @param string $resource The resource identifier. In form
  496. * `[/]$bucket/$object`.
  497. * @return array A list, where index 0 is the resource path, with pieces
  498. * encoded and prefixed with a forward slash, index 1 is the bucket
  499. * name, and index 2 is the object name, relative to the bucket.
  500. */
  501. private function normalizeResource($resource, $urlencode = true)
  502. {
  503. $pieces = explode('/', trim($resource, '/'));
  504. if ($urlencode) {
  505. array_walk($pieces, function (&$piece) {
  506. $piece = rawurlencode($piece);
  507. });
  508. }
  509. $bucket = $pieces[0];
  510. $relative = $pieces;
  511. array_shift($relative);
  512. return [
  513. '/' . implode('/', $pieces),
  514. $bucket,
  515. '/' . implode('/', $relative),
  516. ];
  517. }
  518. /**
  519. * Fixes the user input options, filters and validates data.
  520. *
  521. * @param array $options Signed URL configuration options.
  522. * @return array
  523. * @throws \InvalidArgumentException
  524. */
  525. private function normalizeOptions(array $options)
  526. {
  527. $options += [
  528. 'allowPost' => false,
  529. 'cname' => null, //@deprecated
  530. 'bucketBoundHostname' => self::DEFAULT_DOWNLOAD_HOST,
  531. 'contentMd5' => null,
  532. 'contentType' => null,
  533. 'forceOpenssl' => false,
  534. 'headers' => [],
  535. 'keyFile' => null,
  536. 'keyFilePath' => null,
  537. 'method' => 'GET',
  538. 'queryParams' => [],
  539. 'responseDisposition' => null,
  540. 'responseType' => null,
  541. 'saveAsName' => null,
  542. // note that in almost every case this default will be overridden.
  543. 'scheme' => 'http',
  544. 'timestamp' => null,
  545. 'virtualHostedStyle' => false,
  546. ];
  547. $allowedMethods = ['GET', 'PUT', 'POST', 'DELETE'];
  548. $options['method'] = strtoupper($options['method']);
  549. if (!in_array($options['method'], $allowedMethods)) {
  550. throw new \InvalidArgumentException('$options.method must be one of `GET`, `PUT` or `DELETE`.');
  551. }
  552. if ($options['method'] === 'POST' && !$options['allowPost']) {
  553. throw new \InvalidArgumentException(
  554. 'Invalid method. To create an upload URI, use StorageObject::signedUploadUrl().'
  555. );
  556. }
  557. // Rewrite deprecated `cname` to new `bucketBoundHostname`.
  558. if ($options['cname'] && $options['bucketBoundHostname'] === self::DEFAULT_DOWNLOAD_HOST) {
  559. $options['bucketBoundHostname'] = $options['cname'];
  560. }
  561. // strip protocol from hostname.
  562. $hostnameParts = explode('//', $options['bucketBoundHostname']);
  563. if (count($hostnameParts) > 1) {
  564. $options['bucketBoundHostname'] = $hostnameParts[1];
  565. }
  566. $options['bucketBoundHostname'] = trim($options['bucketBoundHostname'], '/');
  567. // If a timestamp is provided, use it in place of `now` for v4 URLs only..
  568. // This option exists for testing purposes, and should not generally be provided by users.
  569. if ($options['timestamp']) {
  570. if (!($options['timestamp'] instanceof \DateTimeInterface)) {
  571. if (!is_string($options['timestamp'])) {
  572. throw new \InvalidArgumentException(
  573. 'User-provided timestamps must be a string or instance of `\DateTimeInterface`.'
  574. );
  575. }
  576. $options['timestamp'] = \DateTimeImmutable::createFromFormat(
  577. \DateTime::RFC3339,
  578. $options['timestamp'],
  579. new \DateTimeZone('UTC')
  580. );
  581. if (!$options['timestamp']) {
  582. throw new \InvalidArgumentException(
  583. 'Given timestamp string is in an invalid format. Provide timestamp formatted as follows: `' .
  584. \DateTime::RFC3339 .
  585. '`. Note that timestamps MUST be in UTC.'
  586. );
  587. }
  588. }
  589. } else {
  590. $options['timestamp'] = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
  591. }
  592. unset(
  593. $options['cname'],
  594. $options['allowPost']
  595. );
  596. return $options;
  597. }
  598. /**
  599. * Cleans and normalizes header values.
  600. *
  601. * Arrays of values are collapsed into a comma-separated list, trailing and
  602. * leading spaces are removed, newlines are replaced by empty strings, and
  603. * multiple whitespace chars are replaced by a single space.
  604. *
  605. * @param array $headers Input headers
  606. * @return array
  607. */
  608. private function normalizeHeaders(array $headers)
  609. {
  610. $out = [];
  611. foreach ($headers as $name => $value) {
  612. $name = strtolower(trim($name));
  613. // collapse arrays of values into a comma-separated list.
  614. if (!is_array($value)) {
  615. $value = [$value];
  616. }
  617. foreach ($value as &$headerValue) {
  618. // strip trailing and leading spaces.
  619. $headerValue = trim($headerValue);
  620. // replace newlines with empty strings.
  621. $headerValue = str_replace(PHP_EOL, '', $headerValue);
  622. // collapse multiple whitespace chars to a single space.
  623. $headerValue = preg_replace('/[\s]+/', ' ', $headerValue);
  624. }
  625. $out[$name] = implode(', ', $value);
  626. }
  627. return $out;
  628. }
  629. /**
  630. * Returns a resource formatted for use in a URI.
  631. *
  632. * If the bucketBoundHostname is other than the default, will omit the bucket name.
  633. *
  634. * @param string $bucketBoundHostname The bucketBoundHostname provided by the user, or the default
  635. * value.
  636. * @param string $resource The GCS resource path (i.e. /bucket/object).
  637. * @return string
  638. */
  639. private function normalizeUriPath($bucketBoundHostname, $resource, $withTrailingSlash = false)
  640. {
  641. if ($bucketBoundHostname !== self::DEFAULT_DOWNLOAD_HOST) {
  642. $resourceParts = explode('/', trim($resource, '/'));
  643. array_shift($resourceParts);
  644. // Resource is a Bucket.
  645. if (empty($resourceParts)) {
  646. $resource = '/';
  647. } else {
  648. $resource = '/' . implode('/', $resourceParts);
  649. }
  650. }
  651. $resource = rtrim($resource, '/');
  652. return $withTrailingSlash
  653. ? $resource . '/'
  654. : $resource;
  655. }
  656. /**
  657. * Normalize the resource provided to the canonical request string.
  658. *
  659. * @param string $resource
  660. * @param string $bucketBoundHostname
  661. * @param boolean $virtualHostedStyle
  662. * @return string
  663. */
  664. private function normalizeCanonicalRequestResource($resource, $bucketBoundHostname, $virtualHostedStyle = false)
  665. {
  666. if ($bucketBoundHostname === self::DEFAULT_DOWNLOAD_HOST && !$virtualHostedStyle) {
  667. return $resource;
  668. }
  669. $pieces = explode('/', trim($resource, '/'));
  670. array_shift($pieces);
  671. return '/' . implode('/', $pieces);
  672. }
  673. /**
  674. * Get the credentials for use with signing.
  675. *
  676. * @param ConnectionInterface $connection A Storage connection object.
  677. * This object is created by StorageClient,
  678. * and should not be instantiated outside of this client.
  679. * @param array $options Configuration options.
  680. * @return array A list containing a credentials object at index 0 and the
  681. * modified options at index 1.
  682. * @throws \RuntimeException If the credentials type is not valid for signing.
  683. * @throws \InvalidArgumentException If a keyfile is given and is not valid.
  684. */
  685. private function getSigningCredentials(ConnectionInterface $connection, array $options)
  686. {
  687. $keyFilePath = $options['keyFilePath'] ?? null;
  688. if ($keyFilePath) {
  689. if (!file_exists($keyFilePath)) {
  690. throw new \InvalidArgumentException(sprintf(
  691. 'Keyfile path %s does not exist.',
  692. $keyFilePath
  693. ));
  694. }
  695. $options['keyFile'] = self::jsonDecode(file_get_contents($keyFilePath), true);
  696. }
  697. $rw = $connection->requestWrapper();
  698. $keyFile = $options['keyFile'] ?? null;
  699. if ($keyFile) {
  700. $scopes = $options['scopes'] ?? $rw->scopes();
  701. $credentials = CredentialsLoader::makeCredentials($scopes, $keyFile);
  702. } else {
  703. $credentials = $rw->getCredentialsFetcher();
  704. }
  705. //@codeCoverageIgnoreStart
  706. if (!($credentials instanceof SignBlobInterface)) {
  707. throw new \RuntimeException(sprintf(
  708. 'Credentials object is of type `%s` and is not valid for signing.',
  709. get_class($credentials)
  710. ));
  711. }
  712. //@codeCoverageIgnoreEnd
  713. unset(
  714. $options['keyFilePath'],
  715. $options['keyFile'],
  716. $options['scopes']
  717. );
  718. return [$credentials, $options];
  719. }
  720. /**
  721. * Add parameters common to all signed URL versions.
  722. *
  723. * @param int|null $generation
  724. * @param array $params
  725. * @param array $options
  726. * @return array
  727. */
  728. private function addCommonParams($generation, array $params, array $options)
  729. {
  730. if ($options['responseType']) {
  731. $params['response-content-type'] = $options['responseType'];
  732. }
  733. if ($options['responseDisposition']) {
  734. $params['response-content-disposition'] = $options['responseDisposition'];
  735. } elseif ($options['saveAsName']) {
  736. $params['response-content-disposition'] = 'attachment; filename='
  737. . '"' . $options['saveAsName'] . '"';
  738. }
  739. if ($generation) {
  740. $params['generation'] = $generation;
  741. }
  742. return $params;
  743. }
  744. /**
  745. * Create a query string from an array.
  746. *
  747. * Note that this method does NOT urlencode keys or values.
  748. *
  749. * @param array $input
  750. * @return string
  751. */
  752. private function buildQueryString(array $input)
  753. {
  754. $q = [];
  755. foreach ($input as $key => $val) {
  756. $q[] = $key . '=' . $val;
  757. }
  758. return implode('&', $q);
  759. }
  760. }