Slide 1

Slide 1 text

Validating and processing the content of a file with Active Storage Claudio Baccigalupo

Slide 2

Slide 2 text

Summary 1. Uploading and downloading with Active Storage has_one_attached, rails_blob_url 2. Extracting metadata and processing attachments download_blob_to_tempfile, prepending callbacks 3. Validating the content of an attachment attachment_changes, attached?

Slide 3

Slide 3 text

Part 1: uploading and downloading files with Active Storage (5 minutes)

Slide 4

Slide 4 text

github.com/clutter/mdma Uploading files Downloading files

Slide 5

Slide 5 text

Basics of Active Storage :package :version Started POST "/builds" Parameters: {"build"=>{"package"=>#, "version"=>"42"} :version :package

Slide 6

Slide 6 text

Uploading an attachment # app/views/builds/new.html.erb <%= form_with(model: @build) do |form| %> 1. Choose a file to upload: <%= form.file_field :package %> # app/controllers/builds_controller.rb def build_params params.require(:build).permit :package, :version end # app/models/build.rb class Build < ActiveRecord::Base has_one_attached :package end

Slide 7

Slide 7 text

Downloading an attachment # config/environments/production.rb Rails.application.configure do config.active_storage.service = :s3 end # app/views/builds/index.html.erb <% @builds.each do |build| %> <%= build.version %> <%= link_to 'Download', rails_blob_url(build.package, disposition: 'attachment') %> <% end %>

Slide 8

Slide 8 text

Basic attachment validations # app/models/build.rb class Build < ActiveRecord::Base has_one_attached :package validates :package, presence: true end

Slide 9

Slide 9 text

Proposed attachment validations # app/models/build.rb class Build < ActiveRecord::Base has_one_attached :package validates :package, presence: true, attachment_size: { maximum: 90.megabytes }, attachment_content_type: "application/zip" end

Slide 10

Slide 10 text

Part 2: extracting metadata and processing an attachment (10 minutes)

Slide 11

Slide 11 text

From downloading to installing https://localhost:3000/rails/active_storage/blobs/ [sha]/demo_v42.ipa?disposition=attachment Simply downloading the package file to an iOS device is not enough to install the app

Slide 12

Slide 12 text

From downloading to installing itms-services://?action=download- manifest&url=https%3A%2F%2Flocalhost%3A300 0%2Frails%2Factive_storage%2Fblobs%2F[sha] %2Fmanifest.plist%3Fdisposition%3Dattachment 1. Extract URL and metadata from the package (app name, version, minimum OS version, …) 2. Create and upload a "manifest.plist" file with all these properties in XML format 3. Share a link to the manifest, not the package

Slide 13

Slide 13 text

Taking inspiration from ImageAnalyzer # app/models/active_storage/attachment.rb class Attachment < ActiveRecord::Base after_create_commit { AnalyzeJob.perform_later(blob) } end # app/jobs/active_storage/analyze_job.rb class AnalyzeJob < BaseJob def perform(blob) # Pick an analyzer based on the blob content-type analyzer = Analyzer::ImageAnalyzer.new(blob) blob.update! metadata: analyzer.metadata end end # lib/active_storage/analyzer/image_analyzer.rb class Analyzer::ImageAnalyzer < Analyzer def metadata download_blob_to_tempfile do |file| image = MiniMagick::Image.new(file.path) { width: image.width, height: image.height } end end end 1. Whenever a model with an attachment is created, Rails enqueues a background job 2. The job looks for the right analyzer for the blob. Metadata returned by analyzer is stored in the DB 3. Analyzers run in a background job, so they must download the file to memory first. The file analysis is typically delegated to external libraries (MiniMagick, FFMpeg, …)

Slide 14

Slide 14 text

Working with a custom analyzer # app/models/build.rb class Build < ActiveRecord::Base after_create_commit(prepend: true) do PackageAnalyzeJob.perform_later(self) end end # app/jobs/package_analyze_job.rb class PackageAnalyzeJob < ActiveStorage::BaseJob def perform(build) analyzer = PackageAnalyzer.new(build.package.blob) build.package.blob.update! metadata: analyzer.metadata end end # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties end end end 2. The package analyzer is invoked and the returned metadata are stored in the database 3. The file is downloaded to memory and the analysis is run by the CFPropertyBundle library 1. Whenever a build with a package is created, enqueue a background job

Slide 15

Slide 15 text

Working with a custom analyzer # app/models/build.rb class Build < ActiveRecord::Base after_create_commit(prepend: true) do PackageAnalyzeJob.perform_later(self) end end # app/jobs/package_analyze_job.rb class PackageAnalyzeJob < ActiveStorage::BaseJob def perform(build) analyzer = PackageAnalyzer.new(build.package.blob) build.package.blob.update! metadata: analyzer.metadata end end # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties end end end 2. The package analyzer is invoked and the returned metadata are stored in the database 3. The file is downloaded to memory and the analysis is run by the CFPropertyBundle library 1. Whenever a build with a package is created, enqueue a background job what?

Slide 16

Slide 16 text

Extracting metadata from a package # lib/cfpropertybundle.rb class CFPropertyBundle class Error < StandardError; end def initialize(path) @path = path end def properties Zip::File.open(@path) do |package| data = extract_plist_data_from package { display_name: data['CFBundleDisplayName'], identifier: data['CFBundleIdentifier'], version: data['CFBundleVersion'], minimum_os_version: data['MinimumOSVersion'], short_version: data['CFBundleShortVersionString'] } end rescue Zip::Error, SystemCallError => e raise Error, e.message end end The CFPropertyBundle library unzips the package, parses the information stored in XML format and returns a Hash with the properties needed to generate a manifest. If the package is not a Zip file or the content doesn’t match the .ipa format, a custom CFProperty::Error is raised.

Slide 17

Slide 17 text

Working with a custom analyzer # app/models/build.rb class Build < ActiveRecord::Base after_create_commit(prepend: true) do PackageAnalyzeJob.perform_later(self) end end # app/jobs/package_analyze_job.rb class PackageAnalyzeJob < ActiveStorage::BaseJob def perform(build) analyzer = PackageAnalyzer.new(build.package.blob) build.package.blob.update! metadata: analyzer.metadata end end # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties end end end 2. The package analyzer is invoked and the returned metadata are stored in the database 3. The file is downloaded to memory and the analysis is run by the CFPropertyBundle library 1. Whenever a build with a package is created, enqueue a background job why?

Slide 18

Slide 18 text

Working with a custom analyzer # app/models/build.rb class Build < ActiveRecord::Base after_create_commit(prepend: true) do PackageAnalyzeJob.perform_later(self) end end # app/jobs/package_analyze_job.rb class PackageAnalyzeJob < ActiveStorage::BaseJob def perform(build) analyzer = PackageAnalyzer.new(build.package.blob) build.package.blob.update! metadata: analyzer.metadata end end # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties end end end 2. The package analyzer is invoked and the returned metadata are stored in the database 3. The file is downloaded to memory and the analysis is run by the CFPropertyBundle library 1. Whenever a build with a package is created, enqueue a background job the manifest can be generated here

Slide 19

Slide 19 text

Processing an attachment # app/jobs/package_analyze_job.rb class PackageAnalyzeJob < ActiveStorage::BaseJob def perform(build) metadata = PackageAnalyzer.new(build.package.blob).metadata build.package.blob.update! metadata: metadata Tempfile.open ['manifest', '.plist'] do |file| file.write <<~MANIFEST […] url#{CGI.escapeHTML build.package.service_url} title#{metadata[:bundle_display_name]} bundle-version#{metadata[:short_version]}[…] MANIFEST build.manifest.attach io: File.open(file.path), filename: 'manifest.plist' end end end # app/models/build.rb class Build < ActiveRecord::Base has_one_attached :package has_one_attached :manifest end Attaching the manifest to the build grants access to all the convenient Active Storage methods (attach, rails_blob_url, …)

Slide 20

Slide 20 text

From downloading to installing itms-services://?action=download- manifest&url=https%3A%2F%2Flocalhost%3A300 0%2Frails%2Factive_storage%2Fblobs%2F[sha] %2Fmanifest.plist%3Fdisposition%3Dattachment manifest_url = rails_blob_url( build.manifest, disposition: 'attachment') <%= link_to 'Install', "itms-services:// ?action=download-manifest &url=#{CGI.escape manifest_url)}" %>

Slide 21

Slide 21 text

Part 3: validating the content of an attachment (5 minutes)

Slide 22

Slide 22 text

Validating the content of an attachment

Slide 23

Slide 23 text

Validating the content of an attachment # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties # extracts properties from the iOS bundle { display_name: "iOS app", identifier: "com.example.app", version: "42", minimum_os_version: "8.0", short_version: "1.0" } end end end

Slide 24

Slide 24 text

Validating the content of an attachment

Slide 25

Slide 25 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base validates :version, presence: true, uniqueness: true end

Slide 26

Slide 26 text

Validating the content of an attachment # app/analyzers/package_analyzer.rb class PackageAnalyzer < ActiveStorage::Analyzer def metadata download_blob_to_tempfile do |file| CFPropertyBundle.new(file.path).properties # extracts properties from the iOS bundle { display_name: "iOS app", identifier: "com.example.app", version: "42", minimum_os_version: "8.0", short_version: "1.0" } end end end

Slide 27

Slide 27 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end

Slide 28

Slide 28 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end

Slide 29

Slide 29 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end

Slide 30

Slide 30 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end Started POST "/builds" Parameters: {"build"=>{"package"=>#}

Slide 31

Slide 31 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end

Slide 32

Slide 32 text

Validating the content of an attachment # app/models/build.rb class Build < ActiveRecord::Base with_options if: -> { package.attached? } do before_validation :set_version, on: :create validates :version, presence: true, uniqueness: true end def set_version file = attachment_changes['package'].attachable metadata = CFPropertyBundle.new(file.path).properties self.version = metadata[:version] rescue CFPropertyBundle::Error message = 'cannot be extracted from the attachment' errors.add(:version, :invalid, message: message) end end

Slide 33

Slide 33 text

Summary 1. Uploading and downloading with Active Storage has_one_attached, rails_blob_url 2. Extracting metadata and processing attachments download_blob_to_tempfile, prepending callbacks 3. Validating the content of an attachment attachment_changes, attached?

Slide 34

Slide 34 text

Thanks! Questions?