1: <?php
2: namespace Dropbox;
3:
4: /**
5: * The class used to make most Dropbox API calls. You can use this once you've gotten an
6: * {@link AccessToken} via {@link WebAuth}.
7: *
8: * This class is stateless so it can be shared/reused.
9: */
10: final class Client
11: {
12: /**
13: * The config used when making requests to the Dropbox server.
14: *
15: * @return Config
16: */
17: function getConfig() { return $this->config; }
18:
19: /** @var Config */
20: private $config;
21:
22: /**
23: * The access token used by this client to make authenticated API calls. You can get an
24: * access token via {@link WebAuth}.
25: *
26: * @return AccessToken
27: */
28: function getAccessToken() { return $this->accessToken; }
29:
30: /** @var AccessToken */
31: private $accessToken;
32:
33: /**
34: * Constructor.
35: *
36: * <code>
37: * use \Dropbox as dbx;
38: * $config = new Config(...);
39: * list($accessToken, $dropboxUserId) = $webAuth->finish(...);
40: * $client = new dbx\Client($config, $accessToken);
41: * </code>
42: *
43: * @param Config $config
44: * See {@link getConfig()}
45: * @param AccessToken $accessToken
46: * See {@link getAccessToken()}
47: */
48: function __construct($config, $accessToken)
49: {
50: Config::checkArg("config", $config);
51: AccessToken::checkArg("accessToken", $accessToken);
52:
53: $this->config = $config;
54: $this->accessToken = $accessToken;
55:
56: // These fields are redundant, but it makes these values a little more convenient
57: // to access.
58: $this->apiHost = $config->getAppInfo()->getHost()->getApi();
59: $this->contentHost = $config->getAppInfo()->getHost()->getContent();
60: $this->root = $config->getAppInfo()->getAccessType()->getUrlPart();
61: }
62:
63: /** @var string */
64: private $apiHost;
65: /** @var string */
66: private $contentHost;
67: /** @var string */
68: private $root;
69:
70: private function appendFilePath($base, $path)
71: {
72: return $base . "/" . $this->root . "/" . rawurlencode(substr($path, 1));
73: }
74:
75: /**
76: * Returns a basic account and quota information.
77: *
78: * <code>
79: * $client = ...
80: * $accountInfo = $client->getAccountInfo();
81: * print_r($accountInfo);
82: * </code>
83: *
84: * @return array
85: * See <a href="https://www.dropbox.com/developers/core/api#account-info">/account/info</a>.
86: *
87: * @throws Exception
88: */
89: function getAccountInfo()
90: {
91: $response = $this->doGet($this->apiHost, "1/account/info");
92: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
93: return RequestUtil::parseResponseJson($response->body);
94: }
95:
96: /**
97: * Downloads a file from Dropbox. The file's contents are written to the
98: * given <code>$outStream</code> and the file's metadata is returned.
99: *
100: * <code>
101: * $client = ...;
102: * $metadata = $client->getFile("/Photos/Frog.jpeg",
103: * fopen("./Frog.jpeg", "wb"));
104: * print_r($metadata);
105: * </code>
106: *
107: * @param string $path
108: * The path to the file on Dropbox (UTF-8).
109: *
110: * @param resource $outStream
111: * If the file exists, the file contents will be written to this stream.
112: *
113: * @param string|null $rev
114: * If you want the latest revision of the file at the given path, pass in <code>null</code>.
115: * If you want a specific version of a file, pass in value of the file metadata's "rev" field.
116: *
117: * @return null|array
118: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
119: * object</a> for the file at the given $path and $rev, or <code>null</code> if the file
120: * doesn't exist,
121: *
122: * @throws Exception
123: */
124: function getFile($path, $outStream, $rev = null)
125: {
126: Path::checkArgNonRoot("path", $path);
127: Checker::argResource("outStream", $outStream);
128: Checker::argStringNonEmptyOrNull("rev", $rev);
129:
130: $url = RequestUtil::buildUrl(
131: $this->config,
132: $this->contentHost,
133: $this->appendFilePath("1/files", $path),
134: array("rev" => $rev));
135:
136: $curl = self::mkCurl($url);
137: $metadataCatcher = new DropboxMetadataHeaderCatcher($curl->handle);
138: $streamRelay = new CurlStreamRelay($curl->handle, $outStream);
139:
140: $response = $curl->exec();
141:
142: if ($response->statusCode === 404) return null;
143:
144: if ($response->statusCode !== 200) {
145: $response->body = $streamRelay->getErrorBody();
146: throw RequestUtil::unexpectedStatus($response);
147: }
148:
149: return $metadataCatcher->getMetadata();
150: }
151:
152: /**
153: * Calling 'uploadFile' with <code>$numBytes</code> less than this value, will cause this SDK
154: * to use the standard /files_put endpoint. When <code>$numBytes</code> is greater than this
155: * value, we'll use the /chunked_upload endpoint.
156: *
157: * @var int
158: */
159: private static $AUTO_CHUNKED_UPLOAD_THRESHOLD = 9863168; // 8 MB
160:
161: /**
162: * @var int
163: */
164: private static $DEFAULT_CHUNK_SIZE = 4194304; // 4 MB
165:
166: /**
167: * Creates a file on Dropbox, using the data from <code>$inStream</code> for the file contents.
168: *
169: * <code>
170: * use \Dropbox as dbx;
171: * $client = ...;
172: * $md1 = $client->uploadFile("/Photos/Frog.jpeg",
173: * dbx\WriteMode::add(),
174: * fopen("./frog.jpeg", "rb"));
175: * print_r($md1);
176: *
177: * // Re-upload with WriteMode::update(...), which will overwrite the
178: * // file if it hasn't been modified from our original upload.
179: * $md2 = $client->uploadFile("/Photos/Frog.jpeg",
180: * dbx\WriteMode::update($md1["rev"]),
181: * fopen("./frog-new.jpeg", "rb"));
182: * print_r($md2);
183: * </code>
184: *
185: * @param string $path
186: * The Dropbox path to save the file to (UTF-8).
187: *
188: * @param WriteMode $writeMode
189: * What to do if there's already a file at the given path.
190: *
191: * @param resource $inStream
192: *
193: * @param int|null $numBytes
194: * You can pass in <code>null</code> if you don't know. If you do provide the size, we can
195: * perform a slightly more efficient upload (fewer network round-trips) for files smaller
196: * than 8 MB.
197: *
198: * @return mixed
199: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
200: * object</a> for the newly-added file.
201: *
202: * @throws Exception
203: */
204: function uploadFile($path, $writeMode, $inStream, $numBytes = null)
205: {
206: try {
207: Path::checkArgNonRoot("path", $path);
208: WriteMode::checkArg("writeMode", $writeMode);
209: Checker::argResource("inStream", $inStream);
210: Checker::argNatOrNull("numBytes", $numBytes);
211:
212: // If we don't know how many bytes are coming, we have to use chunked upload.
213: // If $numBytes is large, we elect to use chunked upload.
214: // In all other cases, use regular upload.
215: if ($numBytes === null || $numBytes > self::$AUTO_CHUNKED_UPLOAD_THRESHOLD) {
216: $metadata = $this->_uploadFileChunked($path, $writeMode, $inStream, $numBytes,
217: self::$DEFAULT_CHUNK_SIZE);
218: } else {
219: $metadata = $this->_uploadFile($path, $writeMode,
220: function(Curl $curl) use ($inStream, $numBytes) {
221: $curl->set(CURLOPT_PUT, true);
222: $curl->set(CURLOPT_INFILE, $inStream);
223: $curl->set(CURLOPT_INFILESIZE, $numBytes);
224: });
225: }
226: }
227: catch (\Exception $ex) {
228: fclose($inStream);
229: throw $ex;
230: }
231: fclose($inStream);
232:
233: return $metadata;
234: }
235:
236: /**
237: * Creates a file on Dropbox, using the given $data string as the file contents.
238: *
239: * <code>
240: * use \Dropbox as dbx;
241: * $client = ...;
242: * $md = $client->uploadFile("/Grocery List.txt",
243: * dbx\WriteMode::add(),
244: * "1. Coke\n2. Popcorn\n3. Toothpaste\n");
245: * print_r($md);
246: * </code>
247: *
248: * @param string $path
249: * The Dropbox path to save the file to (UTF-8).
250: *
251: * @param WriteMode $writeMode
252: * What to do if there's already a file at the given path.
253: *
254: * @param string $data
255: * The data to use for the contents of the file.
256: *
257: * @return mixed
258: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
259: * object</a> for the newly-added file.
260: *
261: * @throws Exception
262: */
263: function uploadFileFromString($path, $writeMode, $data)
264: {
265: Path::checkArgNonRoot("path", $path);
266: WriteMode::checkArg("writeMode", $writeMode);
267: Checker::argString("data", $data);
268:
269: return $this->_uploadFile($path, $writeMode, function(Curl $curl) use ($data) {
270: $curl->set(CURLOPT_CUSTOMREQUEST, "PUT");
271: $curl->set(CURLOPT_POSTFIELDS, $data);
272: $curl->addHeader("Content-Type: application/octet-stream");
273: });
274: }
275:
276: /**
277: * Creates a file on Dropbox, using the data from $inStream as the file contents.
278: *
279: * This version of <code>uploadFile</code> splits uploads the file ~4MB chunks at a time and
280: * will retry a few times if one chunk fails to upload. Uses {@link chunkedUploadStart()},
281: * {@link chunkedUploadContinue()}, and {@link chunkedUploadFinish()}.
282: *
283: * @param string $path
284: * The Dropbox path to save the file to (UTF-8).
285: *
286: * @param WriteMode $writeMode
287: * What to do if there's already a file at the given path.
288: *
289: * @param resource $inStream
290: *
291: * @param int|null $numBytes
292: * The number of bytes available from $inStream.
293: * You can pass in <code>null</code> if you don't know.
294: *
295: * @param int|null $chunkSize
296: * The number of bytes to upload in each chunk. You can omit this (or pass in
297: * <code>null</code> and the library will use a reasonable default.
298: *
299: * @return mixed
300: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
301: * object</a> for the newly-added file.
302: *
303: * @throws Exception
304: */
305: function uploadFileChunked($path, $writeMode, $inStream, $numBytes = null, $chunkSize = null)
306: {
307: try {
308: if ($chunkSize === null) {
309: $chunkSize = self::$DEFAULT_CHUNK_SIZE;
310: }
311:
312: Path::checkArgNonRoot("path", $path);
313: WriteMode::checkArg("writeMode", $writeMode);
314: Checker::argResource("inStream", $inStream);
315: Checker::argNatOrNull("numBytes", $numBytes);
316: Checker::argIntPositive("chunkSize", $chunkSize);
317:
318: $metadata = $this->_uploadFileChunked($path, $writeMode, $inStream, $numBytes,
319: $chunkSize);
320: }
321: catch (\Exception $ex) {
322: fclose($inStream);
323: throw $ex;
324: }
325: fclose($inStream);
326:
327: return $metadata;
328: }
329:
330: /**
331: * @param string $path
332: *
333: * @param WriteMode $writeMode
334: * What to do if there's already a file at the given path (UTF-8).
335: *
336: * @param resource $inStream
337: * The source of data to upload.
338: *
339: * @param int|null $numBytes
340: * You can pass in <code>null</code>. But if you know how many bytes you expect, pass in
341: * that value and this function will do a sanity check at the end to make sure the number of
342: * bytes read from $inStream matches up.
343: *
344: * @param int $chunkSize
345: *
346: * @return array
347: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
348: * object</a> for the newly-added file.
349: */
350: private function _uploadFileChunked($path, $writeMode, $inStream, $numBytes, $chunkSize)
351: {
352: Path::checkArg("path", $path);
353: WriteMode::checkArg("writeMode", $writeMode);
354: Checker::argResource("inStream", $inStream);
355: Checker::argNatOrNull("numBytes", $numBytes);
356: Checker::argNat("chunkSize", $chunkSize);
357:
358: // NOTE: This function performs 3 retries on every call. This is maybe not the right
359: // layer to make retry decisions. It's also awkward because none of the other calls
360: // perform retries.
361:
362: assert($chunkSize > 0);
363:
364: $data = fread($inStream, $chunkSize);
365: $len = strlen($data);
366:
367: $client = $this;
368: $uploadId = RequestUtil::runWithRetry(3, function() use ($data, $client) {
369: return $client->chunkedUploadStart($data);
370: });
371:
372: $byteOffset = $len;
373:
374: while (!feof($inStream)) {
375: $data = fread($inStream, $chunkSize);
376: $len = strlen($data);
377:
378: while (true) {
379: $r = RequestUtil::runWithRetry(3,
380: function() use ($client, $uploadId, $byteOffset, $data) {
381: return $client->chunkedUploadContinue($uploadId, $byteOffset, $data);
382: });
383:
384: if ($r === true) { // Chunk got uploaded!
385: $byteOffset += $len;
386: break;
387: }
388: if ($r === false) { // Server didn't recognize our upload ID
389: // This is very unlikely since we're uploading all the chunks in sequence.
390: throw new Exception_BadResponse("Server forgot our uploadId");
391: }
392:
393: // Otherwise, the server is at a different byte offset from us.
394: $serverByteOffset = $r;
395: assert($serverByteOffset !== $byteOffset); // chunkedUploadContinue ensures this.
396: // An earlier byte offset means the server has lost data we sent earlier.
397: if ($r < $byteOffset) throw new Exception_BadResponse(
398: "Server is at an ealier byte offset: us=$byteOffset, server=$serverByteOffset");
399: // The normal case is that the server is a bit further along than us because of a
400: // partially-uploaded chunk.
401: $diff = $serverByteOffset - $byteOffset;
402: if ($diff > $len) throw new Exception_BadResponse(
403: "Server is more than a chunk ahead: us=$byteOffset, server=$serverByteOffset");
404:
405: // Finish the rest of this chunk.
406: $byteOffset += $diff;
407: $data = substr($data, $diff);
408: }
409: }
410:
411: if ($numBytes !== null && $byteOffset !== $numBytes) throw new \InvalidArgumentException(
412: "You passed numBytes=$numBytes but the stream had $byteOffset bytes.");
413:
414: $metadata = RequestUtil::runWithRetry(3,
415: function() use ($client, $uploadId, $path, $writeMode) {
416: return $client->chunkedUploadFinish($uploadId, $path, $writeMode);
417: });
418:
419: return $metadata;
420: }
421:
422: /**
423: * @param string $path
424: * @param WriteMode $writeMode
425: * @param callable $curlConfigClosure
426: * @return array
427: */
428: private function _uploadFile($path, $writeMode, $curlConfigClosure)
429: {
430: Path::checkArg("path", $path);
431: WriteMode::checkArg("writeMode", $writeMode);
432: Checker::argCallable("curlConfigClosure", $curlConfigClosure);
433:
434: $url = RequestUtil::buildUrl(
435: $this->config,
436: $this->contentHost,
437: $this->appendFilePath("1/files_put", $path),
438: $writeMode->getExtraParams());
439:
440: $curl = $this->mkCurl($url);
441:
442: $curlConfigClosure($curl);
443:
444: $curl->set(CURLOPT_RETURNTRANSFER, true);
445: $response = $curl->exec();
446:
447: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
448:
449: return RequestUtil::parseResponseJson($response->body);
450: }
451:
452: /**
453: * Start a new chunked upload session and upload the first chunk of data.
454: *
455: * @param string $data
456: * The data to start off the chunked upload session.
457: *
458: * @return array
459: * A pair of <code>(string $uploadId, int $byteOffset)</code>. <code>$uploadId</code>
460: * is a unique identifier for this chunked upload session. You pass this in to
461: * {@link chunkedUploadContinue} and {@link chuunkedUploadFinish}. <code>$byteOffset</code>
462: * is the number of bytes that were successfully uploaded.
463: *
464: * @throws Exception
465: */
466: function chunkedUploadStart($data)
467: {
468: Checker::argString("data", $data);
469:
470: $response = $this->_chunkedUpload(array(), $data);
471:
472: if ($response->statusCode === 404) {
473: throw new Exception_BadResponse("Got a 404, but we didn't send up an 'upload_id'");
474: }
475:
476: $correction = self::_chunkedUploadCheckForOffsetCorrection($response);
477: if ($correction !== null) throw new Exception_BadResponse(
478: "Got an offset-correcting 400 response, but we didn't send an offset");
479:
480: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
481:
482: list($uploadId, $byteOffset) = self::_chunkedUploadParse200Response($response->body);
483: $len = strlen($data);
484: if ($byteOffset !== $len) throw new Exception_BadResponse(
485: "We sent $len bytes, but server returned an offset of $byteOffset");
486:
487: return $uploadId;
488: }
489:
490: /**
491: * Append another chunk data to a previously-started chunked upload session.
492: *
493: * @param string $uploadId
494: * The unique identifier for the chunked upload session. This is obtained via
495: * {@link chunkedUploadStart}.
496: *
497: * @param int $byteOffset
498: * The number of bytes you think you've already uploaded to the given chunked upload
499: * session. The server will append the new chunk of data after that point.
500: *
501: * @param string $data
502: * The data to append to the existing chunked upload session.
503: *
504: * @return int|bool
505: * If <code>false</code>, it means the server didn't know about the given
506: * <code>$uploadId</code>. This may be because the chunked upload session has expired
507: * (they last around 24 hours).
508: * If <code>true</code>, the chunk was successfully uploaded. If an integer, it means
509: * you and the server don't agree on the current <code>$byteOffset</code>. The returned
510: * integer is the server's internal byte offset for the chunked upload session. You need
511: * to adjust your input to match.
512: *
513: * @throws Exception
514: */
515: function chunkedUploadContinue($uploadId, $byteOffset, $data)
516: {
517: Checker::argStringNonEmpty("uploadId", $uploadId);
518: Checker::argNat("byteOffset", $byteOffset);
519: Checker::argString("data", $data);
520:
521: $response = $this->_chunkedUpload(
522: array("upload_id" => $uploadId, "offset" => $byteOffset), $data);
523:
524: if ($response->statusCode === 404) {
525: // The server doesn't know our upload ID. Maybe it expired?
526: return false;
527: }
528:
529: $correction = self::_chunkedUploadCheckForOffsetCorrection($response);
530: if ($correction !== null) {
531: list($correctedUploadId, $correctedByteOffset) = $correction;
532: if ($correctedUploadId !== $uploadId) throw new Exception_BadResponse(
533: "Corrective 400 upload_id mismatch: us=".
534: self::q($uploadId)." server=".self::q($correctedUploadId));
535: if ($correctedByteOffset === $byteOffset) throw new Exception_BadResponse(
536: "Corrective 400 offset is the same as ours: $byteOffset");
537: return $correctedByteOffset;
538: }
539:
540: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
541: list($retUploadId, $retByteOffset) = self::_chunkedUploadParse200Response($response->body);
542:
543: $nextByteOffset = $byteOffset + strlen($data);
544: if ($uploadId !== $retUploadId) throw new Exception_BadResponse(
545: "upload_id mismatch: us=".self::q($uploadId).", server=".self::q($uploadId));
546: if ($nextByteOffset !== $retByteOffset) throw new Exception_BadResponse(
547: "next-offset mismatch: us=$nextByteOffset, server=$retByteOffset");
548:
549: return true;
550: }
551:
552: /**
553: * @param string $body
554: * @return array
555: */
556: private static function _chunkedUploadParse200Response($body)
557: {
558: $j = RequestUtil::parseResponseJson($body);
559: $uploadId = self::getField($j, "upload_id");
560: $byteOffset = self::getField($j, "offset");
561: return array($uploadId, $byteOffset);
562: }
563:
564: /**
565: * @param HttpResponse $response
566: * @return array|null
567: */
568: private static function _chunkedUploadCheckForOffsetCorrection($response)
569: {
570: if ($response->statusCode !== 400) return null;
571: $j = json_decode($response->body, true);
572: if ($j === null) return null;
573: if (!array_key_exists("upload_id", $j) || !array_key_exists("offset", $j)) return null;
574: $uploadId = $j["upload_id"];
575: $byteOffset = $j["offset"];
576: return array($uploadId, $byteOffset);
577: }
578:
579: /**
580: * Creates a file on Dropbox using the accumulated contents of the given chunked upload session.
581: *
582: * @param string $uploadId
583: * The unique identifier for the chunked upload session. This is obtained via
584: * {@link chunkedUploadStart}.
585: *
586: * @param string $path
587: * The Dropbox path to save the file to ($path).
588: *
589: * @param WriteMode $writeMode
590: * What to do if there's already a file at the given path.
591: *
592: * @return array|null
593: * If <code>null</code>, it means the Dropbox server wasn't aware of the
594: * <code>$uploadId</code> you gave it.
595: * Otherwise, you get back the
596: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>
597: * for the newly-created file.
598: *
599: * @throws Exception
600: */
601: function chunkedUploadFinish($uploadId, $path, $writeMode)
602: {
603: Checker::argStringNonEmpty("uploadId", $uploadId);
604: Path::checkArgNonRoot("path", $path);
605: WriteMode::checkArg("writeMode", $writeMode);
606:
607: $params = array_merge(array("upload_id" => $uploadId), $writeMode->getExtraParams());
608:
609: $response = $this->doPost(
610: $this->contentHost,
611: $this->appendFilePath("1/commit_chunked_upload", $path),
612: $params);
613:
614: if ($response->statusCode === 404) return null;
615: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
616:
617: return RequestUtil::parseResponseJson($response->body);
618: }
619:
620: /**
621: * @param array $params
622: * @param string $data
623: * @return HttpResponse
624: */
625: private function _chunkedUpload($params, $data)
626: {
627: $url = RequestUtil::buildUrl(
628: $this->config, $this->contentHost, "1/chunked_upload", $params);
629:
630: $curl = $this->mkCurl($url);
631:
632: // We can't use CURLOPT_PUT because it wants a stream, but we already have $data in memory.
633: $curl->set(CURLOPT_CUSTOMREQUEST, "PUT");
634: $curl->set(CURLOPT_POSTFIELDS, $data);
635: $curl->addHeader("Content-Type: application/octet-stream");
636:
637: $curl->set(CURLOPT_RETURNTRANSFER, true);
638: return $curl->exec();
639: }
640:
641: /**
642: * Returns the metadata for whatever file or folder is at the given path.
643: *
644: * <code>
645: * $client = ...;
646: * $md = $client->getMetadata("/Photos/Frog.jpeg");
647: * print_r($md);
648: * </code>
649: *
650: * @param string $path
651: * The Dropbox path to a file or folder (UTF-8).
652: *
653: * @return array|null
654: * If there is a file or folder at the given path, you'll get back the
655: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>
656: * for that file or folder. If not, you'll get back <code>null</code>.
657: *
658: * @throws Exception
659: */
660: function getMetadata($path)
661: {
662: Path::checkArg("path", $path);
663:
664: return $this->_getMetadata($path, array("list" => "false"));
665: }
666:
667: /**
668: * Returns the metadata for whatever file or folder is at the given path and, if it's a folder,
669: * also include the metadata for all the immediate children of that folder.
670: *
671: * <code>
672: * $client = ...;
673: * $md = $client->getMetadataWithChildren("/Photos");
674: * print_r($md);
675: * </code>
676: *
677: * @param string $path
678: * The Dropbox path to a file or folder (UTF-8).
679: *
680: * @return array|null
681: * If there is a file or folder at the given path, you'll get back the
682: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>
683: * for that file or folder, along with all immediate children if it's a folder. If not,
684: * you'll get back <code>null</code>.
685: *
686: * @throws Exception
687: */
688: function getMetadataWithChildren($path)
689: {
690: Path::checkArg("path", $path);
691:
692: return $this->_getMetadata($path, array("list" => "true", "file_limit" => "25000"));
693: }
694:
695: /**
696: * @param string $path
697: * @param array $params
698: * @return array
699: */
700: private function _getMetadata($path, $params)
701: {
702: $response = $this->doGet(
703: $this->apiHost,
704: $this->appendFilePath("1/metadata", $path),
705: $params);
706:
707: if ($response->statusCode === 404) return null;
708: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
709:
710: $metadata = RequestUtil::parseResponseJson($response->body);
711: if (array_key_exists("is_deleted", $metadata) && $metadata["is_deleted"]) return null;
712: return $metadata;
713: }
714:
715: /**
716: * If you've previously retrieved the metadata for a folder and its children, this method will
717: * retrieve updated metadata only if something has changed. This is more efficient than
718: * calling {@link getMetadataWithChildren} if you have a cache of previous results.
719: *
720: * <code>
721: * $client = ...;
722: * $md = $client->getMetadataWithChildren("/Photos");
723: * print_r($md);
724: * assert($md["is_dir"], "expecting \"/Photos\" to be a folder");
725: *
726: * sleep(10);
727: *
728: * // Now see if anything changed...
729: * list($changed, $new_md) = $client->getMetadataWithChildrenIfChanged(
730: * "/Photos", $md["hash"]);
731: * if ($changed) {
732: * echo "Folder changed.\n";
733: * print_r($new_md);
734: * } else {
735: * echo "Folder didn't change.\n";
736: * }
737: * </code>
738: *
739: * @param string $path
740: * The Dropbox path to a folder (UTF-8).
741: *
742: * @param string $previousFolderHash
743: * The "hash" field from the previously retrieved folder metadata.
744: *
745: * @return array
746: * A <code>list(boolean $changed, array $metadata)</code>. If the metadata hasn't changed,
747: * you'll get <code>list(false, null)</code>. If the metadata of the folder or any of its
748: * children has changed, you'll get <code>list(true, $newMetadata)</code>. $metadata is a
749: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>.
750: *
751: * @throws Exception
752: */
753: function getMetadataWithChildrenIfChanged($path, $previousFolderHash)
754: {
755: Path::checkArg("path", $path);
756: Checker::argStringNonEmpty("previousFolderHash", $previousFolderHash);
757:
758: $params = array("list" => "true", "limit" => "25000", "hash" => $previousFolderHash);
759:
760: $response = $this->doGet(
761: $this->apiHost, "1/metadata",
762: $this->appendFilePath("1/metadata", $path),
763: $params);
764:
765: if ($response->statusCode === 304) return array(false, null);
766: if ($response->statusCode === 404) return array(true, null);
767: if ($response->statusCode !== 404) throw RequestUtil::unexpectedStatus($response);
768:
769: $metadata = RequestUtil::parseResponseJson($response->body);
770: if (array_key_exists("is_deleted", $metadata) && $metadata["is_deleted"]) {
771: return array(true, null);
772: }
773: return array(true, $metadata);
774: }
775:
776: /**
777: * A way of letting you keep up with changes to files and folders in a user's Dropbox.
778: *
779: * @param string|null $cursor
780: * If this is the first time you're calling this, pass in <code>null</code>. Otherwise,
781: * pass in whatever cursor was returned by the previous call.
782: *
783: * @return array
784: * A <a href="https://www.dropbox.com/developers/core/api#delta">delta page</a>, which
785: * contains a list of changes to apply along with a new "cursor" that should be passed into
786: * future <code>getDelta</code> calls. If the "reset" field is <code>true</code>, you
787: * should clear your local state before applying the changes. If the "has_more" field is
788: * <code>true</code>, call <code>getDelta</code> immediately to get more results, otherwise
789: * wait a while (at least 5 minutes) before calling <code>getDelta</code> again.
790: *
791: * @throws Exception
792: */
793: function getDelta($cursor = null)
794: {
795: Checker::argStringNonEmptyOrNull("cursor", $cursor);
796:
797: $response = $this->doPost($this->apiHost, "1/delta", array("cursor" => $cursor));
798:
799: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
800:
801: return RequestUtil::parseResponseJson($response->body);
802: }
803:
804: /**
805: * Gets the metadata for all the file revisions (up to a limit) for a given path.
806: *
807: * See <a href="https://www.dropbox.com/developers/core/api#revisions">/revisions</a>.
808: *
809: * @param string path
810: * The Dropbox path that you want file revision metadata for (UTF-8).
811: *
812: * @param int|null limit
813: * The maximum number of revisions to return.
814: *
815: * @return array|null
816: * A list of <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
817: * objects</a>, one for each file revision. The later revisions appear first in the list.
818: * If <code>null</code>, then there were too many revisions at that path.
819: *
820: * @throws Exception
821: */
822: function getRevisions($path, $limit = null)
823: {
824: Path::checkArgNonRoot("path", $path);
825: Checker::argIntPositiveOrNull("limit", $limit);
826:
827: $response = $this->doGet(
828: $this->apiHost,
829: $this->appendFilePath("1/revisions", $path),
830: array("rev_limit" => $limit));
831:
832: if ($response->statusCode === 406) return null;
833: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
834:
835: return RequestUtil::parseResponseJson($response->body);
836: }
837:
838: /**
839: * Takes a copy of the file at the given revision and saves it over the current copy. This
840: * will create a new revision, but the file contents will match the revision you specified.
841: *
842: * See <a href="https://www.dropbox.com/developers/core/api#restore">/restore</a>.
843: *
844: * @param string $path
845: * The Dropbox path of the file to restore (UTF-8).
846: *
847: * @param string $rev
848: * The revision to restore the contents to.
849: *
850: * @return mixed
851: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
852: * object</a>
853: *
854: * @throws Exception
855: */
856: function restoreFile($path, $rev)
857: {
858: Path::checkArgNonRoot("path", $path);
859: Checker::argStringNonEmpty("rev", $rev);
860:
861: $response = $this->doPost(
862: $this->apiHost,
863: $this->appendFilePath("1/restore", $path),
864: array("rev" => $rev));
865:
866: if ($response->statusCode === 404) return null;
867: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
868:
869: return RequestUtil::parseResponseJson($response->body);
870: }
871:
872: /**
873: * Returns metadata for all files and folders whose filename matches the query string.
874: *
875: * @param string $basePath
876: * The path to limit the search to (UTF-8). Pass in "/" to search everything.
877: *
878: * @param string $query
879: * A space-separated list of substrings to search for. A file matches only if it contains
880: * all the substrings.
881: *
882: * @param int|null $limit
883: * The maximum number of results to return.
884: *
885: * @param bool $includeDeleted
886: * Whether to include deleted files in the results.
887: *
888: * @return mixed
889: * A list of <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata
890: * objects</a> of files that match the search query.
891: *
892: * @throws Exception
893: */
894: function searchFileNames($basePath, $query, $limit = null, $includeDeleted = false)
895: {
896: Path::checkArg("basePath", $basePath);
897: Checker::argStringNonEmpty("query", $query);
898: Checker::argNatOrNull("limit", $limit);
899: Checker::argBool("includeDeleted", $includeDeleted);
900:
901: $response = $this->doPost(
902: $this->apiHost,
903: $this->appendFilePath("1/search", $basePath),
904: array(
905: "query" => $query,
906: "file_limit" => $limit,
907: "include_deleted" => $includeDeleted,
908: ));
909:
910: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
911:
912: return RequestUtil::parseResponseJson($response->body);
913: }
914:
915: /**
916: * Creates and returns a public link to a file or folder's "preview page". This link can be
917: * used without authentication. The preview page may contain a thumbnail or some other
918: * preview of the file, along with a download link to download the actual file.
919: *
920: * See <a href="https://www.dropbox.com/developers/core/api#shares">/shares</a>.
921: *
922: * @param string $path
923: * The Dropbox path to the file or folder you want to create a shareable link to (UTF-8).
924: *
925: * @return string
926: * The URL of the preview page.
927: *
928: * @throws Exception
929: */
930: function createShareableLink($path)
931: {
932: Path::checkArg("path", $path);
933:
934: $response = $this->doPost(
935: $this->apiHost,
936: $this->appendFilePath("1/shares", $path),
937: array(
938: "short_url" => "false",
939: ));
940:
941: if ($response->statusCode === 404) return null;
942: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
943:
944: $j = RequestUtil::parseResponseJson($response->body);
945: return self::getField($j, "url");
946: }
947:
948: /**
949: * Creates and returns a direct link to a file. This link can be used without authentication.
950: * This link will expire in a few hours.
951: *
952: * @param string $path
953: * The Dropbox path to a file or folder (UTF-8).
954: *
955: * @return array
956: * A <code>list(string $url, \DateTime $expires) where <code>$url</code> is a direct link to
957: * the requested file and <code>$expires</code> is a standard PHP <code>\DateTime</code>
958: * representing when <code>$url</code> will stop working.
959: *
960: * @throws Exception
961: */
962: function createTemporaryDirectLink($path)
963: {
964: Path::checkArgNonRoot("path", $path);
965:
966: $response = $this->doPost(
967: $this->apiHost,
968: $this->appendFilePath("1/media", $path));
969:
970: if ($response->statusCode === 404) return null;
971: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
972:
973: $j = RequestUtil::parseResponseJson($response->body);
974: $url = self::getField($j, "url");
975: $expires = self::parseDateTime(self::getField($j, "expires"));
976: return array($url, $expires);
977: }
978:
979: /**
980: * Creates and returns a "copy ref" to a file. A copy ref can be used to copy a file across
981: * different Dropbox accounts without downloading and re-uploading.
982: *
983: * For example: Create a <code>Client</code> using the access token from one account and call
984: * <code>createCopyRef</code>. Then, create a <code>Client</code> using the access token for
985: * another account and call <code>copyFromCopyRef</code> using the copy ref. (You need to use
986: * the same app key both times.)
987: *
988: * @param string path
989: * The Dropbox path of the file or folder you want to create a copy ref for (UTF-8).
990: *
991: * @return string
992: * The copy ref (just a string that you keep track of).
993: *
994: * @throws Exception
995: */
996: function createCopyRef($path)
997: {
998: Path::checkArg("path", $path);
999:
1000: $response = $this->doGet(
1001: $this->apiHost,
1002: $this->appendFilePath("1/copy_ref", $path));
1003:
1004: if ($response->statusCode === 404) return null;
1005: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1006:
1007: $j = RequestUtil::parseResponseJson($response->body);
1008: return self::getField($j, "copy_ref");
1009: }
1010:
1011: /**
1012: * Gets a thumbnail image representation of the file at the given path.
1013: *
1014: * @param string $path
1015: * The path to the file you want a thumbnail for (UTF-8).
1016: *
1017: * @param string $format
1018: * One of the two image formats: "jpeg" or "png".
1019: *
1020: * @param string $size
1021: * One of the predefined image size names, as a string:
1022: * <ul>
1023: * <li>"xs" - 32x32</li>
1024: * <li>"s" - 64x64</li>
1025: * <li>"m" - 128x128</li>
1026: * <li>"l" - 640x480</li>
1027: * <li>"xl" - 1024x768</li>
1028: * </ul>
1029: *
1030: * @return array|null
1031: * If the file exists, you'll get <code>list(array $metadata, string $data)</code> where
1032: * <code>$metadata</code> is the file's
1033: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>
1034: * and $data is the raw data for the thumbnail image. If the file doesn't exist, you'll
1035: * get <code>null</code>.
1036: *
1037: * @throws Exception
1038: */
1039: function getThumbnail($path, $format, $size)
1040: {
1041: Path::checkArgNonRoot("path", $path);
1042: Checker::argString("format", $format);
1043: Checker::argString("size", $size);
1044: if (!in_array($format, array("jpeg", "png"))) {
1045: throw new \InvalidArgumentException("Invalid 'format': ".self::q($format));
1046: }
1047: if (!in_array($size, array("xs", "s", "m", "l", "xl"))) {
1048: throw new \InvalidArgumentException("Invalid 'size': ".self::q($format));
1049: }
1050:
1051: $url = RequestUtil::buildUrl(
1052: $this->config,
1053: $this->contentHost,
1054: $this->appendFilePath("1/thumbnails", $path),
1055: array("size" => $size, "format" => $format));
1056:
1057: $curl = self::mkCurl($url);
1058: $metadataCatcher = new DropboxMetadataHeaderCatcher($curl->handle);
1059:
1060: $curl->set(CURLOPT_RETURNTRANSFER, true);
1061: $response = $curl->exec();
1062:
1063: if ($response->statusCode === 404) return null;
1064: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1065:
1066: $metadata = $metadataCatcher->getMetadata();
1067: return array($metadata, $response->body);
1068: }
1069:
1070: /**
1071: * Copies a file or folder to a new location
1072: *
1073: * @param string $fromPath
1074: * The Dropbox path of the file or folder you want to copy (UTF-8).
1075: *
1076: * @param string $toPath
1077: * The destination Dropbox path (UTF-8).
1078: *
1079: * @return mixed
1080: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
1081: * object</a> for the new file or folder.
1082: *
1083: * @throws Exception
1084: */
1085: function copy($fromPath, $toPath)
1086: {
1087: Path::checkArg("fromPath", $fromPath);
1088: Path::checkArgNonRoot("toPath", $toPath);
1089:
1090: $response = $this->doPost(
1091: $this->apiHost,
1092: "1/fileops/copy",
1093: array(
1094: "root" => $this->root,
1095: "from_path" => $fromPath,
1096: "to_path" => $toPath,
1097: ));
1098:
1099: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1100:
1101: return RequestUtil::parseResponseJson($response->body);
1102: }
1103:
1104: /**
1105: * Creates a file or folder based on an existing copy ref (possibly from a different Dropbox
1106: * account).
1107: *
1108: * @param string $copyRef
1109: * A copy ref obtained via the {@link createCopyRef()} call.
1110: *
1111: * @param string $toPath
1112: * The Dropbox path you want to copy the file or folder to (UTF-8).
1113: *
1114: * @return mixed
1115: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
1116: * object</a> for the new file or folder.
1117: *
1118: * @throws Exception
1119: */
1120: function copyFromCopyRef($copyRef, $toPath)
1121: {
1122: Checker::argStringNonEmpty("copyRef", $copyRef);
1123: Path::checkArgNonRoot("toPath", $toPath);
1124:
1125: $response = $this->doPost(
1126: $this->apiHost,
1127: "1/fileops/copy",
1128: array(
1129: "root" => $this->root,
1130: "from_copy_ref" => $copyRef,
1131: "to_path" => $toPath,
1132: )
1133: );
1134:
1135: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1136:
1137: return RequestUtil::parseResponseJson($response->body);
1138: }
1139:
1140: /**
1141: * Creates a folder.
1142: *
1143: * @param string $path
1144: * The Dropbox path at which to create the folder (UTF-8).
1145: *
1146: * @return array|null
1147: * If successful, you'll get back the
1148: * <a href="https://www.dropbox.com/developers/core/api#metadata-details>metadata object</a>
1149: * for the newly-created folder. If not successful, you'll get <code>null</code>.
1150: *
1151: * @throws Exception
1152: */
1153: function createFolder($path)
1154: {
1155: Path::checkArgNonRoot("path", $path);
1156:
1157: $response = $this->doPost(
1158: $this->apiHost,
1159: "1/fileops/create_folder",
1160: array(
1161: "root" => $this->root,
1162: "path" => $path,
1163: ));
1164:
1165: if ($response->statusCode === 403) return null;
1166: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1167:
1168: return RequestUtil::parseResponseJson($response->body);
1169: }
1170:
1171: /**
1172: * Deletes a file or folder
1173: *
1174: * See <a href="https://www.dropbox.com/developers/core/api#fileops-delete">/fileops/delete</a>.
1175: *
1176: * @param string $path
1177: * The Dropbox path of the file or folder to delete (UTF-8).
1178: *
1179: * @return mixed
1180: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
1181: * object</a> for the deleted file or folder.
1182: *
1183: * @throws Exception
1184: */
1185: function delete($path)
1186: {
1187: Path::checkArgNonRoot("path", $path);
1188:
1189: $response = $this->doPost(
1190: $this->apiHost,
1191: "1/fileops/delete",
1192: array(
1193: "root" => $this->root,
1194: "path" => $path,
1195: ));
1196:
1197: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1198:
1199: return RequestUtil::parseResponseJson($response->body);
1200: }
1201:
1202: /**
1203: * Moves a file or folder to a new location.
1204: *
1205: * See <a href="https://www.dropbox.com/developers/core/api#fileops-move">/fileops/move</a>.
1206: *
1207: * @param string $fromPath
1208: * The source Dropbox path (UTF-8).
1209: *
1210: * @param string $toPath
1211: * The destination Dropbox path (UTF-8).
1212: *
1213: * @return mixed
1214: * The <a href="https://www.dropbox.com/developers/core/api#metadata-details">metadata
1215: * object</a> for the destination file or folder.
1216: *
1217: * @throws Exception
1218: */
1219: function move($fromPath, $toPath)
1220: {
1221: Path::checkArgNonRoot("fromPath", $fromPath);
1222: Path::checkArgNonRoot("toPath", $toPath);
1223:
1224: $response = $this->doPost(
1225: $this->apiHost,
1226: "1/fileops/move",
1227: array(
1228: "root" => $this->root,
1229: "from_path" => $fromPath,
1230: "to_path" => $toPath,
1231: ));
1232:
1233: if ($response->statusCode !== 200) throw RequestUtil::unexpectedStatus($response);
1234:
1235: return RequestUtil::parseResponseJson($response->body);
1236: }
1237:
1238: /**
1239: * @param string $host
1240: * @param string $path
1241: * @param array|null $params
1242: * @return HttpResponse
1243: *
1244: * @throws Exception
1245: */
1246: private function doGet($host, $path, $params = null)
1247: {
1248: Checker::argString("host", $host);
1249: Checker::argString("path", $path);
1250: return RequestUtil::doGet($this->config, $this->accessToken, $host, $path, $params);
1251: }
1252:
1253: /**
1254: * @param string $host
1255: * @param string $path
1256: * @param array|null $params
1257: * @return HttpResponse
1258: *
1259: * @throws Exception
1260: */
1261: private function doPost($host, $path, $params = null)
1262: {
1263: Checker::argString("host", $host);
1264: Checker::argString("path", $path);
1265: return RequestUtil::doPost($this->config, $this->accessToken, $host, $path, $params);
1266: }
1267:
1268: /**
1269: * @param string $url
1270: * @return Curl
1271: */
1272: private function mkCurl($url)
1273: {
1274: return RequestUtil::mkCurl($this->config, $url, $this->accessToken);
1275: }
1276:
1277: /**
1278: * Parses date/time strings returned by the Dropbox API. The Dropbox API returns date/times
1279: * formatted like: <code>"Sat, 21 Aug 2010 22:31:20 +0000"</code>.
1280: *
1281: * @param string $apiDateTimeString
1282: * A date/time string returned by the API.
1283: *
1284: * @return \DateTime
1285: * A standard PHP <code>\DateTime</code> instance.
1286: *
1287: * @throws Exception_BadResponse
1288: * Thrown if <code>$apiDateTimeString</code> isn't correctly formatted.
1289: */
1290: static function parseDateTime($apiDateTimeString)
1291: {
1292: $dt = \DateTime::createFromFormat(self::$dateTimeFormat, $apiDateTimeString);
1293: if ($dt === false) throw new Exception_BadResponse(
1294: "Bad date/time from server: ".self::q($apiDateTimeString));
1295: return $dt;
1296: }
1297:
1298: private static $dateTimeFormat = "D, d M Y H:i:s T";
1299:
1300: private static function q($object) { return var_export($object, true); }
1301:
1302: private static function getField($j, $fieldName)
1303: {
1304: if (!array_key_exists($fieldName, $j)) throw new Exception_BadResponse(
1305: "missing field \"$fieldName\": $body");
1306: return $j[$fieldName];
1307: }
1308: }
1309: