CartoDB/cartodb20

View on GitHub
services/datasources/lib/datasources/url/dropbox.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'dropbox_api'
require_relative '../base_oauth'
require_relative '../../../../../lib/dropbox_api/endpoints/auth/token/revoke'

module CartoDB
  module Datasources
    module Url
      # In order to test Dropbox in local, do the following:
      # 1. In Dropbox, change OAuth2 configuration, adding this (replace username and API key as needed):
      #    http://localhost:3000/u/juanignaciosl/api/v1/imports/service/dropbox/oauth_callback/?api_key=3312b39c6360862e13217a8aec540e57367f4a4b
      # 2. Configure it in app_config.yml:
      #    dropbox:
      #      app_key:              '528omteaww7fj86'
      #      app_secret:           'rhx2ovpuni266ra'
      #      callback_url:         'http://localhost:3000/u/juanignaciosl/api/v1/imports/service/dropbox/oauth_callback/?api_key=3312b39c6360862e13217a8aec540e57367f4a4b'
      #    This obviously will work for a single user.
      class Dropbox < BaseOAuth

        include ::LoggerHelper

        # Required for all datasources
        DATASOURCE_NAME = 'dropbox'

        # Specific of this datasource
        FORMATS_TO_SEARCH_QUERIES = {
            FORMAT_CSV =>         %W( .csv ),
            FORMAT_EXCEL =>       %W( .xls .xlsx ),
            FORMAT_GPX =>         %W( .gpx ),
            FORMAT_KML =>         %W( .kml ),
            FORMAT_COMPRESSED =>  %W( .zip )
        }

        START_LIMIT = 9999

        # Constructor
        # @param config Array
        # [
        #  'app_key'
        #  'app_secret'
        #  'callback_url'
        # ]
        # @param user ::User
        # @throws UninitializedError
        # @throws MissingConfigurationError
        def initialize(config, user)
          super(config, user, %w{ app_key app_secret callback_url }, DATASOURCE_NAME)

          @user               = user
          @app_key            = config.fetch('app_key')
          @app_secret         = config.fetch('app_secret')
          @callback_url       = config.fetch('callback_url')

          self.filter   = []
          @access_token = nil
          @auth_flow    = nil
          @client       = nil
        end

        # Factory method
        # @param config : {}
        # @param user : ::User
        # @return CartoDB::Datasources::Url::Dropbox
        def self.get_new(config, user)
          return new(config, user)
        end

        # If will provide a url to download the resource, or requires calling get_resource()
        # @return bool
        def providers_download_url?
          false
        end

        # Return the url to be displayed or sent the user to to authenticate and get authorization code
        # Older implementations had a use_callback_flow parameter that became deprecated. Not implemented.
        # @throws AuthError
        def get_auth_url
          authenticator.authorize_url redirect_uri: @callback_url, state: state
        rescue StandardError => ex
          raise AuthError.new("get_auth_url(#{use_callback_flow}): #{ex.message}", DATASOURCE_NAME)
        end

        # Validates the authorization callback
        # @param params : mixed
        def validate_callback(params)
          raise "state doesn't match" unless params[:state] == state
          auth_bearer = authenticator.get_token(params[:code], redirect_uri: @callback_url)
          @access_token = auth_bearer.token

          @client = DropboxApi::Client.new(@access_token)
          @access_token
        rescue StandardError => ex
          raise AuthError.new("validate_callback(#{params.inspect}): #{ex.message}", DATASOURCE_NAME)
        end

        # Set the token
        # @param token string
        # @throws TokenExpiredOrInvalidError
        # @throws AuthError
        def token=(token)
          @access_token = token
          @client = DropboxApi::Client.new(@access_token)
        rescue StandardError => ex
          handle_error(ex, "token= : #{ex.message}")
        end

        # Retrieve set token
        # @return string | nil
        def token
          @access_token
        end

        # Perform the listing and return results
        # @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
        # @return [ { :id, :title, :url, :service } ]
        # @throws TokenExpiredOrInvalidError
        # @throws AuthError
        # @throws DataDownloadError
        def get_resources_list(filter=[])
          all_results = []
          self.filter = filter

          @formats.each do |search_query|
            start = 0
            loop do
              response = @client.search(search_query, '', max_results: SEARCH_BATCH_SIZE, start: start)
              response.matches.select { |item| item.resource.is_a?(DropboxApi::Metadata::File) }.each do |item|
                all_results.push(format_item_data(item.resource))
              end
              no_more_results = start == START_LIMIT || !response.has_more?
              break if no_more_results

              start += SEARCH_BATCH_SIZE
              start = START_LIMIT if start > START_LIMIT
            end
          end
          all_results
        rescue StandardError => ex
          handle_error(ex, "get_resources_list(): #{ex.message}")
        end

        # Retrieves a resource and returns its contents
        # @param id string
        # @return mixed
        # @throws TokenExpiredOrInvalidError
        # @throws AuthError
        # @throws DataDownloadError
        def get_resource(id)
          file_contents = ''
          @client.download(id) do |chunk|
            file_contents << chunk
          end
          file_contents
        rescue StandardError => ex
          handle_error(ex, "get_resource() #{id}: #{ex.message}")
        end

        # @param id string
        # @return Hash
        # @throws TokenExpiredOrInvalidError
        # @throws AuthError
        # @throws DataDownloadError
        def get_resource_metadata(id)
          raise DropboxPermissionError.new('No Dropbox client', DATASOURCE_NAME) unless @client.present?

          response = @client.get_metadata(id)
          item_data = format_item_data(response)

          item_data.to_hash
        rescue StandardError => ex
          handle_error(ex, "get_resource_metadata() #{id}: #{ex.message}")
        end

        # Retrieves current filters
        # @return {}
        def filter
          @formats
        end

        # Sets current filters
        # @param filter_data {}
        def filter=(filter_data=[])
          @formats = []
          FORMATS_TO_SEARCH_QUERIES.each do |id, queries|
            if filter_data.empty? || filter_data.include?(id)
              queries.each do |query|
                @formats = @formats.push(query)
              end
            end
          end
        end

        # Just return datasource name
        # @return string
        def to_s
          DATASOURCE_NAME
        end

        # If this datasource accepts a data import instance
        # @return Boolean
        def persists_state_via_data_import?
          false
        end

        # Stores the data import item instance to use/manipulate it
        # @param value DataImport
        def data_import_item=(value)
          nil
        end

        # Checks if token is still valid or has been revoked
        # @return bool
        # @throws AuthError
        def token_valid?
          # Any call would do, we just want to see if communicates or refuses the token
          @client.get_current_account
          true
        rescue DropboxApi::Errors::HttpError
          false
        end

        # Revokes current set token
        def revoke_token
          @client.revoke
          true
        rescue DropboxApi::Errors::HttpError => ex
          log_info(message: 'Error revoking Dropbox token: already invalid', exception: ex, current_user: @user)
          true
        rescue StandardError => ex
          raise AuthError.new("revoke_token: #{ex.message}", DATASOURCE_NAME)
        end

        private

        SEARCH_BATCH_SIZE = 1000

        # Handles
        # @param original_exception mixed
        # @param message string
        # @throws TokenExpiredOrInvalidError
        # @throws AuthError
        # @throws mixed
        def handle_error(original_exception, message)
          if original_exception.is_a? DropboxApi::Errors::NotFoundError
            raise NotFoundDownloadError.new(message, DATASOURCE_NAME)
          elsif original_exception.is_a? DropboxApi::Errors::BasicError
            error_code = original_exception.http_response.code.to_i
            if error_code == 401 || error_code == 403
              raise TokenExpiredOrInvalidError.new(message, DATASOURCE_NAME)
            else
              raise AuthError.new(message)
            end
          elsif original_exception.is_a? ArgumentError
            raise DataDownloadError.new(message, DATASOURCE_NAME)
          else
            raise original_exception
          end
        end

        # Formats all data to comply with our desired format
        # @param item_data Hash : Single item returned from Dropbox API
        # @return { :id, :title, :url, :service, :size }
        def format_item_data(resource)
          path = resource.path_display
          filename = path.split('/').last

          {
            id:       path,
            title:    filename,
            filename: filename,
            service:  DATASOURCE_NAME,
            checksum: checksum_of(resource.rev),
            size:     resource.size
          }
        end

        def authenticator
          @authenticator ||= DropboxApi::Authenticator.new(@app_key, @app_secret)
        end

        def state
          service_name = service_name_for_user(DATASOURCE_NAME, @user)
          CALLBACK_STATE_DATA_PLACEHOLDER.sub('service', service_name).sub('user', @user.username)
        end
      end
    end
  end
end