Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Dropbox V2 API.
  19   *
  20   * @since       Moodle 3.2
  21   * @package     repository_dropbox
  22   * @copyright   Andrew Nicols <andrew@nicols.co.uk>
  23   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  namespace repository_dropbox;
  27  
  28  use core\oauth2\client;
  29  use core\oauth2\issuer;
  30  
  31  /**
  32   * Dropbox V2 API.
  33   *
  34   * @package     repository_dropbox
  35   * @copyright   Andrew Nicols <andrew@nicols.co.uk>
  36   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class dropbox extends client {
  39  
  40      /**
  41       * @var array Custom continue endpoints that differ from the standard.
  42       */
  43      private $mappedcontinueoverides = [
  44          'files/search_v2' => 'files/search/continue_v2'
  45      ];
  46  
  47      /**
  48       * Create the DropBox API Client.
  49       *
  50       * @param   issuer      $issuer     The dropbox issuer
  51       * @param   string      $callback   The callback URL
  52       */
  53      public function __construct(issuer $issuer, $callback) {
  54          parent::__construct($issuer, $callback, '', false, true);
  55      }
  56  
  57      /**
  58       * Override - Return an empty string to override parent function.
  59       *
  60       * Dropbox does not require scopes to be provided and can function without them.
  61       * Additional information MDL-70268
  62       *
  63       * @return string
  64       */
  65      protected function get_login_scopes() {
  66          return '';
  67      }
  68  
  69      /**
  70       * Returns the auth url for OAuth 2.0 request.
  71       *
  72       * @return string the auth url
  73       */
  74      protected function auth_url() {
  75          return 'https://www.dropbox.com/oauth2/authorize';
  76      }
  77  
  78      /**
  79       * Returns the token url for OAuth 2.0 request.
  80       *
  81       * @return string the auth url
  82       */
  83      protected function token_url() {
  84          return 'https://api.dropboxapi.com/oauth2/token';
  85      }
  86  
  87      /**
  88       * Return the constructed API endpoint URL.
  89       *
  90       * @param   string      $endpoint   The endpoint to be contacted
  91       * @return  moodle_url              The constructed API URL
  92       */
  93      protected function get_api_endpoint($endpoint) {
  94          return new \moodle_url('https://api.dropboxapi.com/2/' . $endpoint);
  95      }
  96  
  97      /**
  98       * Return the constructed content endpoint URL.
  99       *
 100       * @param   string      $endpoint   The endpoint to be contacted
 101       * @return  moodle_url              The constructed content URL
 102       */
 103      protected function get_content_endpoint($endpoint) {
 104          return new \moodle_url('https://api-content.dropbox.com/2/' . $endpoint);
 105      }
 106  
 107      /**
 108       * Get the continue endpoint for the provided endpoint.
 109       *
 110       * @param string $endpoint The original endpoint
 111       * @return string $endpoint The generated/mapped continue link
 112       */
 113      protected function get_endpoint_for_continue(string $endpoint) {
 114          // Any API endpoint returning 'has_more' will provide a cursor, and also have a matching endpoint suffixed
 115          // with /continue which takes that cursor.
 116          if (preg_match('_/continue$_', $endpoint) === 0) {
 117              // First check if the API call uses a custom mapped continue endpoint.
 118              if (isset($this->mappedcontinueoverides[$endpoint])) {
 119                  $endpoint = $this->mappedcontinueoverides[$endpoint];
 120              } else {
 121                  // Only add /continue if it is not already present.
 122                  $endpoint .= '/continue';
 123              }
 124          }
 125  
 126          return $endpoint;
 127      }
 128  
 129      /**
 130       * Make an API call against the specified endpoint with supplied data.
 131       *
 132       * @param   string      $endpoint   The endpoint to be contacted
 133       * @param   array       $data       Any data to pass to the endpoint
 134       * @param   string      $resultnode The name of the node that contains the data
 135       * @return  object                  Content decoded from the endpoint
 136       */
 137      protected function fetch_dropbox_data($endpoint, $data = [], string $resultnode = 'entries') {
 138          $url = $this->get_api_endpoint($endpoint);
 139          $this->cleanopt();
 140          $this->resetHeader();
 141  
 142          if ($data === null) {
 143              // Some API endpoints explicitly expect a data submission of 'null'.
 144              $options['CURLOPT_POSTFIELDS'] = 'null';
 145          } else {
 146              $options['CURLOPT_POSTFIELDS'] = json_encode($data);
 147          }
 148          $options['CURLOPT_POST'] = 1;
 149          $this->setHeader('Content-Type: application/json');
 150  
 151          $response = $this->request($url, $options);
 152          $result = json_decode($response);
 153  
 154          $this->check_and_handle_api_errors($result);
 155  
 156          if ($this->has_additional_results($result)) {
 157              $endpoint = $this->get_endpoint_for_continue($endpoint);
 158  
 159              // Fetch the next page of results.
 160              $additionaldata = $this->fetch_dropbox_data($endpoint, [
 161                      'cursor' => $result->cursor,
 162                  ], $resultnode);
 163  
 164              // Merge the list of entries.
 165              $result->$resultnode = array_merge($result->$resultnode, $additionaldata->$resultnode);
 166          }
 167  
 168          if (isset($result->has_more)) {
 169              // Unset the cursor and has_more flags.
 170              unset($result->cursor);
 171              unset($result->has_more);
 172          }
 173  
 174          return $result;
 175      }
 176  
 177      /**
 178       * Whether the supplied result is paginated and not the final page.
 179       *
 180       * @param   object      $result     The result of an operation
 181       * @return  boolean
 182       */
 183      public function has_additional_results($result) {
 184          return !empty($result->has_more) && !empty($result->cursor);
 185      }
 186  
 187      /**
 188       * Fetch content from the specified endpoint with the supplied data.
 189       *
 190       * @param   string      $endpoint   The endpoint to be contacted
 191       * @param   array       $data       Any data to pass to the endpoint
 192       * @return  string                  The returned data
 193       */
 194      protected function fetch_dropbox_content($endpoint, $data = []) {
 195          $url = $this->get_content_endpoint($endpoint);
 196          $this->cleanopt();
 197          $this->resetHeader();
 198  
 199          $options['CURLOPT_POST'] = 1;
 200          $this->setHeader('Content-Type: ');
 201          $this->setHeader('Dropbox-API-Arg: ' . json_encode($data));
 202  
 203          $response = $this->request($url, $options);
 204  
 205          $this->check_and_handle_api_errors($response);
 206          return $response;
 207      }
 208  
 209      /**
 210       * Check for an attempt to handle API errors.
 211       *
 212       * This function attempts to deal with errors as per
 213       * https://www.dropbox.com/developers/documentation/http/documentation#error-handling.
 214       *
 215       * @param   mixed      $data       The returned content.
 216       * @throws  moodle_exception
 217       */
 218      protected function check_and_handle_api_errors($data) {
 219          if (!is_array($this->info) or $this->info['http_code'] == 200) {
 220              // Dropbox only returns errors on non-200 response codes.
 221              return;
 222          }
 223  
 224          switch($this->info['http_code']) {
 225              case 400:
 226                  // Bad input parameter. Error message should indicate which one and why.
 227                  throw new \coding_exception('Invalid input parameter passed to DropBox API.');
 228                  break;
 229              case 401:
 230                  // Bad or expired token. This can happen if the access token is expired or if the access token has been
 231                  // revoked by Dropbox or the user. To fix this, you should re-authenticate the user.
 232                  throw new authentication_exception('Authentication token expired');
 233                  break;
 234              case 409:
 235                  // Endpoint-specific error. Look to the JSON response body for the specifics of the error.
 236                  throw new \coding_exception('Endpoint specific error: ' . $data->error_summary);
 237                  break;
 238              case 429:
 239                  // Your app is making too many requests for the given user or team and is being rate limited. Your app
 240                  // should wait for the number of seconds specified in the "Retry-After" response header before trying
 241                  // again.
 242                  throw new rate_limit_exception();
 243                  break;
 244              default:
 245                  break;
 246          }
 247  
 248          if ($this->info['http_code'] >= 500 && $this->info['http_code'] < 600) {
 249              throw new \invalid_response_exception($this->info['http_code'] . ": " . $data);
 250          }
 251      }
 252  
 253      /**
 254       * Get file listing from dropbox.
 255       *
 256       * @param   string      $path       The path to query
 257       * @return  object                  The returned directory listing, or null on failure
 258       */
 259      public function get_listing($path = '') {
 260          if ($path === '/') {
 261              $path = '';
 262          }
 263  
 264          $data = $this->fetch_dropbox_data('files/list_folder', [
 265                  'path' => $path,
 266              ]);
 267  
 268          return $data;
 269      }
 270  
 271      /**
 272       * Get file search results from dropbox.
 273       *
 274       * @param   string      $query      The search query
 275       * @return  object                  The returned directory listing, or null on failure
 276       */
 277      public function search($query = '') {
 278          // There is nothing to be searched. Return an empty array to mimic the response from Dropbox.
 279          if (!$query) {
 280              return [];
 281          }
 282  
 283          $data = $this->fetch_dropbox_data('files/search_v2', [
 284                  'options' => [
 285                      'path' => '',
 286                      'filename_only' => true,
 287                  ],
 288                  'query' => $query,
 289              ], 'matches');
 290  
 291          return $data;
 292      }
 293  
 294      /**
 295       * Whether the entry is expected to have a thumbnail.
 296       * See docs at https://www.dropbox.com/developers/documentation/http/documentation#files-get_thumbnail.
 297       *
 298       * @param   object      $entry      The file entry received from the DropBox API
 299       * @return  boolean                 Whether dropbox has a thumbnail available
 300       */
 301      public function supports_thumbnail($entry) {
 302          if ($entry->{".tag"} !== "file") {
 303              // Not a file. No thumbnail available.
 304              return false;
 305          }
 306  
 307          // Thumbnails are available for files under 20MB with file extensions jpg, jpeg, png, tiff, tif, gif, and bmp.
 308          if ($entry->size > 20 * 1024 * 1024) {
 309              return false;
 310          }
 311  
 312          $supportedtypes = [
 313                  'jpg'   => true,
 314                  'jpeg'  => true,
 315                  'png'   => true,
 316                  'tiff'  => true,
 317                  'tif'   => true,
 318                  'gif'   => true,
 319                  'bmp'   => true,
 320              ];
 321  
 322          $extension = substr($entry->path_lower, strrpos($entry->path_lower, '.') + 1);
 323          return isset($supportedtypes[$extension]) && $supportedtypes[$extension];
 324      }
 325  
 326      /**
 327       * Retrieves the thumbnail for the content, as supplied by dropbox.
 328       *
 329       * @param   string      $path       The path to fetch a thumbnail for
 330       * @return  string                  Thumbnail image content
 331       */
 332      public function get_thumbnail($path) {
 333          $content = $this->fetch_dropbox_content('files/get_thumbnail', [
 334                  'path' => $path,
 335              ]);
 336  
 337          return $content;
 338      }
 339  
 340      /**
 341       * Fetch a valid public share link for the specified file.
 342       *
 343       * @param   string      $id         The file path or file id of the file to fetch information for.
 344       * @return  object                  An object containing the id, path, size, and URL of the entry
 345       */
 346      public function get_file_share_info($id) {
 347          // Attempt to fetch any existing shared link first.
 348          $data = $this->fetch_dropbox_data('sharing/list_shared_links', [
 349                  'path'      => $id,
 350              ]);
 351  
 352          if (isset($data->links)) {
 353              $link = reset($data->links);
 354              if (isset($link->{".tag"}) && $link->{".tag"} === "file") {
 355                  return $this->normalize_file_share_info($link);
 356              }
 357          }
 358  
 359          // No existing link available.
 360          // Create a new one.
 361          $link = $this->fetch_dropbox_data('sharing/create_shared_link_with_settings', [
 362                  'path'      => $id,
 363                  'settings'  => [
 364                      'requested_visibility'  => 'public',
 365                  ],
 366              ]);
 367  
 368          if (isset($link->{".tag"}) && $link->{".tag"} === "file") {
 369              return $this->normalize_file_share_info($link);
 370          }
 371  
 372          // Some kind of error we don't know how to handle at this stage.
 373          return null;
 374      }
 375  
 376      /**
 377       * Normalize the file share info.
 378       *
 379       * @param   object $entry   Information retrieved from share endpoints
 380       * @return  object          Normalized entry information to store as repository information
 381       */
 382      protected function normalize_file_share_info($entry) {
 383          return (object) [
 384                  'id'    => $entry->id,
 385                  'path'  => $entry->path_lower,
 386                  'url'   => $entry->url,
 387              ];
 388      }
 389  
 390      /**
 391       * Process the callback.
 392       */
 393      public function callback() {
 394          $this->log_out();
 395          $this->is_logged_in();
 396      }
 397  
 398      /**
 399       * Revoke the current access token.
 400       *
 401       * @return string
 402       */
 403      public function logout() {
 404          try {
 405              $this->fetch_dropbox_data('auth/token/revoke', null);
 406          } catch(authentication_exception $e) {
 407              // An authentication_exception may be expected if the token has
 408              // already expired.
 409          }
 410      }
 411  }