feat(procedure): validate external links

This commit is contained in:
Paul Chavard 2023-06-29 12:58:38 +02:00
parent a43c3fd19d
commit 7136c96a36
13 changed files with 133 additions and 17 deletions

View file

@ -8,6 +8,7 @@ gem 'active_link_to' # Automatically set a class on active links
gem 'active_model_serializers' gem 'active_model_serializers'
gem 'activestorage-openstack' gem 'activestorage-openstack'
gem 'active_storage_validations' gem 'active_storage_validations'
gem 'addressable'
gem 'administrate' gem 'administrate'
gem 'administrate-field-enum' # Allow using Field::Enum in administrate gem 'administrate-field-enum' # Allow using Field::Enum in administrate
gem 'after_party' gem 'after_party'

View file

@ -800,6 +800,7 @@ DEPENDENCIES
active_model_serializers active_model_serializers
active_storage_validations active_storage_validations
activestorage-openstack activestorage-openstack
addressable
administrate administrate
administrate-field-enum administrate-field-enum
after_party after_party

View file

@ -1016,7 +1016,7 @@ type DemarcheDescriptor {
""" """
URL pour commencer la démarche URL pour commencer la démarche
""" """
demarcheUrl: String demarcheUrl: URL
""" """
Description de la démarche. Description de la démarche.
@ -1039,7 +1039,7 @@ type DemarcheDescriptor {
notice explicative de la démarche notice explicative de la démarche
""" """
notice: File notice: File
noticeUrl: String noticeUrl: URL
""" """
Numero de la démarche. Numero de la démarche.

View file

@ -25,10 +25,10 @@ Cela évite laccès récursif aux dossiers."
field :duree_conservation_dossiers, Int, "Durée de conservation des dossiers en mois.", null: false field :duree_conservation_dossiers, Int, "Durée de conservation des dossiers en mois.", null: false
field :demarche_url, String, "URL pour commencer la démarche", null: true field :demarche_url, Types::URL, "URL pour commencer la démarche", null: true
field :site_web_url, String, "URL où les usagers trouvent le lien vers la démarche", null: true field :site_web_url, String, "URL où les usagers trouvent le lien vers la démarche", null: true
field :dpo_url, String, "URL ou email pour contacter le Délégué à la Protection des Données (DPO)", null: true field :dpo_url, String, "URL ou email pour contacter le Délégué à la Protection des Données (DPO)", null: true
field :notice_url, String, null: true field :notice_url, Types::URL, null: true
field :cadre_juridique_url, String, "URL du cadre juridique qui justifie le droit de collecter les données demandées dans la démarche", null: true field :cadre_juridique_url, String, "URL du cadre juridique qui justifie le droit de collecter les données demandées dans la démarche", null: true
field :opendata, Boolean, null: false field :opendata, Boolean, null: false

View file

@ -3,12 +3,14 @@ module Types
description "A valid URL, transported as a string" description "A valid URL, transported as a string"
def self.coerce_input(input_value, context) def self.coerce_input(input_value, context)
url = URI.parse(input_value) url = Addressable::URI(input_value)
if url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS) if uri.scheme.in?(['http', 'https'])
url url
else else
raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid URL" raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid URL"
end end
rescue Addressable::URI::InvalidURIError
raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid URL"
end end
def self.coerce_result(ruby_value, context) def self.coerce_result(ruby_value, context)

View file

@ -48,7 +48,7 @@ module ProcedureHelper
def url_or_email_to_lien_dpo(procedure) def url_or_email_to_lien_dpo(procedure)
URI::MailTo.build([procedure.lien_dpo, "subject="]).to_s URI::MailTo.build([procedure.lien_dpo, "subject="]).to_s
rescue URI::InvalidComponentError rescue URI::InvalidComponentError
uri = URI.parse(procedure.lien_dpo) uri = Addressable::URI.parse(procedure.lien_dpo)
return "//#{uri}" if uri.scheme.nil? return "//#{uri}" if uri.scheme.nil?
uri.to_s uri.to_s
end end

View file

@ -0,0 +1,7 @@
class Cron::ProcedureExternalURLCheckJob < Cron::CronJob
self.schedule_expression = "every week on monday at 1 am"
def perform
Procedure.with_external_urls.find_each { ::ProcedureExternalURLCheckJob.perform_later(_1) }
end
end

View file

@ -0,0 +1,33 @@
class ProcedureExternalURLCheckJob < ApplicationJob
def perform(procedure)
procedure.validate
if procedure.lien_notice.present?
error = procedure.errors.find { _1.attribute == :lien_notice }
if error.present?
procedure.update!(lien_notice_error: error.message)
else
response = Typhoeus.get(procedure.lien_notice, followlocation: true)
if response.success?
procedure.update!(lien_notice_error: nil)
else
procedure.update!(lien_notice_error: "#{response.code} #{response.return_message}")
end
end
end
if procedure.lien_dpo.present? && !procedure.lien_dpo_email?
error = procedure.errors.find { _1.attribute == :lien_dpo }
if error.present?
procedure.update!(lien_dpo_error: error.message)
else
response = Typhoeus.get(procedure.lien_dpo, followlocation: true)
if response.success?
procedure.update!(lien_dpo_error: nil)
else
procedure.update!(lien_dpo_error: "#{response.code} #{response.return_message}")
end
end
end
end
end

View file

@ -234,6 +234,8 @@ class Procedure < ApplicationRecord
scope :opendata, -> { where(opendata: true) } scope :opendata, -> { where(opendata: true) }
scope :publiees_ou_closes, -> { where(aasm_state: [:publiee, :close, :depubliee]) } scope :publiees_ou_closes, -> { where(aasm_state: [:publiee, :close, :depubliee]) }
scope :with_external_urls, -> { where.not(lien_notice: [nil, '']).or(where.not(lien_dpo: [nil, ''])) }
scope :publiques, -> do scope :publiques, -> do
publiees_ou_closes publiees_ou_closes
.opendata .opendata
@ -294,7 +296,12 @@ class Procedure < ApplicationRecord
validates :libelle, presence: true, allow_blank: false, allow_nil: false validates :libelle, presence: true, allow_blank: false, allow_nil: false
validates :description, presence: true, allow_blank: false, allow_nil: false validates :description, presence: true, allow_blank: false, allow_nil: false
validates :administrateurs, presence: true validates :administrateurs, presence: true
validates :lien_site_web, presence: true, if: :publiee? validates :lien_site_web, presence: true, if: :publiee?
validates :lien_notice, url: { no_local: true, allow_blank: true }
validates :lien_dpo, format: { with: Devise.email_regexp, message: "n'est pas valide" }, if: :lien_dpo_email?
validates :lien_dpo, url: { no_local: true, allow_blank: true }, unless: :lien_dpo_email?
validates :draft_types_de_champ_public, validates :draft_types_de_champ_public,
'types_de_champ/no_empty_block': true, 'types_de_champ/no_empty_block': true,
'types_de_champ/no_empty_drop_down': true, 'types_de_champ/no_empty_drop_down': true,
@ -320,7 +327,6 @@ class Procedure < ApplicationRecord
less_than_or_equal_to: 60 less_than_or_equal_to: 60
} }
validates :lien_dpo, email_or_link: true, allow_nil: true
validates_with MonAvisEmbedValidator validates_with MonAvisEmbedValidator
validates_associated :draft_revision, on: :publication validates_associated :draft_revision, on: :publication
@ -978,6 +984,10 @@ class Procedure < ApplicationRecord
update!(routing_enabled: self.groupe_instructeurs.active.many?) update!(routing_enabled: self.groupe_instructeurs.active.many?)
end end
def lien_dpo_email?
lien_dpo.present? && lien_dpo.match?(/@/)
end
private private
def validate_auto_archive_on_in_the_future def validate_auto_archive_on_in_the_future

View file

@ -1,7 +0,0 @@
class EmailOrLinkValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
URI.parse(value)
rescue URI::InvalidURIError
record.errors.add(attribute, :invalid_uri_or_email)
end
end

View file

@ -0,0 +1,69 @@
require 'active_model'
require 'active_support/i18n'
require 'public_suffix'
require 'addressable/uri'
# Most of this code is borowed from https://github.com/perfectline/validates_url
class URLValidator < ActiveModel::EachValidator
RESERVED_OPTIONS = [:schemes, :no_local]
def initialize(options)
options.reverse_merge!(schemes: ['http', 'https'])
options.reverse_merge!(message: :url)
options.reverse_merge!(no_local: false)
options.reverse_merge!(public_suffix: false)
options.reverse_merge!(accept_array: false)
super(options)
end
def validate_each(record, attribute, value)
message = options.fetch(:message)
schemes = [*options.fetch(:schemes)].map(&:to_s)
if value.respond_to?(:each)
# Error out if we're not allowing arrays
if !options.include?(:accept_array) || !options.fetch(:accept_array)
record.errors.add(attribute, message, **filtered_options(value))
end
# We have to manually handle `:allow_nil` and `:allow_blank` since it's not caught by
# ActiveRecord's own validators. We do that by just removing all the nil's if we want to
# allow them so it's not passed on later.
value = value.compact if options.include?(:allow_nil) && options.fetch(:allow_nil)
value = value.compact_blank if options.include?(:allow_blank) && options.fetch(:allow_blank)
result = value.flat_map { validate_url(record, attribute, _1, message, schemes) }
errors = result.compact
return errors.any? ? errors.first : true
end
validate_url(record, attribute, value, message, schemes)
end
protected
def filtered_options(value)
filtered = options.except(*RESERVED_OPTIONS)
filtered[:value] = value
filtered
end
def validate_url(record, attribute, value, message, schemes)
uri = Addressable::URI.parse(value)
host = uri && uri.host
scheme = uri && uri.scheme
valid_scheme = host && scheme && schemes.include?(scheme)
valid_no_local = !options.fetch(:no_local) || (host && host.include?('.'))
valid_suffix = !options.fetch(:public_suffix) || (host && PublicSuffix.valid?(host, default_rule: nil))
unless valid_scheme && valid_no_local && valid_suffix
record.errors.add(attribute, message, **filtered_options(value))
end
rescue Addressable::URI::InvalidURIError
record.errors.add(attribute, message, **filtered_options(value))
end
end

View file

@ -335,7 +335,6 @@ en:
updated_at: "Updated on %{datetime}" updated_at: "Updated on %{datetime}"
edit: edit:
autosave: Your file is automatically saved after each modification. You can close the window at any time and pick up where you left off later. autosave: Your file is automatically saved after each modification. You can close the window at any time and pick up where you left off later.
notice: "Download the notice of the procedure"
pending_correction: pending_correction:
confirm_label: I certify that I have made all corrections requested by the administration. confirm_label: I certify that I have made all corrections requested by the administration.
messages: messages:
@ -569,6 +568,7 @@ en:
messages: messages:
not_a_phone: 'Invalid phone number' not_a_phone: 'Invalid phone number'
not_a_rna: 'Invalid RNA number' not_a_rna: 'Invalid RNA number'
url: 'is not a valid link'
models: models:
attestation_template: attestation_template:
attributes: attributes:

View file

@ -335,7 +335,6 @@ fr:
updated_at: "Modifié le %{datetime}" updated_at: "Modifié le %{datetime}"
edit: edit:
autosave: Votre dossier est enregistré automatiquement après chaque modification. Vous pouvez à tout moment fermer la fenêtre et reprendre plus tard là où vous en étiez. autosave: Votre dossier est enregistré automatiquement après chaque modification. Vous pouvez à tout moment fermer la fenêtre et reprendre plus tard là où vous en étiez.
notice: Télécharger le guide de la démarche
pending_correction: pending_correction:
confirm_label: Je certifie avoir effectué toutes les corrections demandées par ladministration. confirm_label: Je certifie avoir effectué toutes les corrections demandées par ladministration.
messages: messages:
@ -572,6 +571,7 @@ fr:
messages: messages:
not_a_phone: 'Numéro de téléphone invalide' not_a_phone: 'Numéro de téléphone invalide'
not_a_rna: 'Numéro RNA invalide' not_a_rna: 'Numéro RNA invalide'
url: 'nest pas un lien valide'
models: models:
attestation_template: attestation_template:
attributes: attributes: