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