Merge pull request #5979 from betagouv/refactor-active-storage-concerns

Jobs : refactor de la méthode utilisée pour déclencher les jobs de scan anti-virus et de filigranage (#5979)
This commit is contained in:
Pierre de La Morinerie 2021-03-16 12:16:50 +01:00 committed by GitHub
commit 5ab7ea1d79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 139 additions and 115 deletions

View file

@ -1,9 +1,23 @@
class TitreIdentiteWatermarkJob < ApplicationJob class TitreIdentiteWatermarkJob < ApplicationJob
class FileNotScannedYetError < StandardError
end
# If by the time the job runs the blob has been deleted, ignore the error
discard_on ActiveRecord::RecordNotFound
# If the file is deleted during the scan, ignore the error
discard_on ActiveStorage::FileNotFoundError
# If the file is not analyzed or scanned for viruses yet, retry later
# (to avoid modifying the file while it is being scanned).
retry_on FileNotScannedYetError, wait: :exponentially_longer, attempts: 10
MAX_IMAGE_SIZE = 1500 MAX_IMAGE_SIZE = 1500
SCALE = 0.9 SCALE = 0.9
WATERMARK = Rails.root.join("app/assets/images/#{WATERMARK_FILE}") WATERMARK = Rails.root.join("app/assets/images/#{WATERMARK_FILE}")
def perform(blob) def perform(blob)
if blob.virus_scanner.pending? then raise FileNotScannedYetError end
if blob.watermark_done? then return end
blob.open do |file| blob.open do |file|
watermark = resize_watermark(file) watermark = resize_watermark(file)

View file

@ -1,15 +1,22 @@
class VirusScannerJob < ApplicationJob class VirusScannerJob < ApplicationJob
class FileNotAnalyzedYetError < StandardError
end
queue_as :active_storage_analysis queue_as :active_storage_analysis
# If by the time the job runs the blob has been deleted, ignore the error # If by the time the job runs the blob has been deleted, ignore the error
discard_on ActiveRecord::RecordNotFound discard_on ActiveRecord::RecordNotFound
# If the file is deleted during the scan, ignore the error # If the file is deleted during the scan, ignore the error
discard_on ActiveStorage::FileNotFoundError discard_on ActiveStorage::FileNotFoundError
# If the file is not analyzed yet, retry later (to avoid clobbering metadata)
retry_on FileNotAnalyzedYetError, wait: :exponentially_longer, attempts: 10
# If for some reason the file appears invalid, retry for a while # If for some reason the file appears invalid, retry for a while
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: 5.seconds retry_on ActiveStorage::IntegrityError, attempts: 10, wait: 5.seconds
def perform(blob) def perform(blob)
if !blob.analyzed? then raise FileNotAnalyzedYetError end
if blob.virus_scanner.done? then return end
metadata = extract_metadata_via_virus_scanner(blob) metadata = extract_metadata_via_virus_scanner(blob)
blob.update!(metadata: blob.metadata.merge(metadata)) blob.update!(metadata: blob.metadata.merge(metadata))
end end

View file

@ -0,0 +1,17 @@
# Request a watermark on files attached to a `Champs::TitreIdentiteChamp`.
#
# We're using a class extension here, but we could as well have a periodic
# job that watermarks relevant attachments.
module AttachmentTitreIdentiteWatermarkConcern
extend ActiveSupport::Concern
included do
after_create_commit :watermark_later
end
private
def watermark_later
blob&.watermark_later
end
end

View file

@ -0,0 +1,20 @@
# Run a virus scan on all attachments after they are analyzed.
#
# We're using a class extension to ensure that all attachments get scanned,
# regardless on how they were created. This could be an ActiveStorage::Analyzer,
# but as of Rails 6.1 only the first matching analyzer is ever run on
# a blob (and we may want to analyze the dimension of a picture as well
# as scanning it).
module AttachmentVirusScannerConcern
extend ActiveSupport::Concern
included do
after_create_commit :scan_for_virus_later
end
private
def scan_for_virus_later
blob&.scan_for_virus_later
end
end

View file

@ -1,38 +1,21 @@
# Request a watermark on blobs attached to a `Champs::TitreIdentiteChamp`
# after the virus scan has run.
#
# We're using a class extension here, but we could as well have a periodic
# job that watermarks relevant attachments.
#
# The `after_commit` hook is triggered, among other cases, when
# the analyzer or virus scan updates the blob metadata. When both the analyzer
# and the virus scan have run, it is now safe to start the watermarking,
# without risking to replace the picture while it is being scanned in a
# concurrent job.
module BlobTitreIdentiteWatermarkConcern module BlobTitreIdentiteWatermarkConcern
extend ActiveSupport::Concern
included do
after_commit :enqueue_watermark_job
end
def watermark_pending? def watermark_pending?
watermark_required? && !watermark_done? watermark_required? && !watermark_done?
end end
def watermark_done?
metadata[:watermark]
end
def watermark_later
if watermark_required?
TitreIdentiteWatermarkJob.perform_later(self)
end
end
private private
def watermark_required? def watermark_required?
attachments.any? { |attachment| attachment.record.class.name == 'Champs::TitreIdentiteChamp' } attachments.any? { |attachment| attachment.record.class.name == 'Champs::TitreIdentiteChamp' }
end end
def watermark_done?
metadata[:watermark]
end
def enqueue_watermark_job
if analyzed? && virus_scanner.done? && watermark_pending?
TitreIdentiteWatermarkJob.perform_later(self)
end
end
end end

View file

@ -1,36 +1,21 @@
# Run a virus scan on all blobs after they are analyzed.
#
# We're using a class extension to ensure that all blobs get scanned,
# regardless on how they were created. This could be an ActiveStorage::Analyzer,
# but as of Rails 6.1 only the first matching analyzer is ever run on
# a blob (and we may want to analyze the dimension of a picture as well
# as scanning it).
#
# The `after_commit` hook is triggered, among other cases, when
# the analyzer updates the blob metadata. When the analyzer has run,
# it is now safe to start our own scanning, without risking to have
# two concurrent jobs overwriting the metadata of the blob.
module BlobVirusScannerConcern module BlobVirusScannerConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_create :set_pending before_create :set_pending
after_commit :enqueue_virus_scan
end end
def virus_scanner def virus_scanner
ActiveStorage::VirusScanner.new(self) ActiveStorage::VirusScanner.new(self)
end end
def scan_for_virus_later
VirusScannerJob.perform_later(self)
end
private private
def set_pending def set_pending
self.metadata[:virus_scan_result] ||= ActiveStorage::VirusScanner::PENDING metadata[:virus_scan_result] ||= ActiveStorage::VirusScanner::PENDING
end
def enqueue_virus_scan
if analyzed? && !virus_scanner.done?
VirusScannerJob.perform_later(self)
end
end end
end end

View file

@ -4,9 +4,14 @@ Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer
Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
ActiveSupport.on_load(:active_storage_blob) do ActiveSupport.on_load(:active_storage_blob) do
include BlobSignedIdConcern
include BlobVirusScannerConcern
include BlobTitreIdentiteWatermarkConcern include BlobTitreIdentiteWatermarkConcern
include BlobVirusScannerConcern
include BlobSignedIdConcern
end
ActiveSupport.on_load(:active_storage_attachment) do
include AttachmentTitreIdentiteWatermarkConcern
include AttachmentVirusScannerConcern
end end
# When an OpenStack service is initialized it makes a request to fetch # When an OpenStack service is initialized it makes a request to fetch

View file

@ -98,14 +98,10 @@ describe Instructeurs::AvisController, type: :controller do
end end
context 'with attachment' do context 'with attachment' do
include ActiveJob::TestHelper
let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') }
before do before do
expect(ClamavService).to receive(:safe_file?).and_return(true) post :update, params: { id: avis_without_answer.id, procedure_id: procedure.id, avis: { answer: 'answer', piece_justificative_file: file } }
perform_enqueued_jobs do
post :update, params: { id: avis_without_answer.id, procedure_id: procedure.id, avis: { answer: 'answer', piece_justificative_file: file } }
end
avis_without_answer.reload avis_without_answer.reload
end end
@ -126,7 +122,6 @@ describe Instructeurs::AvisController, type: :controller do
subject { post :create_commentaire, params: { id: avis_without_answer.id, procedure_id: procedure.id, commentaire: { body: 'commentaire body', piece_jointe: file } } } subject { post :create_commentaire, params: { id: avis_without_answer.id, procedure_id: procedure.id, commentaire: { body: 'commentaire body', piece_jointe: file } } }
before do before do
allow(ClamavService).to receive(:safe_file?).and_return(scan_result)
Timecop.freeze(now) Timecop.freeze(now)
end end

View file

@ -25,9 +25,8 @@ feature 'Inviting an expert:' do
check 'avis_invite_linked_dossiers' check 'avis_invite_linked_dossiers'
page.select 'confidentiel', from: 'avis_confidentiel' page.select 'confidentiel', from: 'avis_confidentiel'
perform_enqueued_jobs do click_on 'Demander un avis'
click_on 'Demander un avis' perform_enqueued_jobs
end
expect(page).to have_content('Une demande d\'avis a été envoyée') expect(page).to have_content('Une demande d\'avis a été envoyée')
expect(page).to have_content('Avis des invités') expect(page).to have_content('Avis des invités')
@ -38,7 +37,8 @@ feature 'Inviting an expert:' do
end end
expect(Avis.count).to eq(4) expect(Avis.count).to eq(4)
expect(all_emails.size).to eq(2) expect(emails_sent_to('expert1@exemple.fr').size).to eq(1)
expect(emails_sent_to('expert2@exemple.fr').size).to eq(1)
invitation_email = open_email('expert2@exemple.fr') invitation_email = open_email('expert2@exemple.fr')
avis = Avis.find_by(email: 'expert2@exemple.fr', dossier: dossier) avis = Avis.find_by(email: 'expert2@exemple.fr', dossier: dossier)

View file

@ -1,48 +1,49 @@
RSpec.describe VirusScannerJob, type: :job do describe VirusScannerJob, type: :job do
include ActiveJob::TestHelper let(:blob) do
ActiveStorage::Blob.create_and_upload!(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
let(:champ) do
champ = create(:champ_piece_justificative)
champ.piece_justificative_file.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
champ.save
champ
end end
subject do subject do
perform_enqueued_jobs do VirusScannerJob.perform_now(blob)
VirusScannerJob.perform_later(champ.piece_justificative_file.blob) end
context "when the blob is not analyzed yet" do
it "retries the job later" do
expect { subject }.to have_enqueued_job(VirusScannerJob)
end end
end end
context "when no virus is found" do context "when the blob has been analyzed" do
let(:virus_found?) { true }
before do before do
allow(ClamavService).to receive(:safe_file?).and_return(virus_found?) blob.analyze
subject
end end
it { expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy } context "when no virus is found" do
end before do
allow(ClamavService).to receive(:safe_file?).and_return(true)
subject
end
context "when a virus is found" do it { expect(blob.virus_scanner.safe?).to be_truthy }
let(:virus_found?) { false }
before do
allow(ClamavService).to receive(:safe_file?).and_return(virus_found?)
subject
end end
it { expect(champ.reload.piece_justificative_file.virus_scanner.infected?).to be_truthy } context "when a virus is found" do
end before do
allow(ClamavService).to receive(:safe_file?).and_return(false)
subject
end
context "when the blob has been deleted" do it { expect(blob.virus_scanner.infected?).to be_truthy }
before do
Champ.find(champ.id).piece_justificative_file.purge
end end
it "ignores the error" do context "when the blob has been deleted" do
expect { subject }.not_to raise_error before do
ActiveStorage::Blob.find(blob.id).purge
end
it "ignores the error" do
expect { subject }.not_to raise_error
end
end end
end end
end end

View file

@ -458,7 +458,8 @@ describe Champ do
end end
it 'marks the file as safe once the scan completes' do it 'marks the file as safe once the scan completes' do
perform_enqueued_jobs { subject } subject
perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy expect(champ.reload.piece_justificative_file.virus_scanner.safe?).to be_truthy
end end
end end
@ -480,13 +481,15 @@ describe Champ do
champ champ
end end
it 'enqueues a watermark job on file attachment' do it 'marks the file as needing watermarking' do
expect(subject.piece_justificative_file.watermark_pending?).to be_truthy expect(subject.piece_justificative_file.watermark_pending?).to be_truthy
end end
it 'watermarks the file' do it 'watermarks the file' do
perform_enqueued_jobs { subject } subject
expect(champ.reload.piece_justificative_file.blob.metadata[:watermark]).to be_truthy perform_enqueued_jobs
expect(champ.reload.piece_justificative_file.watermark_pending?).to be_falsy
expect(champ.reload.piece_justificative_file.blob.watermark_done?).to be_truthy
end end
end end
end end

View file

@ -1386,27 +1386,21 @@ describe Dossier do
it "clean up titres identite on accepter" do it "clean up titres identite on accepter" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.accepter!(dossier.followers_instructeurs.first, "yolo!")
dossier.accepter!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
it "clean up titres identite on refuser" do it "clean up titres identite on refuser" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.refuser!(dossier.followers_instructeurs.first, "yolo!")
dossier.refuser!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
it "clean up titres identite on classer_sans_suite" do it "clean up titres identite on classer_sans_suite" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.classer_sans_suite!(dossier.followers_instructeurs.first, "yolo!")
dossier.classer_sans_suite!(dossier.followers_instructeurs.first, "yolo!")
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
@ -1416,9 +1410,7 @@ describe Dossier do
it "clean up titres identite on accepter_automatiquement" do it "clean up titres identite on accepter_automatiquement" do
expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy expect(champ_titre_identite.piece_justificative_file.attached?).to be_truthy
expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite_vide.piece_justificative_file.attached?).to be_falsey
perform_enqueued_jobs do dossier.accepter_automatiquement!
dossier.accepter_automatiquement!
end
expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey expect(champ_titre_identite.piece_justificative_file.attached?).to be_falsey
end end
end end

View file

@ -29,15 +29,8 @@ describe CommentaireService do
context 'when it has a file' do context 'when it has a file' do
let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') } let(:file) { fixture_file_upload('spec/fixtures/files/piece_justificative_0.pdf', 'application/pdf') }
before do it 'attaches the file' do
expect(ClamavService).to receive(:safe_file?).and_return(true) expect(commentaire.piece_jointe.attached?).to be_truthy
end
it 'saves the attached file' do
perform_enqueued_jobs do
commentaire.save
expect(commentaire.piece_jointe.attached?).to be_truthy
end
end end
end end
end end

View file

@ -0,0 +1,9 @@
RSpec.configure do |config|
config.include ActiveJob::TestHelper
config.before(:each) do
clear_enqueued_jobs
end
end
ActiveJob::Base.queue_adapter = :test