gregbell/active_admin

View on GitHub
lib/active_admin/csv_builder.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true
module ActiveAdmin
  # CSVBuilder stores CSV configuration
  #
  # Usage example:
  #
  #   csv_builder = CSVBuilder.new
  #   csv_builder.column :id
  #   csv_builder.column("Name") { |resource| resource.full_name }
  #   csv_builder.column(:name, humanize_name: false)
  #   csv_builder.column("name", humanize_name: false) { |resource| resource.full_name }
  #
  #   csv_builder = CSVBuilder.new col_sep: ";"
  #   csv_builder = CSVBuilder.new humanize_name: false
  #   csv_builder.column :id
  #
  #
  class CSVBuilder

    # Return a default CSVBuilder for a resource
    # The CSVBuilder's columns would be Id followed by this
    # resource's content columns
    def self.default_for_resource(resource)
      new resource: resource do
        column :id
        resource.content_columns.each { |c| column c }
      end
    end

    attr_reader :columns, :options, :view_context

    COLUMN_TRANSITIVE_OPTIONS = [:humanize_name].freeze

    def initialize(options = {}, &block)
      @resource = options.delete(:resource)
      @columns = []
      @options = ActiveAdmin.application.csv_options.merge options
      @block = block
    end

    def column(name, options = {}, &block)
      @columns << Column.new(name, @resource, column_transitive_options.merge(options), block)
    end

    def build(controller, csv)
      columns = exec_columns controller.view_context
      bom = options[:byte_order_mark]
      column_names = options.delete(:column_names) { true }
      csv_options = options.except :encoding_options, :humanize_name, :byte_order_mark

      csv << bom if bom

      if column_names
        csv << CSV.generate_line(columns.map { |c| sanitize(encode(c.name, options)) }, **csv_options)
      end

      controller.send(:in_paginated_batches) do |resource|
        csv << CSV.generate_line(build_row(resource, columns, options), **csv_options)
      end

      csv
    end

    def exec_columns(view_context = nil)
      @view_context = view_context
      @columns = [] # we want to re-render these every instance
      instance_exec &@block if @block.present?
      columns
    end

    def build_row(resource, columns, options)
      columns.map do |column|
        sanitize(encode(call_method_or_proc_on(resource, column.data), options))
      end
    end

    def encode(content, options)
      if options[:encoding]
        if options[:encoding_options]
          content.to_s.encode options[:encoding], **options[:encoding_options]
        else
          content.to_s.encode options[:encoding]
        end
      else
        content
      end
    end

    def sanitize(content)
      Sanitizer.sanitize(content)
    end

    def method_missing(method, *args, &block)
      if @view_context.respond_to? method
        @view_context.public_send method, *args, &block
      else
        super
      end
    end

    class Column
      attr_reader :name, :data, :options

      DEFAULT_OPTIONS = { humanize_name: true }

      def initialize(name, resource = nil, options = {}, block = nil)
        @options = options.reverse_merge(DEFAULT_OPTIONS)
        @name = humanize_name(name, resource, @options[:humanize_name])
        @data = block || name.to_sym
      end

      def humanize_name(name, resource, humanize_name_option)
        if humanize_name_option
          name.is_a?(Symbol) && resource ? resource.resource_class.human_attribute_name(name) : name.to_s.humanize
        else
          name.to_s
        end
      end
    end

    private

    def column_transitive_options
      @column_transitive_options ||= @options.slice(*COLUMN_TRANSITIVE_OPTIONS)
    end
  end

  # Prevents CSV Injection according to https://owasp.org/www-community/attacks/CSV_Injection
  module Sanitizer
    extend self

    ATTACK_CHARACTERS = ['=', '+', '-', '@', "\t", "\r"].freeze

    def sanitize(value)
      return "'#{value}" if require_sanitization?(value)

      value
    end

    def require_sanitization?(value)
      value.is_a?(String) && value.starts_with?(*ATTACK_CHARACTERS)
    end
  end
end