Recently, in a Solidus project, we faced the need to add Files to Products for customers to be able to download.
In general terms, the business logic was:
- A Product can have many Files
- Customers can download Files from purchased Products
- File links cannot be shared
- Files must be administrables
Given the project was using Amazon S3 to store the files and we didn't wanted to modify Solidus a lot, our approach was:
We created a File model inheriting from Spree::Asset adding a method to obtain a presigned_url which expires in 30 seconds, allowing the browser to init download but not to share.
app/models/my_app_scope/file.rb
module MyAppScope
class File < ::Spree::Asset
has_attached_file :attachment,
url: '/spree/products/:id/:basename.:extension',
path: ':rails_root/public/spree/products/:id/:basename.:extension'
do_not_validate_attachment_file_type :attachment
def download_url
s3_object.presigned_url('get', expires_in: 30)
end
end
end
In this example the paths or validations weren't modified, but you can easily define them as needed.
We decorate Product adding the has_many
association to Files; in our case we didn't have the need to add more information to the File, like flags or metadata, but if that would be the case, it would be better to create a has_many_through
association containing the information preventing modification of Spree::Asset model.
app/models/decorators/solidus/product.rb
module Decorators
module Product
extend ActiveSupport::Concern
included do
has_many :files, as: :viewable,
dependent: :destroy,
class_name: 'MyAppScope::File'
end
end
end
Spree::Product.include(Decorators::Product)
Given we need to restrict Files access for a User, we added a couple of scopes to it. One to Products through his Orders and another to Files through the Products.
app/models/decorators/solidus/user.rb
# frozen_string_literal: true
module Decorators
module Solidus
module User
extend ActiveSupport::Concern
included do
has_many :products,
-> { unscope(:order).distinct },
through: :orders
has_many :files,
-> { distinct },
through: :products
end
end
end
end
Spree::User.include(Decorators::Solidus::User)
To restrict Files access, we created a PermissionSet
where we look for the File ID to belongs to User file_ids.
lib/spree/permission_sets/file_ability.rb
# frozen_string_literal: true
module Spree
module PermissionSets
class FileAbility < PermissionSets::Base
def activate!
can :download,
MyAppScope::File,
id: files_ids
end
private
def files_ids
@files_ids ||= user.file_ids
end
end
end
end
Then, we used a controller to redirecto to File presigned_url, delegating the action to the browser; e.g. Zip files will be downloaded, while PDF or Images will be opened.
app/controllers/my_app_scope/files_controller.rb
module MyAppScope
class FilesController < Spree::StoreController
def download
authorize!(:download, file)
redirect_to file.download_url
end
private
def file
@file ||= MyAppScope::File.find(params[:id])
end
end
end
and the route for this action:
config/routes.rb
Rails.application.routes.draw do
scope module: 'my_app_scope' do
get 'download/:id', to: 'files#download', as: :download
end
end
In our case, we modified Account to have different sections using ViewComponents, but the very basic is:
<%- product.files.each do |file| %>
<li><%= link_to file.attachment_file_name, download_path(file), target: '_blank' %></li>
<% end %>
The last thing would be to generate overrides, views, routes, and controller for the Admin, but I'll not cover that in this post; you can see example code in this Gist: Code: Using Spree::Asset to add Files to Spree::Product.