lib/active_storage/service/open_stack_service.rb
# frozen_string_literal: true
require 'fog/openstack'
module ActiveStorage
class Service
# ActiveStorage provider for OpenStack
class OpenStackService < Service
attr_reader :settings, :container
def initialize(container:, credentials:, public: false, connection_options: {})
super()
@settings = credentials.reverse_merge(connection_options: connection_options)
@public = public
@container = Fog::OpenStack.escape(container)
end
def upload(key, io, checksum: nil, disposition: nil, content_type: nil, filename: nil, **)
instrument :upload, key: key, checksum: checksum do
params = { 'Content-Type' => content_type || guess_content_type(io) }
params['ETag'] = convert_base64digest_to_hexdigest(checksum) if checksum
if disposition && filename
params['Content-Disposition'] =
content_disposition_with(type: disposition, filename: filename)
end
begin
client.put_object(container, key, io, params)
rescue Excon::Error::UnprocessableEntity
raise ActiveStorage::IntegrityError
end
end
end
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
object_for(key, &block)
end
else
instrument :download, key: key do
object_for(key).body
end
end
rescue Fog::OpenStack::Storage::NotFound
raise ActiveStorage::FileNotFoundError if defined?(ActiveStorage::FileNotFoundError)
end
def download_chunk(key, range)
instrument :download_chunk, key: key, range: range do
chunk_buffer = []
object_for(key) do |chunk|
chunk_buffer << chunk
end
chunk_buffer.join[range]
rescue Fog::OpenStack::Storage::NotFound
raise ActiveStorage::FileNotFoundError if defined?(ActiveStorage::FileNotFoundError)
end
end
def delete(key)
instrument :delete, key: key do
client.delete_object(container, key)
rescue Fog::OpenStack::Storage::NotFound
false
end
end
def delete_prefixed(prefix)
instrument :delete, prefix: prefix do
directory = client.directories.get(container)
filtered_files = client.files(directory: directory, prefix: prefix)
filtered_files = filtered_files.map(&:key)
client.delete_multiple_objects(container, filtered_files)
end
end
def exist?(key)
instrument :exist, key: key do |payload|
answer = head_for(key)
payload[:exist] = answer.present?
rescue Fog::OpenStack::Storage::NotFound
payload[:exist] = false
end
end
def url(key, **options)
if ActiveStorage.version < Gem::Version.new('6.1-alpha')
instrument :url, key: key do |payload|
private_url(key, **options).tap do |generated_url|
payload[:url] = generated_url
end
end
else
super
end
end
def url_for_direct_upload(key, expires_in:, filename: nil, **)
instrument :url, key: key do |payload|
expire_at = unix_timestamp_expires_at(expires_in)
generated_url = client.create_temp_url(
container,
key,
expire_at,
'PUT',
port: 443,
**(filename ? { filename: ActiveStorage::Filename.wrap(filename).to_s } : {}),
scheme: 'https'
)
payload[:url] = generated_url
generated_url
end
end
def headers_for_direct_upload(_key, content_type:, checksum:, **)
{
'Content-Type' => content_type,
'ETag' => convert_base64digest_to_hexdigest(checksum)
}
end
def update_metadata(key, content_type:, disposition: nil, filename: nil, **)
instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
params = { 'Content-Type' => content_type }
if disposition && filename
params['Content-Disposition'] =
content_disposition_with(
type: disposition,
filename: ActiveStorage::Filename.wrap(filename)
)
end
client.post_object(container, key, params)
true
rescue Fog::OpenStack::Storage::NotFound
raise ActiveStorage::FileNotFoundError if defined?(ActiveStorage::FileNotFoundError)
end
end
private
def client
@client ||= Fog::OpenStack::Storage.new(settings)
end
def head_for(key)
client.head_object(container, key)
end
def object_for(key, &block)
client.get_object(container, key, &block)
end
# ActiveStorage sends a `Digest::MD5.base64digest` checksum
# OpenStack expects a `Digest::MD5.hexdigest` ETag
def convert_base64digest_to_hexdigest(base64digest)
base64digest.unpack1('m0').unpack1('H*')
end
def unix_timestamp_expires_at(seconds_from_now)
Time.current.advance(seconds: seconds_from_now).to_i
end
def guess_content_type(io)
Marcel::MimeType.for io,
name: io.try(:original_filename),
declared_type: io.try(:content_type)
end
def private_url(key, expires_in:, filename:, disposition:, **)
expire_at = unix_timestamp_expires_at(expires_in)
generated_url = client.get_object_https_url(container, key, expire_at, filename: filename.sanitized)
generated_url += '&inline' if disposition.to_s != 'attachment'
# unfortunately OpenStack Swift cannot overwrite the content type of an object via a temp url
# so we just ignore the content_type argument here
generated_url
end
def public_url(key, **)
client.public_url(container, key)
end
end
end
end