loomio/loomio

View on GitHub
app/models/concerns/has_rich_text.rb

Summary

Maintainability
A
2 hrs
Test Coverage
module HasRichText
  PREVIEW_OPTIONS = {
    resize_to_limit: [1280,1280],
    saver: {
      quality: 85,
      strip: true
    }
  }

  extend ActiveSupport::Concern

  module ClassMethods
    def is_rich_text(on: [])
      define_singleton_method :rich_text_fields, -> { Array on }
      rich_text_fields.each do |field|
        define_method "sanitize_#{field}!" do
          # return if self.send("#{field}_format") == 'md'
          tags = %w[strong em b i p s code pre big div small hr br span mark h1 h2 h3 ul ol li abbr a img video audio blockquote table thead th tr td iframe u]
          attributes = %w[href src alt title data-type data-iframe-container data-done data-mention-id poster controls data-author-id data-uid data-checked data-due-on data-color data-remind width height target colspan rowspan data-text-align]
          
          self[field] = Rails::Html::WhiteListSanitizer.new.sanitize(self[field], tags: tags, attributes: attributes)
          self[field] = HasRichText::strip_empty_paragraphs(self[field])
          self[field] = add_required_link_attributes(self[field])
          self[field] = HasRichText::add_heading_ids(self[field])
          self[field] = TaskService.rewrite_uids(self[field])
          # preserve markdown quotes after html sanitizer
          self[field] = self[field].gsub(/^&gt\; /, '> ') if self.send("#{field}_format") == 'md'
        end

        define_method "#{field}_visible_text" do
          if self.send("#{field}_format") == 'html'
            Nokogiri::HTML(self[field]).text
          else
            Nokogiri::HTML(MarkdownService.render_html(self[field])).text
          end
        end

        define_method "body_is_blank?" do
          self[field] == '' ||
          self[field] == nil ||
          self[field] == '<p></p>'
        end

        before_save :"sanitize_#{field}!"

        define_method "parse_and_update_tasks_#{field}!" do
          TaskService.parse_and_update(self, field)
        end
        after_save :"parse_and_update_tasks_#{field}!"

        validates field, {length: {maximum: Rails.application.secrets.max_message_length}}
        validates_inclusion_of :"#{field}_format", in: ['html', 'md']
        if respond_to?(:after_discard)
          after_discard do
            tasks.discard_all
          end
          after_undiscard do
            tasks.undiscard_all
          end
        end
      end
    end
  end

  included do
    has_many_attached :files, dependent: :detach
    has_many_attached :image_files, dependent: :detach
    has_many :tasks, as: :record
    before_save :update_content_locale
    before_save :build_attachments
    before_save :sanitize_link_previews
  end

  def update_content_locale
    return unless self.changed.intersection(self.class.rich_text_fields.map(&:to_s)).any?
    
    combined_text = self.class.rich_text_fields.map {|field| self[field] }.join(' ')
    stripped_text = Rails::Html::WhiteListSanitizer.new.sanitize(combined_text, tags: [])
    result = CLD.detect_language stripped_text
    self.content_locale = result[:code] if result[:reliable]
  end

  def sanitize_link_previews
    sanitizer = Rails::Html::FullSanitizer.new
    self.link_previews.each do |preview|
      preview.keys.each do |key|
        preview[key] = String(sanitizer.sanitize preview[key]).truncate(480)
      end
    end
  end

  def build_attachments
    # this line is just to help migrations through
    return true unless self.class.column_names.include?('attachments')
    self[:attachments] = files.map do |file|
      i = file.blob.slice(:id, :filename, :content_type, :byte_size)
      i.merge!({ preview_url: Rails.application.routes.url_helpers.rails_representation_path(file.representation(PREVIEW_OPTIONS), only_path: true) }) if file.representable?
      i.merge!({ download_url: Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true) })
      i.merge!({ icon: attachment_icon(file.content_type || file.filename) })
      i.merge!({ signed_id: file.signed_id })
      i
    end
  end

  def assign_attributes_and_files(params)
    self.assign_attributes API::V1::SnorlaxBase.filter_params(self.class, params)
  end

  def attachment_icon(name)
    AppConfig.doctypes.detect{ |type| /#{type['regex']}/.match(name) }['icon']
  end

  def self.strip_empty_paragraphs(text)
    fragment = Nokogiri::HTML::DocumentFragment.parse(text)
    fragment.css('p').each do |node|
      if node.content.match?(/^[[:space:]]+$/)
        node.content = node.content.gsub(/^[[:space:]]+$/, '')
      end
    end
    fragment.to_s
  end

  def self.add_heading_ids(text)
    fragment = Nokogiri::HTML::DocumentFragment.parse(text)
    fragment.css('h1,h2,h3,h4,h5,h6').each do |node|
      node['id'] = node.text[0,60].strip.parameterize
    end
    fragment.to_s
  end

  def add_required_link_attributes(text)
    fragment = Nokogiri::HTML::DocumentFragment.parse(text)
    fragment.css('a').each do |node|
      node['rel'] = 'nofollow ugc noreferrer noopener'
      node['target'] = '_blank'
    end
    fragment.to_s
  end
end