1<?php
2/**
3 * Copyright 2012 Google Inc.
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
18namespace Google\Http;
19
20use Google\Client;
21use Google\Http\REST;
22use Google\Exception as GoogleException;
23use GuzzleHttp\Psr7;
24use GuzzleHttp\Psr7\Request;
25use GuzzleHttp\Psr7\Uri;
26use Psr\Http\Message\RequestInterface;
27
28/**
29 * Manage large file uploads, which may be media but can be any type
30 * of sizable data.
31 */
32class MediaFileUpload
33{
34  const UPLOAD_MEDIA_TYPE = 'media';
35  const UPLOAD_MULTIPART_TYPE = 'multipart';
36  const UPLOAD_RESUMABLE_TYPE = 'resumable';
37
38  /** @var string $mimeType */
39  private $mimeType;
40
41  /** @var string $data */
42  private $data;
43
44  /** @var bool $resumable */
45  private $resumable;
46
47  /** @var int $chunkSize */
48  private $chunkSize;
49
50  /** @var int $size */
51  private $size;
52
53  /** @var string $resumeUri */
54  private $resumeUri;
55
56  /** @var int $progress */
57  private $progress;
58
59  /** @var Client */
60  private $client;
61
62  /** @var RequestInterface */
63  private $request;
64
65  /** @var string */
66  private $boundary;
67
68  /**
69   * Result code from last HTTP call
70   * @var int
71   */
72  private $httpResultCode;
73
74  /**
75   * @param Client $client
76   * @param RequestInterface $request
77   * @param string $mimeType
78   * @param string $data The bytes you want to upload.
79   * @param bool $resumable
80   * @param bool $chunkSize File will be uploaded in chunks of this many bytes.
81   * only used if resumable=True
82   */
83  public function __construct(
84      Client $client,
85      RequestInterface $request,
86      $mimeType,
87      $data,
88      $resumable = false,
89      $chunkSize = false
90  ) {
91    $this->client = $client;
92    $this->request = $request;
93    $this->mimeType = $mimeType;
94    $this->data = $data;
95    $this->resumable = $resumable;
96    $this->chunkSize = $chunkSize;
97    $this->progress = 0;
98
99    $this->process();
100  }
101
102  /**
103   * Set the size of the file that is being uploaded.
104   * @param $size - int file size in bytes
105   */
106  public function setFileSize($size)
107  {
108    $this->size = $size;
109  }
110
111  /**
112   * Return the progress on the upload
113   * @return int progress in bytes uploaded.
114   */
115  public function getProgress()
116  {
117    return $this->progress;
118  }
119
120  /**
121   * Send the next part of the file to upload.
122   * @param string|bool $chunk Optional. The next set of bytes to send. If false will
123   * use $data passed at construct time.
124   */
125  public function nextChunk($chunk = false)
126  {
127    $resumeUri = $this->getResumeUri();
128
129    if (false == $chunk) {
130      $chunk = substr($this->data, $this->progress, $this->chunkSize);
131    }
132
133    $lastBytePos = $this->progress + strlen($chunk) - 1;
134    $headers = array(
135      'content-range' => "bytes $this->progress-$lastBytePos/$this->size",
136      'content-length' => strlen($chunk),
137      'expect' => '',
138    );
139
140    $request = new Request(
141        'PUT',
142        $resumeUri,
143        $headers,
144        Psr7\Utils::streamFor($chunk)
145    );
146
147    return $this->makePutRequest($request);
148  }
149
150  /**
151   * Return the HTTP result code from the last call made.
152   * @return int code
153   */
154  public function getHttpResultCode()
155  {
156    return $this->httpResultCode;
157  }
158
159  /**
160  * Sends a PUT-Request to google drive and parses the response,
161  * setting the appropiate variables from the response()
162  *
163  * @param RequestInterface $request the Request which will be send
164  *
165  * @return false|mixed false when the upload is unfinished or the decoded http response
166  *
167  */
168  private function makePutRequest(RequestInterface $request)
169  {
170    $response = $this->client->execute($request);
171    $this->httpResultCode = $response->getStatusCode();
172
173    if (308 == $this->httpResultCode) {
174      // Track the amount uploaded.
175      $range = $response->getHeaderLine('range');
176      if ($range) {
177        $range_array = explode('-', $range);
178        $this->progress = $range_array[1] + 1;
179      }
180
181      // Allow for changing upload URLs.
182      $location = $response->getHeaderLine('location');
183      if ($location) {
184        $this->resumeUri = $location;
185      }
186
187      // No problems, but upload not complete.
188      return false;
189    }
190
191    return REST::decodeHttpResponse($response, $this->request);
192  }
193
194  /**
195   * Resume a previously unfinished upload
196   * @param $resumeUri the resume-URI of the unfinished, resumable upload.
197   */
198  public function resume($resumeUri)
199  {
200     $this->resumeUri = $resumeUri;
201     $headers = array(
202       'content-range' => "bytes */$this->size",
203       'content-length' => 0,
204     );
205     $httpRequest = new Request(
206         'PUT',
207         $this->resumeUri,
208         $headers
209     );
210
211     return $this->makePutRequest($httpRequest);
212  }
213
214  /**
215   * @return RequestInterface
216   * @visible for testing
217   */
218  private function process()
219  {
220    $this->transformToUploadUrl();
221    $request = $this->request;
222
223    $postBody = '';
224    $contentType = false;
225
226    $meta = (string) $request->getBody();
227    $meta = is_string($meta) ? json_decode($meta, true) : $meta;
228
229    $uploadType = $this->getUploadType($meta);
230    $request = $request->withUri(
231        Uri::withQueryValue($request->getUri(), 'uploadType', $uploadType)
232    );
233
234    $mimeType = $this->mimeType ?: $request->getHeaderLine('content-type');
235
236    if (self::UPLOAD_RESUMABLE_TYPE == $uploadType) {
237      $contentType = $mimeType;
238      $postBody = is_string($meta) ? $meta : json_encode($meta);
239    } else if (self::UPLOAD_MEDIA_TYPE == $uploadType) {
240      $contentType = $mimeType;
241      $postBody = $this->data;
242    } else if (self::UPLOAD_MULTIPART_TYPE == $uploadType) {
243      // This is a multipart/related upload.
244      $boundary = $this->boundary ?: mt_rand();
245      $boundary = str_replace('"', '', $boundary);
246      $contentType = 'multipart/related; boundary=' . $boundary;
247      $related = "--$boundary\r\n";
248      $related .= "Content-Type: application/json; charset=UTF-8\r\n";
249      $related .= "\r\n" . json_encode($meta) . "\r\n";
250      $related .= "--$boundary\r\n";
251      $related .= "Content-Type: $mimeType\r\n";
252      $related .= "Content-Transfer-Encoding: base64\r\n";
253      $related .= "\r\n" . base64_encode($this->data) . "\r\n";
254      $related .= "--$boundary--";
255      $postBody = $related;
256    }
257
258    $request = $request->withBody(Psr7\Utils::streamFor($postBody));
259
260    if (isset($contentType) && $contentType) {
261      $request = $request->withHeader('content-type', $contentType);
262    }
263
264    return $this->request = $request;
265  }
266
267  /**
268   * Valid upload types:
269   * - resumable (UPLOAD_RESUMABLE_TYPE)
270   * - media (UPLOAD_MEDIA_TYPE)
271   * - multipart (UPLOAD_MULTIPART_TYPE)
272   * @param $meta
273   * @return string
274   * @visible for testing
275   */
276  public function getUploadType($meta)
277  {
278    if ($this->resumable) {
279      return self::UPLOAD_RESUMABLE_TYPE;
280    }
281
282    if (false == $meta && $this->data) {
283      return self::UPLOAD_MEDIA_TYPE;
284    }
285
286    return self::UPLOAD_MULTIPART_TYPE;
287  }
288
289  public function getResumeUri()
290  {
291    if (null === $this->resumeUri) {
292      $this->resumeUri = $this->fetchResumeUri();
293    }
294
295    return $this->resumeUri;
296  }
297
298  private function fetchResumeUri()
299  {
300    $body = $this->request->getBody();
301    if ($body) {
302      $headers = array(
303        'content-type' => 'application/json; charset=UTF-8',
304        'content-length' => $body->getSize(),
305        'x-upload-content-type' => $this->mimeType,
306        'x-upload-content-length' => $this->size,
307        'expect' => '',
308      );
309      foreach ($headers as $key => $value) {
310        $this->request = $this->request->withHeader($key, $value);
311      }
312    }
313
314    $response = $this->client->execute($this->request, false);
315    $location = $response->getHeaderLine('location');
316    $code = $response->getStatusCode();
317
318    if (200 == $code && true == $location) {
319      return $location;
320    }
321
322    $message = $code;
323    $body = json_decode((string) $this->request->getBody(), true);
324    if (isset($body['error']['errors'])) {
325      $message .= ': ';
326      foreach ($body['error']['errors'] as $error) {
327        $message .= "{$error['domain']}, {$error['message']};";
328      }
329      $message = rtrim($message, ';');
330    }
331
332    $error = "Failed to start the resumable upload (HTTP {$message})";
333    $this->client->getLogger()->error($error);
334
335    throw new GoogleException($error);
336  }
337
338  private function transformToUploadUrl()
339  {
340    $parts = parse_url((string) $this->request->getUri());
341    if (!isset($parts['path'])) {
342      $parts['path'] = '';
343    }
344    $parts['path'] = '/upload' . $parts['path'];
345    $uri = Uri::fromParts($parts);
346    $this->request = $this->request->withUri($uri);
347  }
348
349  public function setChunkSize($chunkSize)
350  {
351    $this->chunkSize = $chunkSize;
352  }
353
354  public function getRequest()
355  {
356    return $this->request;
357  }
358}
359