The constraints of Active Storage mean that you definitely will need to duplicate the blob. Within Active Storage, the blob key is indexed uniquely, so it cannot be referenced twice by different filenames.
If your storage service allows you to duplicate the blob efficiently in situ, then the enabling method on the Rails side is create_before_direct_upload!. Despite the somewhat misleading name, this method is specifically documented to provide a new (but unattached) blob record, in expectation that the content itself will be uploaded outside of the application. The blob record is then attached in a subsequent operation.
That is how Direct Upload works, and we can hook into the same two-phase mechanism; in this special case, the "upload" will instead be a service-specific duplication.
The means to duplicate in-situ vary according to the storage service, but for example:
An illustrative chunk of demonstration code therefore might be:
def blob_clone(current_blob, **options)
blob_parameters = {
filename: current_blob.filename,
byte_size: current_blob.byte_size,
checksum: current_blob.checksum,
content_type: current_blob.content_type,
metadata: current_blob.metadata,
**options
}
service = current_blob.service
new_blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_parameters)
case service.class.name
when 'ActiveStorage::Service::S3Service'
bucket = service.bucket
current_object = bucket.object(current_blob.key)
current_object.copy_to(bucket.object(new_blob.key))
when 'ActiveStorage::Service::DiskService'
current_path = service.path_for(current_blob.key)
new_path = service.path_for(new_blob.key)
FileUtils.mkdir_p(File.dirname(new_path))
FileUtils.ln(current_path, new_path)
else
raise ArgumentError, "unknown blob storage service #{service.class.name}"
end
new_blob
end
to be used thus:
current_blob = mymodel.contract.blob
new_blob = blob_clone(current_blob, filename: 'newfilename.pdf')
new_model.contract.attach(new_blob)
although I recommend refactoring this example according to your application structure and preferences.
Note on the code
In the illustration above, the case service.class.name is rather unlovely, and is only written so to keep the demo code self-contained. The problem being that Rails doesn't even load the class constant for an unused backend storage service. If you are not using diverse storage services across environments, then the case-statement may be unnecessary, and if this were fully realised as a gem, or refactored into an initializer, I'd probably finesse it to be monkey-patching/refining the service classes themselves.