mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 05:42:36 +08:00
Authorization for a CSV export was only checked when the export was requested, not when the background job actually ran. This adds a permission check inside `Jobs::ExportCsvFile` so that access always reflects the user's current privileges at the time the data is generated. If the user is not allowed to export the entity, the job stops before any data is produced. The discourse-calendar plugin is updated with a matching `Guardian#can_export_entity?` extension so that legitimate `post_event` exports keep working under the new check.
476 lines
14 KiB
Ruby
Vendored
476 lines
14 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
|
|
module Jobs
|
|
class ExportCsvFile < ::Jobs::Base
|
|
sidekiq_options retry: false
|
|
|
|
attr_accessor :extra
|
|
attr_accessor :current_user
|
|
attr_accessor :entity
|
|
|
|
HEADER_ATTRS_FOR =
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
user_list: %w[
|
|
id
|
|
name
|
|
username
|
|
email
|
|
title
|
|
created_at
|
|
last_seen_at
|
|
last_posted_at
|
|
last_emailed_at
|
|
trust_level
|
|
approved
|
|
suspended_at
|
|
suspended_till
|
|
silenced_till
|
|
active
|
|
admin
|
|
moderator
|
|
ip_address
|
|
staged
|
|
secondary_emails
|
|
],
|
|
user_stats: %w[
|
|
topics_entered
|
|
posts_read_count
|
|
time_read
|
|
topic_count
|
|
post_count
|
|
likes_given
|
|
likes_received
|
|
],
|
|
user_profile: %w[location website views],
|
|
user_sso: %w[
|
|
external_id
|
|
external_email
|
|
external_username
|
|
external_name
|
|
external_avatar_url
|
|
],
|
|
staff_action: %w[staff_user action subject created_at details context],
|
|
screened_email: %w[email action match_count last_match_at created_at ip_address],
|
|
screened_ip: %w[ip_address action match_count last_match_at created_at],
|
|
screened_url: %w[domain action match_count last_match_at created_at],
|
|
report: %w[date value],
|
|
)
|
|
|
|
def execute(args)
|
|
@entity = args[:entity]
|
|
@extra = ActiveSupport::HashWithIndifferentAccess.new(args[:args]) if args[:args]
|
|
@current_user = User.find_by(id: args[:user_id])
|
|
|
|
entity = { name: @entity }
|
|
entity[:method] = :"#{entity[:name]}_export"
|
|
raise Discourse::InvalidParameters.new(:entity) unless respond_to?(entity[:method])
|
|
|
|
Guardian.new(@current_user).ensure_can_export_entity!(@entity, nil, @extra)
|
|
|
|
@timestamp ||= Time.now.strftime("%y%m%d-%H%M%S")
|
|
entity[:filename] = if entity[:name] == "report" && @extra[:name].present?
|
|
"#{@extra[:name].dasherize}-#{@timestamp}"
|
|
else
|
|
"#{entity[:name].dasherize}-#{@timestamp}"
|
|
end
|
|
|
|
export_title =
|
|
if @entity == "report" && @extra[:name].present?
|
|
I18n.t("reports.#{@extra[:name]}.title")
|
|
else
|
|
@entity.gsub("_", " ").titleize
|
|
end
|
|
|
|
filename = entity[:filename]
|
|
user_export = UserExport.create(file_name: filename, user_id: @current_user.id)
|
|
filename = "#{filename}-#{user_export.id}"
|
|
|
|
zip_filename = write_to_csv_and_zip(filename, entity)
|
|
|
|
# create upload
|
|
upload = nil
|
|
|
|
if File.exist?(zip_filename)
|
|
File.open(zip_filename) do |file|
|
|
upload =
|
|
UploadCreator.new(
|
|
file,
|
|
File.basename(zip_filename),
|
|
type: "csv_export",
|
|
for_export: "true",
|
|
).create_for(@current_user.id)
|
|
|
|
if upload.persisted?
|
|
user_export.update_columns(upload_id: upload.id)
|
|
else
|
|
Rails.logger.warn(
|
|
"Failed to upload the file #{zip_filename}: #{upload.errors.full_messages}",
|
|
)
|
|
end
|
|
end
|
|
|
|
File.delete(zip_filename)
|
|
end
|
|
ensure
|
|
post = notify_user(upload, export_title)
|
|
|
|
if user_export.present? && post.present?
|
|
topic = post.topic
|
|
user_export.update_columns(topic_id: topic.id)
|
|
topic.update_status("closed", true, Discourse.system_user)
|
|
end
|
|
end
|
|
|
|
def user_list_export
|
|
user_field_ids = UserField.pluck(:id)
|
|
|
|
condition = {}
|
|
if @extra && @extra[:trust_level] &&
|
|
trust_level = TrustLevel.levels[@extra[:trust_level].to_sym]
|
|
condition = { trust_level: trust_level }
|
|
end
|
|
|
|
includes = %i[user_profile user_stat groups user_emails]
|
|
includes << [:single_sign_on_record] if SiteSetting.enable_discourse_connect
|
|
|
|
User
|
|
.where(condition)
|
|
.includes(*includes)
|
|
.find_each do |user|
|
|
user_info_array = get_base_user_array(user)
|
|
if SiteSetting.enable_discourse_connect
|
|
user_info_array = add_single_sign_on(user, user_info_array)
|
|
end
|
|
user_info_array = add_custom_fields(user, user_info_array, user_field_ids)
|
|
user_info_array = add_group_names(user, user_info_array)
|
|
yield user_info_array
|
|
end
|
|
end
|
|
|
|
def staff_action_export
|
|
UserHistory
|
|
.staff_action_records(@current_user, @extra)
|
|
.find_each { |staff_action| yield get_staff_action_fields(staff_action) }
|
|
end
|
|
|
|
def screened_email_export
|
|
ScreenedEmail.find_each(order: :desc) do |screened_email|
|
|
yield get_screened_email_fields(screened_email)
|
|
end
|
|
end
|
|
|
|
def screened_ip_export
|
|
ScreenedIpAddress.find_each(order: :desc) do |screened_ip|
|
|
yield get_screened_ip_fields(screened_ip)
|
|
end
|
|
end
|
|
|
|
def screened_url_export
|
|
ScreenedUrl
|
|
.select(
|
|
"domain, sum(match_count) as match_count, max(last_match_at) as last_match_at, min(created_at) as created_at",
|
|
)
|
|
.group(:domain)
|
|
.order("last_match_at DESC")
|
|
.each { |screened_url| yield get_screened_url_fields(screened_url) }
|
|
end
|
|
|
|
def report_export
|
|
# If dates are invalid consider then `nil`
|
|
if @extra[:start_date].is_a?(String)
|
|
@extra[:start_date] = begin
|
|
@extra[:start_date].to_date.beginning_of_day
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
end
|
|
if @extra[:end_date].is_a?(String)
|
|
@extra[:end_date] = begin
|
|
@extra[:end_date].to_date.end_of_day
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
end
|
|
|
|
@extra[:filters] = {}
|
|
@extra[:filters][:category] = @extra[:category].to_i if @extra[:category].present?
|
|
@extra[:filters][:group] = @extra[:group].to_i if @extra[:group].present?
|
|
@extra[:filters][:include_subcategories] = !!ActiveRecord::Type::Boolean.new.cast(
|
|
@extra[:include_subcategories],
|
|
) if @extra[:include_subcategories].present?
|
|
|
|
report = Report.find(@extra[:name], @extra.merge(guardian: @current_user&.guardian))
|
|
|
|
header = []
|
|
titles = {}
|
|
|
|
report.labels.each do |label|
|
|
if label[:type] == :user
|
|
titles[label[:properties][:username]] = label[:title]
|
|
header << label[:properties][:username]
|
|
elsif label[:type] == :topic
|
|
titles[label[:properties][:id]] = label[:title]
|
|
header << label[:properties][:id]
|
|
elsif label[:type] == :post
|
|
titles[label[:properties][:truncated_raw]] = label[:title]
|
|
header << label[:properties][:truncated_raw]
|
|
else
|
|
titles[label[:property]] = label[:title]
|
|
header << label[:property]
|
|
end
|
|
end
|
|
|
|
if report.modes == [Report::MODES[:stacked_chart]]
|
|
hidden_labels = @extra[:hidden_labels].to_s.split(",").map(&:strip)
|
|
|
|
header = [:x]
|
|
data = {}
|
|
|
|
report.data.each do |series|
|
|
next if hidden_labels.include?(series[:req])
|
|
header << series[:label]
|
|
series[:data].each do |datapoint|
|
|
data[datapoint[:x]] ||= { x: datapoint[:x] }
|
|
data[datapoint[:x]][series[:label]] = datapoint[:y]
|
|
end
|
|
end
|
|
|
|
data = data.values
|
|
else
|
|
data = report.data
|
|
end
|
|
|
|
yield header.map { |k| titles[k] || k }
|
|
data.each { |row| yield row.values_at(*header).map(&:to_s) }
|
|
end
|
|
|
|
def get_header(entity)
|
|
if entity == "user_list"
|
|
header_array =
|
|
HEADER_ATTRS_FOR["user_list"] + HEADER_ATTRS_FOR["user_stats"] +
|
|
HEADER_ATTRS_FOR["user_profile"]
|
|
header_array.concat(HEADER_ATTRS_FOR["user_sso"]) if SiteSetting.enable_discourse_connect
|
|
user_custom_fields = UserField.all
|
|
if user_custom_fields.present?
|
|
user_custom_fields.each do |custom_field|
|
|
header_array.push("#{custom_field.name} (custom user field)")
|
|
end
|
|
end
|
|
header_array.push("group_names")
|
|
else
|
|
header_array = HEADER_ATTRS_FOR[entity]
|
|
end
|
|
|
|
header_array
|
|
end
|
|
|
|
private
|
|
|
|
def escape_comma(string)
|
|
string&.include?(",") ? %Q|"#{string}"| : string
|
|
end
|
|
|
|
def get_base_user_array(user)
|
|
# preloading scopes is hard, do this by hand
|
|
secondary_emails = []
|
|
primary_email = nil
|
|
|
|
user.user_emails.each do |user_email|
|
|
if user_email.primary?
|
|
primary_email = user_email.email
|
|
else
|
|
secondary_emails << user_email.email
|
|
end
|
|
end
|
|
|
|
[
|
|
user.id,
|
|
escape_comma(user.name),
|
|
user.username,
|
|
primary_email,
|
|
escape_comma(user.title),
|
|
user.created_at,
|
|
user.last_seen_at,
|
|
user.last_posted_at,
|
|
user.last_emailed_at,
|
|
user.trust_level,
|
|
user.approved,
|
|
user.suspended_at,
|
|
user.suspended_till,
|
|
user.silenced_till,
|
|
user.active,
|
|
user.admin,
|
|
user.moderator,
|
|
user.ip_address,
|
|
user.staged,
|
|
secondary_emails.join(";"),
|
|
user.user_stat.topics_entered,
|
|
user.user_stat.posts_read_count,
|
|
user.user_stat.time_read,
|
|
user.user_stat.topic_count,
|
|
user.user_stat.post_count,
|
|
user.user_stat.likes_given,
|
|
user.user_stat.likes_received,
|
|
escape_comma(user.user_profile.location),
|
|
user.user_profile.website,
|
|
user.user_profile.views,
|
|
]
|
|
end
|
|
|
|
def add_single_sign_on(user, user_info_array)
|
|
if user.single_sign_on_record
|
|
user_info_array.push(
|
|
user.single_sign_on_record.external_id,
|
|
user.single_sign_on_record.external_email,
|
|
user.single_sign_on_record.external_username,
|
|
escape_comma(user.single_sign_on_record.external_name),
|
|
user.single_sign_on_record.external_avatar_url,
|
|
)
|
|
else
|
|
user_info_array.push(nil, nil, nil, nil, nil)
|
|
end
|
|
user_info_array
|
|
end
|
|
|
|
def add_custom_fields(user, user_info_array, user_field_ids)
|
|
if user_field_ids.present?
|
|
user.user_fields.each do |custom_field|
|
|
user_info_array << escape_comma(custom_field[1].to_s)
|
|
end
|
|
end
|
|
user_info_array
|
|
end
|
|
|
|
def add_group_names(user, user_info_array)
|
|
group_names = user.groups.map { |g| g.name }.join(";")
|
|
if group_names.present?
|
|
user_info_array << escape_comma(group_names)
|
|
else
|
|
user_info_array << nil
|
|
end
|
|
user_info_array
|
|
end
|
|
|
|
def get_staff_action_fields(staff_action)
|
|
staff_action_array = []
|
|
can_see_content = staff_action_log_guardian.can_see_staff_action_log_content?(staff_action)
|
|
|
|
HEADER_ATTRS_FOR["staff_action"].each do |attr|
|
|
data =
|
|
if attr == "action"
|
|
UserHistory.actions.key(staff_action.attributes[attr]).to_s
|
|
elsif attr == "staff_user"
|
|
user = User.find_by(id: staff_action.attributes["acting_user_id"])
|
|
user.username if !user.nil?
|
|
elsif attr == "subject"
|
|
user = User.find_by(id: staff_action.attributes["target_user_id"])
|
|
if user.nil?
|
|
staff_action.attributes[attr]
|
|
else
|
|
"#{user.username} #{staff_action.attributes[attr]}"
|
|
end
|
|
elsif %w[details context].include?(attr) && !can_see_content
|
|
attr == "details" ? I18n.t("staff_action_logs.redacted") : nil
|
|
else
|
|
staff_action.attributes[attr]
|
|
end
|
|
|
|
staff_action_array.push(data)
|
|
end
|
|
staff_action_array
|
|
end
|
|
|
|
def get_screened_email_fields(screened_email)
|
|
screened_email_array = []
|
|
|
|
HEADER_ATTRS_FOR["screened_email"].each do |attr|
|
|
data =
|
|
if attr == "action"
|
|
ScreenedEmail.actions.key(screened_email.attributes["action_type"]).to_s
|
|
else
|
|
screened_email.attributes[attr]
|
|
end
|
|
|
|
screened_email_array.push(data)
|
|
end
|
|
|
|
screened_email_array
|
|
end
|
|
|
|
def get_screened_ip_fields(screened_ip)
|
|
screened_ip_array = []
|
|
|
|
HEADER_ATTRS_FOR["screened_ip"].each do |attr|
|
|
data =
|
|
if attr == "action"
|
|
ScreenedIpAddress.actions.key(screened_ip.attributes["action_type"]).to_s
|
|
else
|
|
screened_ip.attributes[attr]
|
|
end
|
|
|
|
screened_ip_array.push(data)
|
|
end
|
|
|
|
screened_ip_array
|
|
end
|
|
|
|
def get_screened_url_fields(screened_url)
|
|
screened_url_array = []
|
|
|
|
HEADER_ATTRS_FOR["screened_url"].each do |attr|
|
|
data =
|
|
if attr == "action"
|
|
action = ScreenedUrl.actions.key(screened_url.attributes["action_type"]).to_s
|
|
action = "do nothing" if action.blank?
|
|
else
|
|
screened_url.attributes[attr]
|
|
end
|
|
|
|
screened_url_array.push(data)
|
|
end
|
|
|
|
screened_url_array
|
|
end
|
|
|
|
def staff_action_log_guardian
|
|
@staff_action_log_guardian ||= Guardian.new(@current_user)
|
|
end
|
|
|
|
def notify_user(upload, export_title)
|
|
post = nil
|
|
|
|
if @current_user
|
|
post =
|
|
if upload&.errors&.empty?
|
|
SystemMessage.create_from_system_user(
|
|
@current_user,
|
|
:csv_export_succeeded,
|
|
download_link: UploadMarkdown.new(upload).attachment_markdown,
|
|
export_title: export_title,
|
|
)
|
|
else
|
|
SystemMessage.create_from_system_user(@current_user, :csv_export_failed)
|
|
end
|
|
end
|
|
|
|
post
|
|
end
|
|
|
|
def write_to_csv_and_zip(filename, entity)
|
|
dirname = "#{UserExport.base_directory}/#{filename}"
|
|
FileUtils.mkdir_p(dirname) unless Dir.exist?(dirname)
|
|
begin
|
|
CSV.open("#{dirname}/#{entity[:filename]}.csv", "w") do |csv|
|
|
csv << get_header(entity[:name]) if entity[:name] != "report"
|
|
public_send(entity[:method]) { |d| csv << d }
|
|
end
|
|
|
|
Compression::Zip.new.compress(UserExport.base_directory, filename)
|
|
ensure
|
|
FileUtils.rm_rf(dirname)
|
|
end
|
|
end
|
|
end
|
|
end
|