629 lines
17 KiB
Ruby
629 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
|
|
module Jobs
|
|
class ExportUserArchive < ::Jobs::Base
|
|
sidekiq_options retry: false
|
|
|
|
attr_accessor :current_user
|
|
# note: contents provided entirely by user
|
|
attr_accessor :extra
|
|
|
|
COMPONENTS = %w[
|
|
user_archive
|
|
preferences
|
|
auth_tokens
|
|
auth_token_logs
|
|
badges
|
|
bookmarks
|
|
category_preferences
|
|
flags
|
|
likes
|
|
post_actions
|
|
queued_posts
|
|
visits
|
|
]
|
|
|
|
HEADER_ATTRS_FOR =
|
|
HashWithIndifferentAccess.new(
|
|
user_archive: %w[
|
|
topic_title
|
|
categories
|
|
is_pm
|
|
post_raw
|
|
post_cooked
|
|
like_count
|
|
reply_count
|
|
url
|
|
created_at
|
|
],
|
|
user_archive_profile: %w[location website bio views],
|
|
auth_tokens: %w[
|
|
id
|
|
auth_token_hash
|
|
prev_auth_token_hash
|
|
auth_token_seen
|
|
client_ip
|
|
user_agent
|
|
seen_at
|
|
rotated_at
|
|
created_at
|
|
updated_at
|
|
],
|
|
auth_token_logs: %w[
|
|
id
|
|
action
|
|
user_auth_token_id
|
|
client_ip
|
|
auth_token_hash
|
|
created_at
|
|
path
|
|
user_agent
|
|
],
|
|
badges: %w[
|
|
badge_id
|
|
badge_name
|
|
granted_at
|
|
post_id
|
|
seq
|
|
granted_manually
|
|
notification_id
|
|
featured_rank
|
|
],
|
|
bookmarks: %w[
|
|
bookmarkable_id
|
|
bookmarkable_type
|
|
link
|
|
name
|
|
created_at
|
|
updated_at
|
|
reminder_at
|
|
reminder_last_sent_at
|
|
reminder_set_at
|
|
auto_delete_preference
|
|
],
|
|
category_preferences: %w[
|
|
category_id
|
|
category_names
|
|
notification_level
|
|
dismiss_new_timestamp
|
|
],
|
|
flags: %w[
|
|
id
|
|
post_id
|
|
flag_type
|
|
created_at
|
|
updated_at
|
|
deleted_at
|
|
deleted_by
|
|
related_post_id
|
|
targets_topic
|
|
was_take_action
|
|
],
|
|
likes: %w[id post_id topic_id post_number created_at updated_at deleted_at deleted_by],
|
|
post_actions: %w[
|
|
id
|
|
post_id
|
|
post_action_type
|
|
created_at
|
|
updated_at
|
|
deleted_at
|
|
deleted_by
|
|
related_post_id
|
|
],
|
|
queued_posts: %w[id verdict category_id topic_id post_raw other_json],
|
|
visits: %w[visited_at posts_read mobile time_read],
|
|
)
|
|
|
|
def execute(args)
|
|
@current_user = User.find_by(id: args[:user_id])
|
|
@extra = HashWithIndifferentAccess.new(args[:args]) if args[:args]
|
|
@timestamp ||= Time.now.strftime("%y%m%d-%H%M%S")
|
|
|
|
components = []
|
|
|
|
COMPONENTS.each do |name|
|
|
export_method = "#{name}_export"
|
|
h = { name: name, method: :"#{export_method}" }
|
|
h[:filetype] = :csv
|
|
filetype_method = :"#{name}_filetype"
|
|
h[:filetype] = public_send(filetype_method) if respond_to? filetype_method
|
|
condition_method = :"include_#{name}?"
|
|
h[:skip] = !public_send(condition_method) if respond_to? condition_method
|
|
h[:filename] = name
|
|
components.push(h)
|
|
end
|
|
|
|
export_title = "user_archive".titleize
|
|
filename = "user_archive-#{@current_user.username}-#{@timestamp}"
|
|
user_export = UserExport.create(file_name: filename, user_id: @current_user.id)
|
|
|
|
filename = "#{filename}-#{user_export.id}"
|
|
dirname = "#{UserExport.base_directory}/#{filename}"
|
|
|
|
# ensure directory exists
|
|
FileUtils.mkdir_p(dirname) unless Dir.exist?(dirname)
|
|
|
|
# Generate a compressed CSV file
|
|
zip_filename = nil
|
|
begin
|
|
components.each do |component|
|
|
next if component[:skip]
|
|
case component[:filetype]
|
|
when :csv
|
|
CSV.open("#{dirname}/#{component[:filename]}.csv", "w") do |csv|
|
|
csv << get_header(component[:name])
|
|
public_send(component[:method]) { |d| csv << d }
|
|
end
|
|
when :json
|
|
File.open("#{dirname}/#{component[:filename]}.json", "w") do |file|
|
|
file.write MultiJson.dump(public_send(component[:method]), indent: 4)
|
|
end
|
|
else
|
|
raise "unknown export filetype"
|
|
end
|
|
end
|
|
|
|
zip_filename = Compression::Zip.new.compress(UserExport.base_directory, filename)
|
|
ensure
|
|
FileUtils.rm_rf(dirname)
|
|
end
|
|
|
|
# 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_archive_export
|
|
return enum_for(:user_archive_export) unless block_given?
|
|
|
|
Post
|
|
.includes(topic: :category)
|
|
.where(user_id: @current_user.id)
|
|
.select(:topic_id, :post_number, :raw, :cooked, :like_count, :reply_count, :created_at)
|
|
.order(:created_at)
|
|
.with_deleted
|
|
.each { |user_archive| yield get_user_archive_fields(user_archive) }
|
|
end
|
|
|
|
def user_archive_profile_export
|
|
return enum_for(:user_archive_profile_export) unless block_given?
|
|
|
|
UserProfile
|
|
.where(user_id: @current_user.id)
|
|
.select(:location, :website, :bio_raw, :views)
|
|
.each { |user_profile| yield get_user_archive_profile_fields(user_profile) }
|
|
end
|
|
|
|
def preferences_export
|
|
UserSerializer.new(@current_user, scope: guardian)
|
|
end
|
|
|
|
def preferences_filetype
|
|
:json
|
|
end
|
|
|
|
def auth_tokens_export
|
|
return enum_for(:auth_tokens) unless block_given?
|
|
|
|
UserAuthToken
|
|
.where(user_id: @current_user.id)
|
|
.each do |token|
|
|
yield(
|
|
[
|
|
token.id,
|
|
token.auth_token.to_s[0..4] + "...", # hashed and truncated
|
|
token.prev_auth_token[0..4] + "...",
|
|
token.auth_token_seen,
|
|
token.client_ip,
|
|
token.user_agent,
|
|
token.seen_at,
|
|
token.rotated_at,
|
|
token.created_at,
|
|
token.updated_at,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def include_auth_token_logs?
|
|
# SiteSetting.verbose_auth_token_logging
|
|
UserAuthTokenLog.where(user_id: @current_user.id).exists?
|
|
end
|
|
|
|
def auth_token_logs_export
|
|
return enum_for(:auth_token_logs) unless block_given?
|
|
|
|
UserAuthTokenLog
|
|
.where(user_id: @current_user.id)
|
|
.each do |log|
|
|
yield(
|
|
[
|
|
log.id,
|
|
log.action,
|
|
log.user_auth_token_id,
|
|
log.client_ip,
|
|
log.auth_token.to_s[0..4] + "...", # hashed and truncated
|
|
log.created_at,
|
|
log.path,
|
|
log.user_agent,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def badges_export
|
|
return enum_for(:badges_export) unless block_given?
|
|
|
|
UserBadge
|
|
.where(user_id: @current_user.id)
|
|
.joins(:badge)
|
|
.select(
|
|
:badge_id,
|
|
:granted_at,
|
|
:post_id,
|
|
:seq,
|
|
:granted_by_id,
|
|
:notification_id,
|
|
:featured_rank,
|
|
)
|
|
.order(:granted_at)
|
|
.each do |ub|
|
|
yield(
|
|
[
|
|
ub.badge_id,
|
|
ub.badge.display_name,
|
|
ub.granted_at,
|
|
ub.post_id,
|
|
ub.seq,
|
|
# Hide the admin's identity, simply indicate human or system
|
|
User.human_user_id?(ub.granted_by_id),
|
|
ub.notification_id,
|
|
ub.featured_rank,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def bookmarks_export
|
|
return enum_for(:bookmarks_export) unless block_given?
|
|
|
|
@current_user
|
|
.bookmarks
|
|
.where.not(bookmarkable_type: nil)
|
|
.order(:id)
|
|
.each do |bookmark|
|
|
link = ""
|
|
if guardian.can_see_bookmarkable?(bookmark)
|
|
if bookmark.bookmarkable.respond_to?(:full_url)
|
|
link = bookmark.bookmarkable.full_url
|
|
else
|
|
link = bookmark.bookmarkable.url
|
|
end
|
|
end
|
|
|
|
yield(
|
|
[
|
|
bookmark.bookmarkable_id,
|
|
bookmark.bookmarkable_type,
|
|
link,
|
|
bookmark.name,
|
|
bookmark.created_at,
|
|
bookmark.updated_at,
|
|
bookmark.reminder_at,
|
|
bookmark.reminder_last_sent_at,
|
|
bookmark.reminder_set_at,
|
|
Bookmark.auto_delete_preferences[bookmark.auto_delete_preference],
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def category_preferences_export
|
|
return enum_for(:category_preferences_export) unless block_given?
|
|
|
|
CategoryUser
|
|
.where(user_id: @current_user.id)
|
|
.includes(:category)
|
|
.merge(Category.secured(guardian))
|
|
.each do |cu|
|
|
yield(
|
|
[
|
|
cu.category_id,
|
|
piped_category_name(cu.category_id, cu.category),
|
|
NotificationLevels.all[cu.notification_level],
|
|
cu.last_seen_at,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def post_action_type_view
|
|
@post_action_type_view ||= PostActionTypeView.new
|
|
end
|
|
|
|
def flags_export
|
|
return enum_for(:flags_export) unless block_given?
|
|
|
|
PostAction
|
|
.with_deleted
|
|
.where(user_id: @current_user.id)
|
|
.where(post_action_type_id: post_action_type_view.flag_types.values)
|
|
.order(:created_at)
|
|
.each do |pa|
|
|
yield(
|
|
[
|
|
pa.id,
|
|
pa.post_id,
|
|
post_action_type_view.flag_types[pa.post_action_type_id],
|
|
pa.created_at,
|
|
pa.updated_at,
|
|
pa.deleted_at,
|
|
self_or_other(pa.deleted_by_id),
|
|
pa.related_post_id,
|
|
pa.targets_topic,
|
|
# renamed to 'was_take_action' to avoid possibility of thinking this is a synonym of agreed_at
|
|
pa.staff_took_action,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def likes_export
|
|
return enum_for(:likes_export) unless block_given?
|
|
PostAction
|
|
.with_deleted
|
|
.where(user_id: @current_user.id)
|
|
.where(post_action_type_id: post_action_type_view.types[:like])
|
|
.order(:created_at)
|
|
.each do |pa|
|
|
post = Post.with_deleted.find_by(id: pa.post_id)
|
|
yield(
|
|
[
|
|
pa.id,
|
|
pa.post_id,
|
|
post&.topic_id,
|
|
post&.post_number,
|
|
pa.created_at,
|
|
pa.updated_at,
|
|
pa.deleted_at,
|
|
self_or_other(pa.deleted_by_id),
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def include_post_actions?
|
|
# Most forums should not have post_action records other than flags and likes, but they are possible in historical oddities.
|
|
PostAction
|
|
.where(user_id: @current_user.id)
|
|
.where.not(
|
|
post_action_type_id:
|
|
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
|
)
|
|
.exists?
|
|
end
|
|
|
|
def post_actions_export
|
|
return enum_for(:likes_export) unless block_given?
|
|
PostAction
|
|
.with_deleted
|
|
.where(user_id: @current_user.id)
|
|
.where.not(
|
|
post_action_type_id:
|
|
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
|
)
|
|
.order(:created_at)
|
|
.each do |pa|
|
|
yield(
|
|
[
|
|
pa.id,
|
|
pa.post_id,
|
|
post_action_type_view.types[pa.post_action_type] || pa.post_action_type,
|
|
pa.created_at,
|
|
pa.updated_at,
|
|
pa.deleted_at,
|
|
self_or_other(pa.deleted_by_id),
|
|
pa.related_post_id,
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def queued_posts_export
|
|
return enum_for(:queued_posts_export) unless block_given?
|
|
|
|
# Most Reviewable fields staff-private, but post content needs to be exported.
|
|
ReviewableQueuedPost
|
|
.where(target_created_by_id: @current_user.id)
|
|
.order(:created_at)
|
|
.each do |rev|
|
|
yield(
|
|
[
|
|
rev.id,
|
|
rev.status,
|
|
rev.category_id,
|
|
rev.topic_id,
|
|
rev.payload["raw"],
|
|
MultiJson.dump(rev.payload.slice(*queued_posts_payload_permitted_keys)),
|
|
]
|
|
)
|
|
end
|
|
end
|
|
|
|
def visits_export
|
|
return enum_for(:visits_export) unless block_given?
|
|
|
|
UserVisit
|
|
.where(user_id: @current_user.id)
|
|
.order(visited_at: :asc)
|
|
.each { |uv| yield [uv.visited_at, uv.posts_read, uv.mobile, uv.time_read] }
|
|
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 guardian
|
|
@guardian ||= Guardian.new(@current_user)
|
|
end
|
|
|
|
def piped_category_name(category_id, category)
|
|
return "#{category_id}" if category_id && !category
|
|
return "-" if !guardian.can_see_category?(category)
|
|
categories = [category.name]
|
|
while category.parent_category_id && category = category.parent_category
|
|
categories << category.name
|
|
end
|
|
categories.reverse.join("|")
|
|
end
|
|
|
|
def self_or_other(user_id)
|
|
if user_id.nil?
|
|
nil
|
|
elsif user_id == @current_user.id
|
|
"self"
|
|
else
|
|
"other"
|
|
end
|
|
end
|
|
|
|
def get_user_archive_fields(user_archive)
|
|
user_archive_array = []
|
|
topic_data = user_archive.topic
|
|
user_archive =
|
|
user_archive.as_json(
|
|
only: %i[topic_id post_number raw cooked like_count reply_count created_at id],
|
|
)
|
|
topic_data =
|
|
Topic
|
|
.with_deleted
|
|
.includes(:category)
|
|
.find_by(id: user_archive["topic_id"]) if topic_data.nil?
|
|
return user_archive_array if topic_data.nil?
|
|
|
|
categories = piped_category_name(topic_data.category_id, topic_data.category)
|
|
is_pm =
|
|
(
|
|
if topic_data.archetype == "private_message"
|
|
I18n.t("csv_export.boolean_yes")
|
|
else
|
|
I18n.t("csv_export.boolean_no")
|
|
end
|
|
)
|
|
url =
|
|
"#{Discourse.base_url}/t/#{topic_data.slug}/#{topic_data.id}/#{user_archive["post_number"]}"
|
|
|
|
topic_hash = {
|
|
"post_raw" => user_archive["raw"],
|
|
"post_cooked" => user_archive["cooked"],
|
|
"topic_title" => topic_data.title,
|
|
"categories" => categories,
|
|
"is_pm" => is_pm,
|
|
"url" => url,
|
|
}
|
|
|
|
user_archive.merge!(topic_hash)
|
|
|
|
HEADER_ATTRS_FOR["user_archive"].each { |attr| user_archive_array.push(user_archive[attr]) }
|
|
|
|
user_archive_array
|
|
end
|
|
|
|
def get_user_archive_profile_fields(user_profile)
|
|
user_archive_profile = []
|
|
|
|
HEADER_ATTRS_FOR["user_archive_profile"].each do |attr|
|
|
data =
|
|
if attr == "bio"
|
|
user_profile.attributes["bio_raw"]
|
|
else
|
|
user_profile.attributes[attr]
|
|
end
|
|
|
|
user_archive_profile.push(data)
|
|
end
|
|
|
|
user_archive_profile
|
|
end
|
|
|
|
def queued_posts_payload_permitted_keys
|
|
# Generated with:
|
|
#
|
|
# SELECT distinct json_object_keys(payload) from reviewables
|
|
# where type = 'ReviewableQueuedPost' and (payload->'old_queued_post_id') IS NULL
|
|
#
|
|
# except raw, created_topic_id, created_post_id
|
|
%w[composer_open_duration_msecs is_poll reply_to_post_number tags title typing_duration_msecs]
|
|
end
|
|
|
|
def notify_user(upload, export_title)
|
|
post = nil
|
|
|
|
if @current_user
|
|
post =
|
|
if upload.persisted?
|
|
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
|
|
end
|
|
end
|