Skip to main content

FTP Service for Active Storage

Rails • Asked by Alexey

Hi!

I am developing a service for ActiveStorage https://github.com/gordienko/activestorage-ftp with the ability to download and delete files via the FTP protocol, and the display via Nginx. The module https://github.com/luan/carrierwave-ftp is used as an example.

The nginx Perl Module to Output Content-MD5 HTTP Header https://gist.github.com/sivel/1870822 is used to verify the checksums of the files.

The tasks of downloading, verifying and deleting files are solved. There are issues with the redefinition of the url and url_for_direct_upload functions.

The url_for_direct_upload function still uses the DiskService.

I give my code below, I apologize for a lot of code.

Perhaps, the idea will seem interesting to someone. I will be glad to any comments or suggestions.

require "active_storage_ftp/ex_ftp"
require "active_storage_ftp/ex_ftptls"
require "digest/md5"
require "active_support/core_ext/numeric/bytes"

module ActiveStorage

  class Service::FtpService < Service

    def initialize(**config)
      @config = config
    end

    def upload(key, io, checksum: nil, **)
      instrument :upload, key: key, checksum: checksum do
        connection do |ftp|
          path_for(key).tap do |path|
            ftp.mkdir_p(::File.dirname path)
            ftp.chdir(::File.dirname path)
            ftp.storbinary("STOR #{File.basename(key)}", io, Net::FTP::DEFAULT_BLOCKSIZE)
            if ftp_chmod
              ftp.sendcmd("SITE CHMOD #{ftp_chmod.to_s(8)} #{path_for(key)}")
            end
          end
        end
        ensure_integrity_of(key, checksum) if checksum
      end
    end

    def download(key)
      if block_given?
        instrument :streaming_download, key: key do
          open(http_url_for(key)) do |file|
            while data = file.read(64.kilobytes)
              yield data
            end
          end
        end
      else
        instrument :download, key: key do
          open(http_url_for(key)) do |file|
            file.read
          end
        end
      end
    end

    def download_chunk(key, range)
      instrument :download_chunk, key: key, range: range do
        open(http_url_for(key)) do |file|
            file.seek range.begin
            file.read range.size
        end
      end
    end

    def delete(key)
      instrument :delete, key: key do
        begin
          connection do |ftp|
            ftp.chdir(::File.dirname path_for(key))
            ftp.delete(::File.basename path_for(key))
          end
        rescue
          # Ignore files already deleted
        end
      end
    end

    def delete_prefixed(prefix)
      instrument :delete_prefixed, prefix: prefix do
        connection do |ftp|
          ftp.chdir(path_for(prefix))
          ftp.list.each do |file|
            ftp.delete(file.split.last)
          end
        end
      end
    end

    def exist?(key)
      instrument :exist, key: key do |payload|
        response = request_head(key)
        answer = response.code.to_i == 200
        payload[:exist] = answer
        answer
      end
    end

    def url(key, expires_in:, filename:, disposition:, content_type:)
      instrument :url, key: key do |payload|
        generated_url = http_url_for(key)
        payload[:url] = generated_url
        generated_url
      end
    end

    def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
      instrument :url, key: key do |payload|
        verified_token_with_expiration = ActiveStorage.verifier.generate(
            {
                key: key,
                content_type: content_type,
                content_length: content_length,
                checksum: checksum
            },
            {expires_in: expires_in,
             purpose: :blob_token}
        )

        generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
        payload[:url] = generated_url
        generated_url

      end
    end

    def headers_for_direct_upload(key, content_type:, **)
      {"Content-Type" => content_type}
    end

    def path_for(key) #:nodoc:
      File.join ftp_folder, folder_for(key), key
    end

    private

    attr_reader :config

    def folder_for(key)
      [key[0..1], key[2..3]].join("/")
    end

    def ensure_integrity_of(key, checksum)
      response = request_head(key)
      unless "#{response['Content-MD5']}==" == checksum
        delete key
        raise ActiveStorage::IntegrityError
      end
    end

    def url_helpers
      @url_helpers ||= Rails.application.routes.url_helpers
    end

    def current_host
      ActiveStorage::Current.host
    end

    def request_head(key)
      uri = URI(http_url_for(key))
      request = Net::HTTP.new(uri.host, uri.port)
      request.use_ssl = uri.scheme == 'https'
      request.request_head(uri.path)
    end

    def http_url_for(key)
      ([ftp_url, folder_for(key), key].join('/'))
    end

    def inferred_content_type
      SanitizedFile.new(path).content_type
    end

    def ftp_host
      config.fetch(:ftp_host)
    end

    def ftp_port
      config.fetch(:ftp_port)
    end

    def ftp_user
      config.fetch(:ftp_user)
    end

    def ftp_passwd
      config.fetch(:ftp_passwd)
    end

    def ftp_folder
      config.fetch(:ftp_folder)
    end

    def ftp_url
      config.fetch(:ftp_url)
    end

    def ftp_passive
      config.fetch(:ftp_passive)
    end

    def ftp_chmod
      config.fetch(:ftp_chmod, 0600)
    end

    def connection
      ftp = ExFTP.new
      ftp.connect(ftp_host, ftp_port)
      begin
        ftp.passive = ftp_passive
        ftp.login(ftp_user, ftp_passwd)
        yield ftp
      ensure
        ftp.quit
      end
    end

  end
end


Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 22,346+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.