elabs/refile

View on GitHub
lib/refile.rb

Summary

Maintainability
A
1 hr
Test Coverage
require "uri"
require "fileutils"
require "tempfile"
require "logger"
require "mime/types"

module Refile
  # @api private
  class Error < StandardError; end

  # @api private
  class Invalid < Error; end

  # @api private
  class InvalidID < Invalid; end

  # @api private
  class InvalidMaxSize < Invalid; end

  # @api private
  class InvalidFile < Invalid; end

  # Raised when the given URL couldn't be parsed.
  class InvalidUrl < Error; end

  # Raised when the given URL redirects more than allowed.
  class TooManyRedirects < Error; end

  # @api private
  class Confirm < StandardError
    def message
      "are you sure? this will remove all files in the backend, call as \
      `clear!(:confirm)` if you're sure you want to do this"
    end
  end

  class << self
    # A shortcut to the instance of the Rack application. This should be
    # set when the application is initialized. `refile/rails` sets this
    # value.
    #
    # @return [Refile::App, nil]
    attr_accessor :app

    # The host name of a CDN distribution that the Rack application can be
    # reached at. If not set, Refile will use an absolute URL without hostname.
    # It is strongly recommended to run Refile behind a CDN and to set this to
    # the hostname of the CDN distribution.
    #
    # The `cdn_host` setting is used when retrieving files, but not when
    # uploading new files, since uploads should normally not go through the
    # CDN.
    #
    # A protocol relative URL is recommended for this value.
    #
    # @return [String, nil]
    attr_accessor :cdn_host

    # The host name that the Rack application can be reached at. If not set,
    # Refile will use an absolute URL without hostname. You should only change
    # this setting if you are running the Refile app on a different domain
    # than your main application.
    #
    # If you are simply running the Refile app behind a CDN you'll want to
    # change {Refile.cdn_host} instead.
    #
    # The difference between {Refile.app_host} and {Refile.cdn_host} is that the
    # latter only affects URLs generated by {Refile.file_url} and the
    # {Refile::AttachmentHelper#attachment_url} and
    # {Refile::AttachmentHelper#attachment_image_tag} helpers, whereas the
    # former also affects {Refile.upload_url}, {Refile.presign_url} and the
    # {Refile::AttachmentHelper#attachment_field} helper.
    #
    # @return [String, nil]
    attr_accessor :app_host

    # @deprecated use {Refile.cdn_host} instead
    def host
      warn "Refile.host is deprecated, please use Refile.cdn_host instead"
      cdn_host
    end

    # @deprecated use {Refile.cdn_host} instead
    def host=(host)
      warn "Refile.host is deprecated, please use Refile.cdn_host instead"
      self.cdn_host = host
    end

    # A list of names which identify backends in the global backend registry.
    # The Rack application allows POST requests to only the backends specified
    # in this config option. This defaults to `["cache"]`, only allowing direct
    # uploads to the cache backend.
    #
    # @return [Array[String], :all]
    attr_accessor :allow_uploads_to

    # A list of names which identify backends in the global backend registry.
    # The Rack application allows GET requests to only the backends specified
    # in this config option. This defaults to `:all`, allowing files from all
    # backends to be downloaded.
    #
    # @return [Array[String], :all]
    attr_accessor :allow_downloads_from

    # Logger that should be used by rack application
    #
    # @return [Logger]
    attr_accessor :logger

    # Value for Access-Control-Allow-Origin header
    #
    # @return [String]
    attr_accessor :allow_origin

    # Value for Cache-Control: max-age=<value in seconds> header
    #
    # @return [Integer]
    attr_accessor :content_max_age

    # Where should the rack application be mounted? The default is 'attachments'.
    #
    # @return [String]
    attr_accessor :mount_point

    # Should the rack application be automounted in a Rails app?
    #
    # If set to false then Refile.app should be mounted in the Rails application
    # routes.rb with the options `at: Refile.mount_point, as: :refile_app`
    #
    # The default is true.
    #
    # @return [Boolean]
    attr_accessor :automount

    # Value for generating signed attachment urls to protect from DoS
    #
    # @return [String]
    attr_accessor :secret_key

    # A global registry of backends.
    #
    # @return [Hash{String => Backend}]
    def backends
      @backends ||= {}
    end

    # A global registry of processors. These will be used by the Rack
    # application to manipulate files prior to serving them up to the user,
    # based on options sent trough the URL. This can be used for example to
    # resize images or to convert files to another file format.
    #
    # @return [Hash{String => Proc}]
    def processors
      @processors ||= {}
    end

    # A global registry of types. Currently, types are simply aliases for a set
    # of content types, but their functionality may expand in the future.
    #
    # @return [Hash{Symbol => Refile::Type}]
    def types
      @types ||= {}
    end

    # Adds a processor. The processor must respond to `call`, both receiving
    # and returning an IO-like object. Alternatively a block can be given to
    # this method which also receives and returns an IO-like object.
    #
    # An IO-like object is recommended to be an instance of the `IO` class or
    # one of its subclasses, like `File` or a `StringIO`, or a `Refile::File`.
    # It can also be any other object which responds to `size`, `read`, `eof`?
    # `rewind` and `close` and mimics the behaviour of IO objects for these
    # methods.
    #
    # @example With processor class
    #   class Reverse
    #     def call(file)
    #       StringIO.new(file.read.reverse)
    #     en
    #   end
    #   Refile.processor(:reverse, Reverse)
    #
    # @example With block
    #   Refile.processor(:reverse) do |file|
    #     StringIO.new(file.read.reverse)
    #   end
    #
    # @param [#to_s] name           The name of the processor
    # @param [Proc, nil] processor  The processor, must respond to `call` and.
    # @yield [Refile::File]         The file to modify
    # @yieldreturn [IO]             An IO-like object representing the processed file
    # @return [void]
    def processor(name, processor = nil, &block)
      processor ||= block
      processors[name.to_s] = processor
    end

    # A shortcut to retrieving the backend named "store" from the global
    # registry.
    #
    # @return [Backend]
    def store
      backends["store"]
    end

    # A shortcut to setting the backend named "store" in the global registry.
    #
    # @param [Backend] backend
    def store=(backend)
      backends["store"] = backend
    end

    # A shortcut to retrieving the backend named "cache" from the global
    # registry.
    #
    # @return [Backend]
    def cache
      backends["cache"]
    end

    # A shortcut to setting the backend named "cache" in the global registry.
    #
    # @param [Backend] backend
    def cache=(backend)
      backends["cache"] = backend
    end

    # Yield the Refile module as a convenience for configuring multiple
    # config options at once.
    #
    # @yield Refile
    def configure
      yield self
    end

    # Extract the filename from an uploadable object. If the filename cannot be
    # determined, this method will return `nil`.
    #
    # @param [IO] uploadable    The uploadable object to extract the filename from
    # @return [String, nil]     The extracted filename
    def extract_filename(uploadable)
      path = if uploadable.respond_to?(:original_filename)
        uploadable.original_filename
      elsif uploadable.respond_to?(:path)
        uploadable.path
      end
      ::File.basename(path) if path
    end

    # Extract the content type from an uploadable object. If the content type
    # cannot be determined, this method will return `nil`.
    #
    # @param [IO] uploadable    The uploadable object to extract the content type from
    # @return [String, nil]     The extracted content type
    def extract_content_type(uploadable)
      if uploadable.respond_to?(:content_type)
        uploadable.content_type
      else
        filename = extract_filename(uploadable)
        if filename
          content_type = MIME::Types.of(filename).first
          content_type.to_s if content_type
        end
      end
    end

    # Generates a URL to the Refile application.
    #
    # The host defaults to {Refile.app_host}. You can also override the host via
    # the `host` option. Normally the Refile app will not be mounted at the
    # root but rather at some other path, the `prefix` option allows you to
    # override this setting, and if not set it will fall back to
    # {Refile.mount_point}.
    #
    # @example
    #   Refile.app_url
    #
    # @example With host and prefix
    #   Refile.app_url(host: "http://some.domain", prefix: "/refile")
    #
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @return [String]                     The generated URL
    def app_url(host: nil, prefix: nil)
      host ||= Refile.app_host
      prefix ||= Refile.mount_point

      uri = URI(host.to_s)
      uri.path = prefix || "/"
      uri.to_s
    end

    # Receives a {Refile::File} and generates a URL to it.
    #
    # Optionally the name of a processor and arguments to it can be appended.
    #
    # The `filename` option must be given.
    #
    # The host defaults to {Refile.cdn_host}, which is useful for serving all
    # attachments from a CDN. You can also override the host via the `host`
    # option.
    #
    # Returns `nil` if the supplied file is `nil`.
    #
    # @example
    #   Refile.file_url(Refile.store.get(id))
    #
    # @example With processor
    #   Refile.file_url(Refile.store.get(id), :image, :fill, 300, 300, format: "jpg")
    #
    # @param [Refile::File] file           The file to generate a URL for
    # @param [String] filename             The filename to be appended to the URL
    # @param [String, nil] format          A file extension to be appended to the URL
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @param [String, nil] expires_at      Adds a sulfix to the URL that sets the expiration time of the URL
    # @param [String, nil] force_download  Adds a sulfix to the URL to force the download of the file when URL is accessed
    # @return [String, nil]                The generated URL
    def file_url(file, *args, expires_at: nil, host: nil, prefix: nil, filename:, format: nil, force_download: nil)
      return unless file

      host ||= Refile.cdn_host
      backend_name = Refile.backends.key(file.backend)

      filename = Rack::Utils.escape(filename)
      filename << "." << format.to_s if format && !filename.downcase.end_with?(format.to_s.downcase)

      base_path = ::File.join("", backend_name, *args.map(&:to_s), file.id.to_s, filename)
      if expires_at
        base_path += "?expires_at=#{expires_at.to_i}" # UNIX timestamp
      end

      base_path += "?force_download=true" if force_download

      ::File.join(app_url(prefix: prefix, host: host), token(base_path), base_path)
    end

    # Receives a Refile backend and returns a URL to the Refile application
    # where files can be uploaded.
    #
    # @example
    #   Refile.upload_url(Refile.store)
    #
    # @param [Refile::Backend] backend     The backend to generate a URL for
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @return [String]                     The generated URL
    def upload_url(backend, host: nil, prefix: nil)
      backend_name = Refile.backends.key(backend)

      ::File.join(app_url(host: host, prefix: prefix), backend_name)
    end

    # Receives a Refile backend and returns a URL to the Refile application
    # where a presign object for the backend can be retrieved.
    #
    # @example
    #   Refile.upload_url(Refile.store)
    #
    # @param [Refile::Backend] backend     The backend to generate a URL for
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @return [String]                     The generated URL
    def presign_url(backend, host: nil, prefix: nil)
      ::File.join(upload_url(backend, host: host, prefix: prefix), "presign")
    end

    # Generate a URL to an attachment. Receives an instance of a class which
    # has used the {Refile::Attachment#attachment} macro to generate an
    # attachment column, and the name of this column, and based on this
    # generates a URL to a {Refile::App}.
    #
    # Optionally the name of a processor and arguments to it can be appended.
    #
    # If the filename option is not given, the filename is taken from the
    # metadata stored in the attachment, or eventually falls back to the
    # `name`.
    #
    # The host defaults to {Refile.cdn_host}, which is useful for serving all
    # attachments from a CDN. You can also override the host via the `host`
    # option.
    #
    # Returns `nil` if there is no file attached.
    #
    # @example
    #   Refile.attachment_url(@post, :document)
    #
    # @example With processor
    #   Refile.attachment_url(@post, :image, :fill, 300, 300, format: "jpg")
    #
    # @param [Refile::Attachment] object   Instance of a class which has an attached file
    # @param [Symbol] name                 The name of the attachment column
    # @param [String, nil] filename        The filename to be appended to the URL
    # @param [String, nil] format          A file extension to be appended to the URL
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @param [String, nil] expires_at      Adds a sulfix to the URL that sets the expiration time of the URL
    # @param [String, nil] force_download  Adds a sulfix to the URL to force the download of the file when URL is accessed
    # @return [String, nil]                The generated URL
    def attachment_url(object, name, *args, expires_at: nil, host: nil, prefix: nil, filename: nil, format: nil, force_download: nil)
      attacher = object.send(:"#{name}_attacher")
      file = attacher.get
      return unless file

      filename ||= attacher.basename || name.to_s
      format ||= attacher.extension

      file_url(file, *args, expires_at: expires_at, host: host, prefix: prefix, filename: filename, format: format, force_download: force_download)
    end

    # Receives an instance of a class which has used the
    # {Refile::Attachment#attachment} macro to generate an attachment column,
    # and the name of this column, and based on this generates a URL to a
    # {Refile::App} where files can be uploaded.
    #
    # @example
    #   Refile.attachment_upload_url(@post, :document)
    #
    # @param [Refile::Attachment] object   Instance of a class which has an attached file
    # @param [Symbol] name                 The name of the attachment column
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @return [String]                     The generated URL
    def attachment_upload_url(object, name, host: nil, prefix: nil)
      backend = object.send(:"#{name}_attachment_definition").cache

      upload_url(backend, host: host, prefix: prefix)
    end

    # Receives an instance of a class which has used the
    # {Refile::Attachment#attachment} macro to generate an attachment column,
    # and the name of this column, and based on this generates a URL to a
    # {Refile::App} where a presign object for the backend can be retrieved.
    #
    # @example
    #   Refile.attachment_presign_url(@post, :document)
    #
    # @param [Refile::Attachment] object   Instance of a class which has an attached file
    # @param [Symbol] name                 The name of the attachment column
    # @param [String, nil] host            Override the host
    # @param [String, nil] prefix          Adds a prefix to the URL if the application is not mounted at root
    # @return [String]                     The generated URL
    def attachment_presign_url(object, name, host: nil, prefix: nil)
      backend = object.send(:"#{name}_attachment_definition").cache

      presign_url(backend, host: host, prefix: prefix)
    end

    # Generate a signature for a given path concatenated with the configured secret token.
    #
    # Raises an error if no secret token is configured.
    #
    # @example
    #   Refile.token('/store/f5f2e4/document.pdf')
    #
    # @param [String] path          The path to generate a token for
    # @raise [RuntimeError]         If {Refile.secret_key} is not set
    # @return [String, nil]         The generated token
    def token(path)
      if secret_key.nil?
        error = "Refile.secret_key was not set.\n\n"
        error << "Please add the following to your Refile configuration and restart your application:\n\n"
        error << "```\nRefile.secret_key = '#{SecureRandom.hex(64)}'\n```\n\n"

        raise error
      end

      OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), secret_key, path)
    end

    # Check if the given token is a valid token for the given path.
    #
    # @example
    #   Refile.valid_token?('/store/f5f2e4/document.pdf', 'abcd1234')
    #
    # @param [String] path          The path to check validity for
    # @param [String] token         The token to check
    # @raise [RuntimeError]         If {Refile.secret_key} is not set
    # @return [Boolean]             Whether the token is valid
    def valid_token?(path, token)
      expected = Digest::SHA1.hexdigest(token(path))
      actual = Digest::SHA1.hexdigest(token)

      expected == actual
    end

    # @api private
    def parse_json(data, *args)
      JSON.parse(data.to_s, *args)
    rescue JSON::ParserError
      nil
    end
  end

  require "refile/version"
  require "refile/signature"
  require "refile/type"
  require "refile/backend_macros"
  require "refile/attachment_definition"
  require "refile/download"
  require "refile/attacher"
  require "refile/attachment"
  require "refile/attachment/multiple_attachments"
  require "refile/random_hasher"
  require "refile/file"
  require "refile/custom_logger"
  require "refile/app"
  require "refile/backend/file_system"
end

Refile.configure do |config|
  config.allow_uploads_to = ["cache"]
  config.allow_downloads_from = :all
  config.allow_origin = "*"
  config.logger = Logger.new(STDOUT) unless ENV["RACK_ENV"] == "test"
  config.mount_point = "/attachments"
  config.automount = true
  config.content_max_age = 60 * 60 * 24 * 365
  config.types[:image] = Refile::Type.new(:image, content_type: %w[image/jpeg image/gif image/png])
end