lib/active_admin/csv_builder.rb
# 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