require "dropbox_sdk" module Backup module Storage class Dropbox < Base include Storage::Cycler class Error < Backup::Error; end ## # Dropbox API credentials attr_accessor :api_key, :api_secret ## # Path to store cached authorized session. # # Relative paths will be expanded using Config.root_path, # which by default is ~/Backup unless --root-path was used # on the command line or set in config.rb. # # By default, +cache_path+ is '.cache', which would be # '~/Backup/.cache/' if using the default root_path. attr_accessor :cache_path ## # Dropbox Access Type # Valid values are: # :app_folder (default) # :dropbox (full access) attr_accessor :access_type ## # Chunk size, specified in MiB, for the ChunkedUploader. attr_accessor :chunk_size ## # Number of times to retry failed operations. # # Default: 10 attr_accessor :max_retries ## # Time in seconds to pause before each retry. # # Default: 30 attr_accessor :retry_waitsec ## # Creates a new instance of the storage object def initialize(model, storage_id = nil) super @path ||= "backups" @cache_path ||= ".cache" @access_type ||= :app_folder @chunk_size ||= 4 # MiB @max_retries ||= 10 @retry_waitsec ||= 30 path.sub!(/^\//, "") end private ## # The initial connection to Dropbox will provide the user with an # authorization url. The user must open this URL and confirm that the # authorization successfully took place. If this is the case, then the # user hits 'enter' and the session will be properly established. # Immediately after establishing the session, the session will be # serialized and written to a cache file in +cache_path+. # The cached file will be used from that point on to re-establish a # connection with Dropbox at a later time. This allows the user to avoid # having to go to a new Dropbox URL to authorize over and over again. def connection return @connection if @connection unless session = cached_session Logger.info "Creating a new session!" session = create_write_and_return_new_session! end # will raise an error if session not authorized @connection = DropboxClient.new(session, access_type) rescue => err raise Error.wrap(err, "Authorization Failed") end ## # Attempt to load a cached session def cached_session session = false if File.exist?(cached_file) begin session = DropboxSession.deserialize(File.read(cached_file)) Logger.info "Session data loaded from cache!" rescue => err Logger.warn Error.wrap(err, <<-EOS) Could not read session data from cache. Cache data might be corrupt. EOS end end session end ## # Transfer each of the package files to Dropbox in chunks of +chunk_size+. # Each chunk will be retried +chunk_retries+ times, pausing +retry_waitsec+ # between retries, if errors occur. def transfer! package.filenames.each do |filename| src = File.join(Config.tmp_path, filename) dest = File.join(remote_path, filename) Logger.info "Storing '#{dest}'..." uploader = nil File.open(src, "r") do |file| uploader = connection.get_chunked_uploader(file, file.stat.size) while uploader.offset < uploader.total_size with_retries do uploader.upload(1024**2 * chunk_size) end end end with_retries do uploader.finish(dest) end end rescue => err raise Error.wrap(err, "Upload Failed!") end def with_retries retries = 0 begin yield rescue StandardError => err retries += 1 raise if retries > max_retries Logger.info Error.wrap(err, "Retry ##{retries} of #{max_retries}.") sleep(retry_waitsec) retry end end # Called by the Cycler. # Any error raised will be logged as a warning. def remove!(package) Logger.info "Removing backup package dated #{package.time}..." connection.file_delete(remote_path_for(package)) end def cached_file path = cache_path.start_with?("/") ? cache_path : File.join(Config.root_path, cache_path) File.join(path, api_key + api_secret) end ## # Serializes and writes the Dropbox session to a cache file def write_cache!(session) FileUtils.mkdir_p File.dirname(cached_file) File.open(cached_file, "w") do |cache_file| cache_file.write(session.serialize) end end ## # Create a new session, write a serialized version of it to the # .cache directory, and return the session object def create_write_and_return_new_session! require "timeout" session = DropboxSession.new(api_key, api_secret) # grab the request token for session session.get_request_token template = Backup::Template.new( session: session, cached_file: cached_file ) template.render("storage/dropbox/authorization_url.erb") # wait for user to hit 'return' to continue Timeout.timeout(180) { STDIN.gets } # this will raise an error if the user did not # visit the authorization_url and grant access # # get the access token from the server # this will be stored with the session in the cache file session.get_access_token template.render("storage/dropbox/authorized.erb") write_cache!(session) template.render("storage/dropbox/cache_file_written.erb") session rescue => err raise Error.wrap(err, "Could not create or authenticate a new session") end end end end