diff --git a/.scss-lint.yml b/.scss-lint.yml index 835a46690..45ead4f7b 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -1,4 +1,7 @@ -exclude: 'app/assets/stylesheets/new_design/reset.scss' +exclude: + - 'app/assets/stylesheets/new_design/reset.scss' + - 'app/assets/stylesheets/direct_uploads.scss' + - 'app/assets/stylesheets/new_design/direct_uploads.scss' linters: BangFormat: diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 62f9b7d15..133fc652a 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,6 +10,7 @@ // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details // about supported directives. // +//= require activestorage //= require jquery //= require jquery_ujs //= require turbolinks diff --git a/app/assets/javascripts/new_design/application.js b/app/assets/javascripts/new_design/application.js index e5d95ac00..ec093c6e2 100644 --- a/app/assets/javascripts/new_design/application.js +++ b/app/assets/javascripts/new_design/application.js @@ -11,6 +11,7 @@ // about supported directives. // //= require ./init +//= require activestorage //= require jquery //= require jquery_ujs //= require turbolinks diff --git a/app/assets/javascripts/new_design/direct_uploads.js b/app/assets/javascripts/new_design/direct_uploads.js new file mode 100644 index 000000000..1ac788eec --- /dev/null +++ b/app/assets/javascripts/new_design/direct_uploads.js @@ -0,0 +1,45 @@ +addEventListener("direct-upload:initialize", function (event) { + var target = event.target, + detail = event.detail, + id = detail.id, + file = detail.file; + + target.insertAdjacentHTML("beforebegin", "\n
\n
\n" + + file.name + + "\n
\n"); +}); + +addEventListener("direct-upload:start", function (event) { + var id = event.detail.id, + element = document.getElementById("direct-upload-" + id); + + element.classList.remove("direct-upload--pending"); +}); + +addEventListener("direct-upload:progress", function (event) { + var id = event.detail.id, + progress = event.detail.progress, + progressElement = document.getElementById("direct-upload-progress-" + id); + + progressElement.style.width = progress + "%"; +}); + +addEventListener("direct-upload:error", function (event) { + event.preventDefault(); + var id = event.detail.id, + error = event.detail.error, + element = document.getElementById("direct-upload-" + id); + + element.classList.add("direct-upload--error"); + element.setAttribute("title", error); +}); + +addEventListener("direct-upload:end", function (event) { + var id = event.detail.id, + element = document.getElementById("direct-upload-" + id); + + element.classList.add("direct-upload--complete"); +}); \ No newline at end of file diff --git a/app/assets/javascripts/old_design/direct_uploads.js b/app/assets/javascripts/old_design/direct_uploads.js new file mode 100644 index 000000000..47b5aa23c --- /dev/null +++ b/app/assets/javascripts/old_design/direct_uploads.js @@ -0,0 +1,57 @@ +addEventListener("direct-upload:initialize", function (event) { + var target = event.target, + detail = event.detail, + id = detail.id, + file = detail.file; + + target.insertAdjacentHTML("beforebegin", "\n
\n
\n" + + file.name + + "\n
\n"); +}); + +addEventListener("direct-upload:start", function (event) { + var id = event.detail.id, + element = document.getElementById("direct-upload-" + id); + + element.classList.remove("direct-upload--pending"); +}); + +addEventListener("direct-upload:progress", function (event) { + var id = event.detail.id, + progress = event.detail.progress, + progressElement = document.getElementById("direct-upload-progress-" + id); + + progressElement.style.width = progress + "%"; +}); + +addEventListener("direct-upload:error", function (event) { + event.preventDefault(); + var id = event.detail.id, + error = event.detail.error, + element = document.getElementById("direct-upload-" + id); + + element.classList.add("direct-upload--error"); + element.setAttribute("title", error); +}); + +addEventListener("direct-upload:end", function (event) { + var id = event.detail.id, + element = document.getElementById("direct-upload-" + id); + + element.classList.add("direct-upload--complete"); +}); + +addEventListener('load', function() { + var submitButtons = document.querySelectorAll('form button[type=submit][data-action]'); + var hiddenInput = document.querySelector('form input[type=hidden][name=submit_action]'); + submitButtons = [].slice.call(submitButtons); + + submitButtons.forEach(function(button) { + button.addEventListener('click', function() { + hiddenInput.value = button.getAttribute('data-action'); + }); + }); +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e8f02b0cc..f2c861d76 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -21,6 +21,7 @@ // = require custom_mails // = require default_data_block // = require description +// = require direct_uploads // = require dossier_show // = require dossiers // = require etapes diff --git a/app/assets/stylesheets/direct_uploads.scss b/app/assets/stylesheets/direct_uploads.scss new file mode 100644 index 000000000..eda8e75c7 --- /dev/null +++ b/app/assets/stylesheets/direct_uploads.scss @@ -0,0 +1,37 @@ +.direct-upload { + display: inline-block; + position: relative; + padding: 2px 4px; + margin: 0 3px 3px 0; + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 3px; + font-size: 11px; + line-height: 13px; +} + +.direct-upload--pending { + opacity: 0.6; +} + +.direct-upload__progress { + position: absolute; + top: 0; + left: 0; + bottom: 0; + opacity: 0.2; + background: #0076ff; + transition: width 120ms ease-out, opacity 60ms 60ms ease-in; + transform: translate3d(0, 0, 0); +} + +.direct-upload--complete .direct-upload__progress { + opacity: 0.4; +} + +.direct-upload--error { + border-color: red; +} + +input[type=file][data-direct-upload-url][disabled] { + display: none; +} diff --git a/app/assets/stylesheets/new_design/direct_uploads.scss b/app/assets/stylesheets/new_design/direct_uploads.scss new file mode 100644 index 000000000..eda8e75c7 --- /dev/null +++ b/app/assets/stylesheets/new_design/direct_uploads.scss @@ -0,0 +1,37 @@ +.direct-upload { + display: inline-block; + position: relative; + padding: 2px 4px; + margin: 0 3px 3px 0; + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 3px; + font-size: 11px; + line-height: 13px; +} + +.direct-upload--pending { + opacity: 0.6; +} + +.direct-upload__progress { + position: absolute; + top: 0; + left: 0; + bottom: 0; + opacity: 0.2; + background: #0076ff; + transition: width 120ms ease-out, opacity 60ms 60ms ease-in; + transform: translate3d(0, 0, 0); +} + +.direct-upload--complete .direct-upload__progress { + opacity: 0.4; +} + +.direct-upload--error { + border-color: red; +} + +input[type=file][data-direct-upload-url][disabled] { + display: none; +} diff --git a/app/controllers/new_gestionnaire/dossiers_controller.rb b/app/controllers/new_gestionnaire/dossiers_controller.rb index 58eea7a09..d5e9c1106 100644 --- a/app/controllers/new_gestionnaire/dossiers_controller.rb +++ b/app/controllers/new_gestionnaire/dossiers_controller.rb @@ -165,6 +165,7 @@ module NewGestionnaire def update_annotations dossier = current_gestionnaire.dossiers.includes(champs_private: :type_de_champ).find(params[:dossier_id]) + # FIXME: add attachements validation, cf. Champ#piece_justificative_file_errors dossier.update_attributes(champs_private_params) redirect_to annotations_privees_dossier_path(procedure, dossier) end @@ -189,7 +190,7 @@ module NewGestionnaire end def champs_private_params - params.require(:dossier).permit(champs_private_attributes: [:id, :value, value: []]) + params.require(:dossier).permit(champs_private_attributes: [:id, :piece_justificative_file, :value, value: []]) end def check_attestation_emailable diff --git a/app/controllers/users/description_controller.rb b/app/controllers/users/description_controller.rb index e991408d7..ccf4b48fc 100644 --- a/app/controllers/users/description_controller.rb +++ b/app/controllers/users/description_controller.rb @@ -36,7 +36,7 @@ class Users::DescriptionController < UsersController return redirect_to_description_with_errors(dossier, cerfa.errors.full_messages) if !cerfa.save end - errors_upload = PiecesJustificativesService.upload!(dossier, current_user, params) + errors_upload = PiecesJustificativesService.upload!(dossier, current_user, params) + ChampsService.check_piece_justificative_files(dossier.champs) return redirect_to_description_with_errors(dossier, errors_upload) if errors_upload.any? if params[:champs] && !(brouillon_submission? || brouillon_then_dashboard_submission?) @@ -113,11 +113,11 @@ class Users::DescriptionController < UsersController end def brouillon_submission? - params[:submit] && params[:submit]['brouillon'].present? + params[:submit_action] == 'brouillon' end def brouillon_then_dashboard_submission? - params[:submit] && params[:submit]['brouillon_then_dashboard'].present? + params[:submit_action] == 'brouillon_then_dashboard' end def check_autorisation_donnees diff --git a/app/helpers/type_de_champ_helper.rb b/app/helpers/type_de_champ_helper.rb new file mode 100644 index 000000000..bacc16e7a --- /dev/null +++ b/app/helpers/type_de_champ_helper.rb @@ -0,0 +1,11 @@ +module TypeDeChampHelper + def tdc_options(current_administrateur) + tdcs = TypeDeChamp.type_de_champs_list_fr + + if !current_administrateur.id.in?(Features.champ_pj_allowed_for_admin_ids) + tdcs.reject! { |tdc| tdc.last == "piece_justificative" } + end + + tdcs + end +end diff --git a/app/models/champ.rb b/app/models/champ.rb index 4ff77ccd5..a67c4e581 100644 --- a/app/models/champ.rb +++ b/app/models/champ.rb @@ -4,6 +4,7 @@ class Champ < ActiveRecord::Base belongs_to :dossier, touch: true belongs_to :type_de_champ, inverse_of: :champ has_many :commentaires + has_one_attached :piece_justificative_file delegate :libelle, :type_champ, :order_place, :mandatory?, :description, :drop_down_list, to: :type_de_champ @@ -15,6 +16,23 @@ class Champ < ActiveRecord::Base scope :public_only, -> { where(type: 'ChampPublic').or(where(private: false)) } scope :private_only, -> { where(type: 'ChampPrivate').or(where(private: true)) } + PIECE_JUSTIFICATIVE_FILE_MAX_SIZE = 200.megabytes + + PIECE_JUSTIFICATIVE_FILE_ACCEPTED_FORMATS = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", + "image/png", + "image/jpeg" + ] + def public? !private? end @@ -32,7 +50,11 @@ class Champ < ActiveRecord::Base end def mandatory_and_blank? - mandatory? && value.blank? + if type_champ == 'piece_justificative' + mandatory? && !piece_justificative_file.attached? + else + mandatory? && value.blank? + end end def same_date? num, compare @@ -88,6 +110,28 @@ class Champ < ActiveRecord::Base end end + def piece_justificative_file_errors + errors = [] + + if piece_justificative_file.attached? && piece_justificative_file.previous_changes.present? + if piece_justificative_file.blob.byte_size > PIECE_JUSTIFICATIVE_FILE_MAX_SIZE + errors << "Le fichier #{piece_justificative_file.filename.to_s} est trop lourd, il doit faire au plus #{PIECE_JUSTIFICATIVE_FILE_MAX_SIZE.to_s(:human_size, precision: 2)}" + end + + if !piece_justificative_file.blob.content_type.in?(PIECE_JUSTIFICATIVE_FILE_ACCEPTED_FORMATS) + errors << "Le fichier #{piece_justificative_file.filename.to_s} est dans un format que nous n'acceptons pas" + end + + # FIXME: add Clamav check + end + + if errors.present? + piece_justificative_file.purge + end + + errors + end + private def format_date_to_iso diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 937a7e000..20c4639a8 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -21,7 +21,8 @@ class TypeDeChamp < ActiveRecord::Base engagement: 'engagement', header_section: 'header_section', explication: 'explication', - dossier_link: 'dossier_link' + dossier_link: 'dossier_link', + piece_justificative: 'piece_justificative' } belongs_to :procedure diff --git a/app/services/champs_service.rb b/app/services/champs_service.rb index 62a1d7342..0374b1b4a 100644 --- a/app/services/champs_service.rb +++ b/app/services/champs_service.rb @@ -11,12 +11,25 @@ class ChampsService .map { |c| "Le champ #{c.libelle.truncate(200)} doit être rempli." } end + def check_piece_justificative_files(champs) + champs.select do |champ| + champ.type_champ == 'piece_justificative' + end.map { |c| c.piece_justificative_file_errors }.flatten + end + private def fill_champs(champs, h) datetimes, not_datetimes = champs.partition { |c| c.type_champ == 'datetime' } - not_datetimes.each { |c| c.value = h[:champs]["'#{c.id}'"] } + not_datetimes.each do |c| + if c.type_champ == 'piece_justificative' && h["champs"]["'#{c.id}'"].present? + c.piece_justificative_file.attach(h["champs"]["'#{c.id}'"]) + else + c.value = h[:champs]["'#{c.id}'"] + end + end + datetimes.each { |c| c.value = parse_datetime(c.id, h) } end diff --git a/app/views/admin/types_de_champ/_fields.html.haml b/app/views/admin/types_de_champ/_fields.html.haml index bbadc32d8..1d03612e4 100644 --- a/app/views/admin/types_de_champ/_fields.html.haml +++ b/app/views/admin/types_de_champ/_fields.html.haml @@ -8,7 +8,8 @@ .form-group.type %h4 Type - = ff.select :type_champ, TypeDeChamp.type_de_champs_list_fr, {}, { class: 'form-control type-champ' } + - tdc_options = tdc_options(current_administrateur) + = ff.select :type_champ, tdc_options, {}, { class: 'form-control type-champ' } .form-group.description %h4 Description diff --git a/app/views/dossiers/_infos_dossier.html.haml b/app/views/dossiers/_infos_dossier.html.haml index f1c09ddf9..de2ee2545 100644 --- a/app/views/dossiers/_infos_dossier.html.haml +++ b/app/views/dossiers/_infos_dossier.html.haml @@ -37,7 +37,7 @@ .col-xs-1.comments-off = "-" .col-xs-5.depositaire-info{ id: "champ-#{champ.id}-value" } - - if champ.decorate.value.present? + - if champ.decorate.value.present? || champ.piece_justificative_file.attached? - if champ.type_champ == 'dossier_link' - dossier = Dossier.includes(:procedure).find_by(id: champ.decorate.value) - if dossier @@ -46,6 +46,10 @@ = sanitize(dossier.text_summary) - else Pas de dossier associé + - elsif champ.type_champ == 'piece_justificative' + - pj = champ.piece_justificative_file + %a{ href: url_for(pj), target: '_blank' } + = pj.filename.to_s - else = sanitize(champ.decorate.value) diff --git a/app/views/new_gestionnaire/dossiers/_champs.html.haml b/app/views/new_gestionnaire/dossiers/_champs.html.haml index 45609eaa2..9e44d4e7a 100644 --- a/app/views/new_gestionnaire/dossiers/_champs.html.haml +++ b/app/views/new_gestionnaire/dossiers/_champs.html.haml @@ -30,6 +30,13 @@ = sanitize(dossier.text_summary) - else Pas de dossier associé + - when "piece_justificative" + %th.libelle + = "#{c.libelle} :" + %td.rich-text + - pj = c.piece_justificative_file + %a{ href: url_for(pj), target: '_blank' } + = pj.filename.to_s - else %th.libelle = "#{c.libelle} :" diff --git a/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml b/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml new file mode 100644 index 000000000..fe59efd3f --- /dev/null +++ b/app/views/new_gestionnaire/dossiers/editable_champs/_piece_justificative.html.haml @@ -0,0 +1,14 @@ +- pj = champ.piece_justificative_file + +- if !pj.attached? + = form.file_field :piece_justificative_file, + id: "champs_#{champ.id}", + direct_upload: true +- else + %a{ href: url_for(pj), target: '_blank' } + = pj.filename.to_s + %br + Modifier : + = form.file_field :piece_justificative_file, + id: "champs_#{champ.id}", + direct_upload: true diff --git a/app/views/users/description/_show.html.haml b/app/views/users/description/_show.html.haml index cbb86df67..56234b031 100644 --- a/app/views/users/description/_show.html.haml +++ b/app/views/users/description/_show.html.haml @@ -40,6 +40,26 @@ - elsif !@dossier.brouillon? = render partial: '/layouts/modifications_terminees' - else - = submit_tag 'Soumettre mon dossier', id: 'suivant', name: 'submit[nouveaux]', class: 'btn btn btn-success', style: 'float: right;', disabled: @procedure.archivee?, data: { disable_with: 'Soumettre votre dossier', submit: true } - = submit_tag 'Enregistrer un brouillon', id: 'brouillon', name: 'submit[brouillon]', class: 'btn btn-xs btn-default', style: 'float: right; margin-right: 10px; margin-top: 6px;', disabled: @procedure.archivee?, data: { disable_with: 'Enregistrer un brouillon', submit: true } - = submit_tag "Enregistrer et voir mes dossiers", id: 'brouillon_then_dashboard', name: 'submit[brouillon_then_dashboard]', class: 'btn btn-xs btn-default', style: 'float: right; margin-right: 10px; margin-top: 6px;', disabled: @procedure.archivee?, data: { disable_with: 'Voir mes brouillons et dossiers', submit: true } + = hidden_field_tag 'submit_action', 'brouillon' + = submit_tag 'Bonjour Active Storage !', style: 'display: none;' + = button_tag 'Soumettre mon dossier', + id: 'suivant', + type: 'submit', + class: 'btn btn btn-success', + style: 'float: right;', + disabled: @procedure.archivee?, + data: { disable: true, action: 'nouveaux' } + = button_tag 'Enregistrer un brouillon', + id: 'brouillon', + type: 'submit', + class: 'btn btn-xs btn-default', + style: 'float: right; margin-right: 10px; margin-top: 6px;', + disabled: @procedure.archivee?, + data: { disable: true, action: 'brouillon' } + = button_tag "Enregistrer et voir mes dossiers", + id: 'brouillon_then_dashboard', + type: 'submit', + class: 'btn btn-xs btn-default', + style: 'float: right; margin-right: 10px; margin-top: 6px;', + disabled: @procedure.archivee?, + data: { disable: true, action: 'brouillon_then_dashboard' } diff --git a/app/views/users/description/champs/_piece_justificative.html.haml b/app/views/users/description/champs/_piece_justificative.html.haml new file mode 100644 index 000000000..e32428721 --- /dev/null +++ b/app/views/users/description/champs/_piece_justificative.html.haml @@ -0,0 +1,15 @@ +- pj = champ.piece_justificative_file + +- if !pj.attached? + = file_field_tag "champs['#{champ.id}']", + id: "champs_#{champ.id}", + direct_upload: true, + mandatory: champ.mandatory? +- else + %a{ href: url_for(pj), target: '_blank' } + = pj.filename.to_s + %br + Modifier : + = file_field_tag "champs['#{champ.id}']", + id: "champs_#{champ.id}", + direct_upload: true diff --git a/app/views/users/description/champs/_render_list_champs.html.haml b/app/views/users/description/champs/_render_list_champs.html.haml index 8115c6cac..e7f5dab2a 100644 --- a/app/views/users/description/champs/_render_list_champs.html.haml +++ b/app/views/users/description/champs/_render_list_champs.html.haml @@ -50,6 +50,9 @@ - when 'date' = render partial: 'users/description/champs/date', locals: { champ: champ } + - when 'piece_justificative' + = render partial: 'users/description/champs/piece_justificative', locals: { champ: champ } + - else %input.form-control{ name: "champs['#{champ.id}']", placeholder: champ.libelle, diff --git a/config/deploy.rb b/config/deploy.rb index f5b8304cd..7752478fe 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -56,6 +56,7 @@ set :shared_paths, [ 'config/database.yml', "config/skylight.yml", "config/fog_credentials.yml", + 'config/storage.yml', 'config/initializers/secret_token.rb', 'config/initializers/features.yml', "config/environments/#{rails_env}.rb", diff --git a/config/environments/development.rb b/config/environments/development.rb index d0370faea..acf2838a6 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -19,6 +19,8 @@ Rails.application.configure do # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false + config.active_storage.service = :local + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 60eaafedd..c27fd85c1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -68,6 +68,8 @@ Rails.application.configure do # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true + config.active_storage.service = :clever_cloud + # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify diff --git a/config/initializers/active_storage_conf_override.rb b/config/initializers/active_storage_conf_override.rb new file mode 100644 index 000000000..be56d2df4 --- /dev/null +++ b/config/initializers/active_storage_conf_override.rb @@ -0,0 +1,4 @@ +# FIXME: remove this once we moved to a properly structured infrastructure +if Rails.env.production? || Rails.env.staging? + Rails.application.config.active_storage.service = :clever_cloud +end diff --git a/config/initializers/features.rb b/config/initializers/features.rb index 761494df0..56541d0e3 100644 --- a/config/initializers/features.rb +++ b/config/initializers/features.rb @@ -11,9 +11,9 @@ class Features if File.exist?("#{File.dirname(__FILE__)}/features.yml") features_map = YAML.load_file("#{File.dirname(__FILE__)}/features.yml") if features_map - features_map.each do |feature, is_active| + features_map.each do |feature, value| define_method("#{feature}") do - is_active + value end end end diff --git a/config/initializers/features.yml b/config/initializers/features.yml index 7776e7e30..b0210bbe9 100644 --- a/config/initializers/features.yml +++ b/config/initializers/features.yml @@ -1,2 +1,4 @@ remote_storage: false weekly_overview: false +champ_pj_allowed_for_admin_ids: + - 0 diff --git a/config/initializers/monkey_patches.rb b/config/initializers/monkey_patches.rb new file mode 100644 index 000000000..ffe0a92ba --- /dev/null +++ b/config/initializers/monkey_patches.rb @@ -0,0 +1,12 @@ +# Monkey patch ActiveStorage to make Range query compatible with CleverCloud Cellar +# +# FIXME : remove when better fix is available +ActiveStorage::Identification.class_eval do + private + + def identifiable_chunk + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |client| + client.get(uri, "Range" => "bytes=0-4096").body + end + end +end diff --git a/config/locales/models/type_de_champ/fr.yml b/config/locales/models/type_de_champ/fr.yml index 1e3414f1f..7d0d1e66c 100644 --- a/config/locales/models/type_de_champ/fr.yml +++ b/config/locales/models/type_de_champ/fr.yml @@ -25,3 +25,4 @@ fr: explication: 'Explication' multiple_drop_down_list: 'Menu déroulant à choix multiples' dossier_link: 'Lien vers un autre dossier' + piece_justificative: 'Pièce justificative' diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..2c6762e0d --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,3 @@ +local: + service: Disk + root: <%= Rails.root.join("storage") %> diff --git a/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb b/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..360e0d1b7 --- /dev/null +++ b/db/migrate/20180130180754_create_active_storage_tables.active_storage.rb @@ -0,0 +1,26 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8db9e6a21..d5260c7ee 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -16,6 +16,27 @@ ActiveRecord::Schema.define(version: 2018_02_09_133452) do enable_extension "plpgsql" enable_extension "unaccent" + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + create_table "administrateurs", id: :serial, force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false diff --git a/lib/active_storage/service/cellar_service.rb b/lib/active_storage/service/cellar_service.rb new file mode 100644 index 000000000..3cb916d3b --- /dev/null +++ b/lib/active_storage/service/cellar_service.rb @@ -0,0 +1,167 @@ +require 'base64' +require 'net/http' +require 'openssl' + +module ActiveStorage + class Service::CellarService < Service + def initialize(access_key_id:, secret_access_key:, bucket:, **) + @endpoint = URI::HTTPS.build(host: "#{bucket}.cellar.services.clever-cloud.com") + @access_key_id = access_key_id + @secret_access_key = secret_access_key + @bucket = bucket + end + + def download(key) + # TODO: error handling + if block_given? + instrument :streaming_download, key: key do + http_start do |http| + http.request(get_request(key)) do |response| + response.read_body do |chunk| + yield(chunk.force_encoding(Encoding::BINARY)) + end + end + end + end + else + instrument :download, key: key do + http_start do |http| + response = http.request(get_request(key)) + if response.is_a?(Net::HTTPSuccess) + response.body.force_encoding(Encoding::BINARY) + end + end + end + end + end + + def delete(key) + # TODO: error handling + instrument :delete, key: key do + http_start do |http| + perform_delete(http, key) + end + end + end + + def delete_prefixed(prefix) + # TODO: error handling + # TODO: handle pagination if more than 1000 keys + instrument :delete_prefixed, prefix: prefix do + http_start do |http| + list_prefixed(http, prefix).each do |key| + # TODO: use bulk delete instead + perform_delete(http, key) + end + end + end + end + + def url(key, expires_in:, filename:, disposition:, content_type:) + instrument :url, key: key do |payload| + generated_url = presigned_url( + method: 'GET', + key: key, + expires_in: expires_in, + "response-content-disposition": content_disposition_with(type: disposition, filename: filename), + "response-content-type": content_type + ) + payload[:url] = generated_url + generated_url + end + end + + def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:) + instrument :url, key: key do |payload| + generated_url = presigned_url( + method: 'PUT', + key: key, + expires_in: expires_in, + content_type: content_type, + checksum: checksum + ) + payload[:url] = generated_url + generated_url + end + end + + def headers_for_direct_upload(key, content_type:, checksum:, **) + { "Content-Type" => content_type, "Content-MD5" => checksum } + end + + private + + def http_start(&block) + Net::HTTP.start(@endpoint.host, @endpoint.port, use_ssl: true, &block) + end + + def sign(request, key, checksum: '') + date = Time.now.httpdate + sig = signature(method: request.method, key: key, date: date, checksum: checksum) + request['date'] = date + request['authorization'] = "AWS #{@access_key_id}:#{sig}" + end + + def presigned_url(method:, key:, expires_in:, content_type: '', checksum: '', **query_params) + expires = expires_in.from_now.to_i + + query = query_params.merge({ + AWSAccessKeyId: @access_key_id, + Expires: expires, + Signature: signature(method: method, key: key, expires: expires, content_type: content_type, checksum: checksum) + }) + + generated_url = URI::join(@endpoint, "/#{key}","?#{query.to_query}").to_s + end + + def signature(method:, key:, expires: '', date: '', content_type: '', checksum: '') + canonicalized_amz_headers = "" + canonicalized_resource = "/#{@bucket}/#{key}" + string_to_sign = "#{method}\n#{checksum}\n#{content_type}\n#{expires}#{date}\n" + + "#{canonicalized_amz_headers}#{canonicalized_resource}" + Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), @secret_access_key, string_to_sign)).strip + end + + def list_prefixed(http, prefix) + request = Net::HTTP::Get.new(URI::join(@endpoint, "/?list-type=2&prefix=#{prefix}")) + sign(request, "") + response = http.request(request) + if response.is_a?(Net::HTTPSuccess) + parse_bucket_listing(response.body) + end + end + + def parse_bucket_listing(bucket_listing_xml) + doc = Nokogiri::XML(bucket_listing_xml) + doc + .xpath('//xmlns:Contents/xmlns:Key') + .map{ |k| k.text } + end + + def get_request(key) + request = Net::HTTP::Get.new(URI::join(@endpoint, "/#{key}")) + sign(request, key) + request + end + + def bulk_deletion_request_body(keys) + builder = Nokogiri::XML::Builder.new(:encoding => 'UTF-8') do |xml| + xml.Delete do + xml.Quiet("true") + keys.each do |k| + xml.Object do + xml.Key(k) + end + end + end + end + builder.to_xml + end + + def perform_delete(http, key) + request = Net::HTTP::Delete.new(URI::join(@endpoint, "/#{key}")) + sign(request, key) + http.request(request) + end + end +end diff --git a/spec/controllers/users/description_controller_shared_example.rb b/spec/controllers/users/description_controller_shared_example.rb index 24d0a7039..f3d024fc8 100644 --- a/spec/controllers/users/description_controller_shared_example.rb +++ b/spec/controllers/users/description_controller_shared_example.rb @@ -107,12 +107,12 @@ shared_examples 'description_controller_spec' do let(:state) { 'brouillon' } def submit_dossier - post :update, params: { dossier_id: dossier_id, submit: submit } + post :update, params: { dossier_id: dossier_id, submit_action: submit } dossier.reload end context "when the user submits the dossier" do - let(:submit) { { nouveaux: 'nouveaux' } } + let(:submit) { 'nouveaux' } it "redirection vers la page recapitulative" do submit_dossier @@ -142,7 +142,7 @@ shared_examples 'description_controller_spec' do end context 'when user saves a brouillon' do - let(:submit) { { brouillon: 'brouillon' } } + let(:submit) { 'brouillon' } it "reste sur la page du dossier" do submit_dossier @@ -156,7 +156,7 @@ shared_examples 'description_controller_spec' do end context 'when user saves a brouillon and goes to dashboard' do - let(:submit) { { brouillon_then_dashboard: 'brouillon_then_dashboard' } } + let(:submit) { 'brouillon_then_dashboard' } it "goes to dashboard" do submit_dossier diff --git a/spec/features/users/dossier_creation_spec.rb b/spec/features/users/dossier_creation_spec.rb index a1c7ab759..1ba1d94f4 100644 --- a/spec/features/users/dossier_creation_spec.rb +++ b/spec/features/users/dossier_creation_spec.rb @@ -27,6 +27,7 @@ feature 'As a User I wanna create a dossier' do expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s)) page.find_by_id('etape_suivante').click fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1' + find(:css, '[name=submit_action]').set('nouveaux') page.find_by_id('suivant').click expect(user.dossiers.first.individual.birthdate).to eq("1987-10-14") expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s)) @@ -38,6 +39,7 @@ feature 'As a User I wanna create a dossier' do expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s)) page.find_by_id('etape_suivante').click fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1' + find(:css, '[name=submit_action]').set('nouveaux') page.find_by_id('suivant').click expect(user.dossiers.first.individual.birthdate).to eq("1987-10-14") expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s)) @@ -52,6 +54,7 @@ feature 'As a User I wanna create a dossier' do expect(page).to have_current_path(users_dossier_carte_path(procedure_for_individual.dossiers.last.id.to_s)) page.find_by_id('etape_suivante').click fill_in "champs_#{procedure_for_individual.dossiers.last.champs.first.id}", with: 'contenu du champ 1' + find(:css, '[name=submit_action]').set('nouveaux') page.find_by_id('suivant').click expect(user.dossiers.first.individual.birthdate).to eq(nil) expect(page).to have_current_path(users_dossier_recapitulatif_path(procedure_for_individual.dossiers.last.id.to_s)) diff --git a/spec/helpers/type_de_champ_helper_spec.rb b/spec/helpers/type_de_champ_helper_spec.rb new file mode 100644 index 000000000..4dc202ed8 --- /dev/null +++ b/spec/helpers/type_de_champ_helper_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe TypeDeChampHelper, type: :helper do + describe ".tdc_options" do + let(:current_administrateur) { create(:administrateur) } + let(:pj_option) { ["Pièce justificative", "piece_justificative"] } + + subject { tdc_options(current_administrateur) } + + context "when the champ_pj_allowed_for_admin_id matches the current_administrateur's id" do + before { allow(Features).to receive(:champ_pj_allowed_for_admin_ids).and_return([current_administrateur.id]) } + + it { is_expected.to include(pj_option) } + end + + context "when the champ_pj_allowed_for_admin_id does not match the current_administrateur's id" do + before { allow(Features).to receive(:champ_pj_allowed_for_admin_ids).and_return([1000]) } + + it { is_expected.not_to include(pj_option) } + end + end +end diff --git a/spec/lib/active_storage/service/cellar_service_spec.rb b/spec/lib/active_storage/service/cellar_service_spec.rb new file mode 100644 index 000000000..eae73bd32 --- /dev/null +++ b/spec/lib/active_storage/service/cellar_service_spec.rb @@ -0,0 +1,166 @@ +require 'active_storage/service/cellar_service' +require 'cgi' +require 'net/http' +require 'uri' + +describe 'CellarService' do + let(:cellar_service) do + # These are actual keys, but they’re safe to put here because + # - they never had any rights attached, and + # - the keys were revoked before copying them here + + ActiveStorage::Service::CellarService.new( + access_key_id: 'AKIAJFTRSGRH3RXX6D5Q', + secret_access_key: '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', + bucket: 'rogets' + ) + end + + before { Timecop.freeze(Time.gm(2016, 10, 2)) } + after { Timecop.return } + + describe 'signature generation' do + context 'for presigned URLs' do + subject do + cellar_service.send( + :signature, + { + method: 'GET', + key: 'fichier', + expires: 5.minutes.from_now.to_i + } + ) + end + + it { is_expected.to eq('nzCsB6cip8oofkuOdvvJs6FafkA=') } + end + + context 'for server-side requests' do + subject do + Net::HTTP::Delete.new('https://rogets.cellar.services.clever-cloud.com/fichier') + end + + before { cellar_service.send(:sign, subject, 'fichier') } + + it { expect(subject['date']).to eq(Time.now.httpdate) } + it { expect(subject['authorization']).to eq('AWS AKIAJFTRSGRH3RXX6D5Q:nkvviwZYb1V9HDrKyJZmY3Z8sSA=') } + end + end + + describe 'presigned url for download' do + subject do + URI.parse( + cellar_service.url( + 'fichier', + expires_in: 5.minutes, + filename: ActiveStorage::Filename.new("toto.png"), + disposition: 'attachment', + content_type: 'image/png' + ) + ) + end + + it do + is_expected.to have_attributes( + scheme: 'https', + host: 'rogets.cellar.services.clever-cloud.com', + path: '/fichier' + ) + end + + it do + expect(CGI::parse(subject.query)).to eq( + { + 'AWSAccessKeyId' => ['AKIAJFTRSGRH3RXX6D5Q'], + 'Expires' => ['1475366700'], + 'Signature' => ['nzCsB6cip8oofkuOdvvJs6FafkA='], + 'response-content-disposition' => ["attachment; filename=\"toto.png\"; filename*=UTF-8''toto.png"], + 'response-content-type' => ['image/png'], + } + ) + end + end + + describe 'presigned url for direct upload' do + subject do + URI.parse( + cellar_service.url_for_direct_upload( + 'fichier', + expires_in: 5.minutes, + content_type: 'image/png', + content_length: 2713, + checksum: 'DEADBEEF' + ) + ) + end + + it do + is_expected.to have_attributes( + scheme: 'https', + host: 'rogets.cellar.services.clever-cloud.com', + path: '/fichier' + ) + end + + it do + expect(CGI::parse(subject.query)).to eq( + { + 'AWSAccessKeyId' => ['AKIAJFTRSGRH3RXX6D5Q'], + 'Expires' => ['1475366700'], + 'Signature' => ['VwsX5nxGfTC3dxXjS6wSeU64r5o='] + } + ) + end + end + + describe 'parse_bucket_listing' do + let(:response) do + ' + example-bucket + + 2 + 1000 + / + false + + sample1.jpg + 2011-02-26T01:56:20.000Z + "bf1d737a4d46a19f3bced6905cc8b902" + 142863 + STANDARD + + + sample2.jpg + 2011-02-26T01:56:20.000Z + "bf1d737a4d46a19f3bced6905cc8b902" + 142863 + STANDARD + + ' + end + + subject { cellar_service.send(:parse_bucket_listing, response) } + + it { is_expected.to eq(["sample1.jpg", "sample2.jpg"]) } + end + + describe 'bulk_deletion_request_body' do + let(:expected_response) do + ' + + true + + chapi + + + chapo + + +' + end + + subject { cellar_service.send(:bulk_deletion_request_body, ['chapi', 'chapo']) } + + it { is_expected.to eq(expected_response) } + end +end diff --git a/spec/views/admin/types_de_champ/show.html.haml_spec.rb b/spec/views/admin/types_de_champ/show.html.haml_spec.rb index 566542f23..32c6fb9c1 100644 --- a/spec/views/admin/types_de_champ/show.html.haml_spec.rb +++ b/spec/views/admin/types_de_champ/show.html.haml_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' describe 'admin/types_de_champ/show.html.haml', type: :view do let(:procedure) { create(:procedure) } + # FIXME: delete this when support for pj champ is generalized + before { allow(view).to receive(:current_administrateur).and_return(create(:administrateur)) } + describe 'fields sorted' do let(:first_libelle) { 'salut la compagnie' } let(:last_libelle) { 'je suis bien sur la page' } diff --git a/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb b/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb index 7e9708199..587ec1b80 100644 --- a/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb +++ b/spec/views/admin/types_de_champ_private/show.html.haml_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' describe 'admin/types_de_champ/show.html.haml', type: :view do let(:procedure) { create(:procedure) } + # FIXME: delete this when support for pj champ is generalized + before { allow(view).to receive(:current_administrateur).and_return(create(:administrateur)) } + describe 'fields sorted' do let(:first_libelle) { 'salut la compagnie' } let(:last_libelle) { 'je suis bien sur la page' }