mirror of
https://github.com/discourse/discourse.git
synced 2025-01-29 05:42:18 +08:00
DEV: Allow export user archive (job) to be requested and sent to an admin (#30543)
It is not possible for an admin to generate a suspended user's archive now, disallowing SAR (subject access requests) under the GDPR. This commit expands the export_user_archive job to allow specifying a requesting_user_id which will send the archive to an admin. When not specified, this defaults to the user itself.
This commit is contained in:
parent
5a55c9062a
commit
91f7ae2741
|
@ -6,7 +6,7 @@ module Jobs
|
||||||
class ExportUserArchive < ::Jobs::Base
|
class ExportUserArchive < ::Jobs::Base
|
||||||
sidekiq_options retry: false
|
sidekiq_options retry: false
|
||||||
|
|
||||||
attr_accessor :current_user
|
attr_accessor :archive_for_user
|
||||||
# note: contents provided entirely by user
|
# note: contents provided entirely by user
|
||||||
attr_accessor :extra
|
attr_accessor :extra
|
||||||
|
|
||||||
|
@ -117,7 +117,19 @@ module Jobs
|
||||||
)
|
)
|
||||||
|
|
||||||
def execute(args)
|
def execute(args)
|
||||||
@current_user = User.find_by(id: args[:user_id])
|
@archive_for_user = User.find_by(id: args[:user_id])
|
||||||
|
|
||||||
|
if args[:requesting_user_id].present?
|
||||||
|
@requesting_user = User.find_by(id: args[:requesting_user_id])
|
||||||
|
if !@requesting_user&.admin?
|
||||||
|
raise Discourse::InvalidParameters.new(
|
||||||
|
"requesting_user_id: can only be admins when specified",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
@requesting_user = @archive_for_user
|
||||||
|
end
|
||||||
|
|
||||||
@extra = HashWithIndifferentAccess.new(args[:args]) if args[:args]
|
@extra = HashWithIndifferentAccess.new(args[:args]) if args[:args]
|
||||||
@timestamp ||= Time.now.strftime("%y%m%d-%H%M%S")
|
@timestamp ||= Time.now.strftime("%y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
@ -136,8 +148,8 @@ module Jobs
|
||||||
end
|
end
|
||||||
|
|
||||||
export_title = "user_archive".titleize
|
export_title = "user_archive".titleize
|
||||||
filename = "user_archive-#{@current_user.username}-#{@timestamp}"
|
filename = "user_archive-#{@archive_for_user.username}-#{@timestamp}"
|
||||||
user_export = UserExport.create(file_name: filename, user_id: @current_user.id)
|
user_export = UserExport.create(file_name: filename, user_id: @archive_for_user.id)
|
||||||
|
|
||||||
filename = "#{filename}-#{user_export.id}"
|
filename = "#{filename}-#{user_export.id}"
|
||||||
dirname = "#{UserExport.base_directory}/#{filename}"
|
dirname = "#{UserExport.base_directory}/#{filename}"
|
||||||
|
@ -170,37 +182,17 @@ module Jobs
|
||||||
FileUtils.rm_rf(dirname)
|
FileUtils.rm_rf(dirname)
|
||||||
end
|
end
|
||||||
|
|
||||||
# create upload
|
begin
|
||||||
upload = nil
|
# create upload
|
||||||
|
upload = create_upload_for_user(user_export, zip_filename)
|
||||||
|
ensure
|
||||||
|
post = notify_user(upload, export_title)
|
||||||
|
|
||||||
if File.exist?(zip_filename)
|
if user_export.present? && post.present?
|
||||||
File.open(zip_filename) do |file|
|
topic = post.topic
|
||||||
upload =
|
user_export.update_columns(topic_id: topic.id)
|
||||||
UploadCreator.new(
|
topic.update_status("closed", true, Discourse.system_user)
|
||||||
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
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -209,7 +201,7 @@ module Jobs
|
||||||
|
|
||||||
Post
|
Post
|
||||||
.includes(topic: :category)
|
.includes(topic: :category)
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.select(:topic_id, :post_number, :raw, :cooked, :like_count, :reply_count, :created_at)
|
.select(:topic_id, :post_number, :raw, :cooked, :like_count, :reply_count, :created_at)
|
||||||
.order(:created_at)
|
.order(:created_at)
|
||||||
.with_deleted
|
.with_deleted
|
||||||
|
@ -220,13 +212,13 @@ module Jobs
|
||||||
return enum_for(:user_archive_profile_export) unless block_given?
|
return enum_for(:user_archive_profile_export) unless block_given?
|
||||||
|
|
||||||
UserProfile
|
UserProfile
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.select(:location, :website, :bio_raw, :views)
|
.select(:location, :website, :bio_raw, :views)
|
||||||
.each { |user_profile| yield get_user_archive_profile_fields(user_profile) }
|
.each { |user_profile| yield get_user_archive_profile_fields(user_profile) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def preferences_export
|
def preferences_export
|
||||||
UserSerializer.new(@current_user, scope: guardian)
|
UserSerializer.new(@archive_for_user, scope: guardian)
|
||||||
end
|
end
|
||||||
|
|
||||||
def preferences_filetype
|
def preferences_filetype
|
||||||
|
@ -237,7 +229,7 @@ module Jobs
|
||||||
return enum_for(:auth_tokens) unless block_given?
|
return enum_for(:auth_tokens) unless block_given?
|
||||||
|
|
||||||
UserAuthToken
|
UserAuthToken
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.each do |token|
|
.each do |token|
|
||||||
yield(
|
yield(
|
||||||
[
|
[
|
||||||
|
@ -258,14 +250,14 @@ module Jobs
|
||||||
|
|
||||||
def include_auth_token_logs?
|
def include_auth_token_logs?
|
||||||
# SiteSetting.verbose_auth_token_logging
|
# SiteSetting.verbose_auth_token_logging
|
||||||
UserAuthTokenLog.where(user_id: @current_user.id).exists?
|
UserAuthTokenLog.where(user_id: @archive_for_user.id).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
def auth_token_logs_export
|
def auth_token_logs_export
|
||||||
return enum_for(:auth_token_logs) unless block_given?
|
return enum_for(:auth_token_logs) unless block_given?
|
||||||
|
|
||||||
UserAuthTokenLog
|
UserAuthTokenLog
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.each do |log|
|
.each do |log|
|
||||||
yield(
|
yield(
|
||||||
[
|
[
|
||||||
|
@ -286,7 +278,7 @@ module Jobs
|
||||||
return enum_for(:badges_export) unless block_given?
|
return enum_for(:badges_export) unless block_given?
|
||||||
|
|
||||||
UserBadge
|
UserBadge
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.joins(:badge)
|
.joins(:badge)
|
||||||
.select(
|
.select(
|
||||||
:badge_id,
|
:badge_id,
|
||||||
|
@ -318,7 +310,7 @@ module Jobs
|
||||||
def bookmarks_export
|
def bookmarks_export
|
||||||
return enum_for(:bookmarks_export) unless block_given?
|
return enum_for(:bookmarks_export) unless block_given?
|
||||||
|
|
||||||
@current_user
|
@archive_for_user
|
||||||
.bookmarks
|
.bookmarks
|
||||||
.where.not(bookmarkable_type: nil)
|
.where.not(bookmarkable_type: nil)
|
||||||
.order(:id)
|
.order(:id)
|
||||||
|
@ -353,7 +345,7 @@ module Jobs
|
||||||
return enum_for(:category_preferences_export) unless block_given?
|
return enum_for(:category_preferences_export) unless block_given?
|
||||||
|
|
||||||
CategoryUser
|
CategoryUser
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.includes(:category)
|
.includes(:category)
|
||||||
.merge(Category.secured(guardian))
|
.merge(Category.secured(guardian))
|
||||||
.each do |cu|
|
.each do |cu|
|
||||||
|
@ -377,7 +369,7 @@ module Jobs
|
||||||
|
|
||||||
PostAction
|
PostAction
|
||||||
.with_deleted
|
.with_deleted
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.where(post_action_type_id: post_action_type_view.flag_types.values)
|
.where(post_action_type_id: post_action_type_view.flag_types.values)
|
||||||
.order(:created_at)
|
.order(:created_at)
|
||||||
.each do |pa|
|
.each do |pa|
|
||||||
|
@ -403,7 +395,7 @@ module Jobs
|
||||||
return enum_for(:likes_export) unless block_given?
|
return enum_for(:likes_export) unless block_given?
|
||||||
PostAction
|
PostAction
|
||||||
.with_deleted
|
.with_deleted
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.where(post_action_type_id: post_action_type_view.types[:like])
|
.where(post_action_type_id: post_action_type_view.types[:like])
|
||||||
.order(:created_at)
|
.order(:created_at)
|
||||||
.each do |pa|
|
.each do |pa|
|
||||||
|
@ -426,7 +418,7 @@ module Jobs
|
||||||
def include_post_actions?
|
def include_post_actions?
|
||||||
# Most forums should not have post_action records other than flags and likes, but they are possible in historical oddities.
|
# Most forums should not have post_action records other than flags and likes, but they are possible in historical oddities.
|
||||||
PostAction
|
PostAction
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.where.not(
|
.where.not(
|
||||||
post_action_type_id:
|
post_action_type_id:
|
||||||
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
||||||
|
@ -438,7 +430,7 @@ module Jobs
|
||||||
return enum_for(:likes_export) unless block_given?
|
return enum_for(:likes_export) unless block_given?
|
||||||
PostAction
|
PostAction
|
||||||
.with_deleted
|
.with_deleted
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.where.not(
|
.where.not(
|
||||||
post_action_type_id:
|
post_action_type_id:
|
||||||
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
post_action_type_view.flag_types.values + [post_action_type_view.types[:like]],
|
||||||
|
@ -465,7 +457,7 @@ module Jobs
|
||||||
|
|
||||||
# Most Reviewable fields staff-private, but post content needs to be exported.
|
# Most Reviewable fields staff-private, but post content needs to be exported.
|
||||||
ReviewableQueuedPost
|
ReviewableQueuedPost
|
||||||
.where(target_created_by_id: @current_user.id)
|
.where(target_created_by_id: @archive_for_user.id)
|
||||||
.order(:created_at)
|
.order(:created_at)
|
||||||
.each do |rev|
|
.each do |rev|
|
||||||
yield(
|
yield(
|
||||||
|
@ -485,7 +477,7 @@ module Jobs
|
||||||
return enum_for(:visits_export) unless block_given?
|
return enum_for(:visits_export) unless block_given?
|
||||||
|
|
||||||
UserVisit
|
UserVisit
|
||||||
.where(user_id: @current_user.id)
|
.where(user_id: @archive_for_user.id)
|
||||||
.order(visited_at: :asc)
|
.order(visited_at: :asc)
|
||||||
.each { |uv| yield [uv.visited_at, uv.posts_read, uv.mobile, uv.time_read] }
|
.each { |uv| yield [uv.visited_at, uv.posts_read, uv.mobile, uv.time_read] }
|
||||||
end
|
end
|
||||||
|
@ -512,8 +504,34 @@ module Jobs
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def create_upload_for_user(user_export, zip_filename)
|
||||||
|
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(@requesting_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
|
||||||
|
upload
|
||||||
|
end
|
||||||
|
|
||||||
def guardian
|
def guardian
|
||||||
@guardian ||= Guardian.new(@current_user)
|
@guardian ||= Guardian.new(@archive_for_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def piped_category_name(category_id, category)
|
def piped_category_name(category_id, category)
|
||||||
|
@ -529,7 +547,7 @@ module Jobs
|
||||||
def self_or_other(user_id)
|
def self_or_other(user_id)
|
||||||
if user_id.nil?
|
if user_id.nil?
|
||||||
nil
|
nil
|
||||||
elsif user_id == @current_user.id
|
elsif user_id == @archive_for_user.id
|
||||||
"self"
|
"self"
|
||||||
else
|
else
|
||||||
"other"
|
"other"
|
||||||
|
@ -608,17 +626,17 @@ module Jobs
|
||||||
def notify_user(upload, export_title)
|
def notify_user(upload, export_title)
|
||||||
post = nil
|
post = nil
|
||||||
|
|
||||||
if @current_user
|
if @requesting_user
|
||||||
post =
|
post =
|
||||||
if upload.persisted?
|
if upload.persisted?
|
||||||
SystemMessage.create_from_system_user(
|
SystemMessage.create_from_system_user(
|
||||||
@current_user,
|
@requesting_user,
|
||||||
:csv_export_succeeded,
|
:csv_export_succeeded,
|
||||||
download_link: UploadMarkdown.new(upload).attachment_markdown,
|
download_link: UploadMarkdown.new(upload).attachment_markdown,
|
||||||
export_title: export_title,
|
export_title: export_title,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SystemMessage.create_from_system_user(@current_user, :csv_export_failed)
|
SystemMessage.create_from_system_user(@requesting_user, :csv_export_failed)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ RSpec.describe Jobs::ExportUserArchive do
|
||||||
let(:extra) { {} }
|
let(:extra) { {} }
|
||||||
let(:job) do
|
let(:job) do
|
||||||
j = Jobs::ExportUserArchive.new
|
j = Jobs::ExportUserArchive.new
|
||||||
j.current_user = user
|
j.archive_for_user = user
|
||||||
j.extra = extra
|
j.extra = extra
|
||||||
j
|
j
|
||||||
end
|
end
|
||||||
|
@ -115,6 +115,42 @@ RSpec.describe Jobs::ExportUserArchive do
|
||||||
I18n.t("system_messages.csv_export_failed.subject_template"),
|
I18n.t("system_messages.csv_export_failed.subject_template"),
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "with a requesting_user_id that is not the user being exported" do
|
||||||
|
it "raises an error when not admin" do
|
||||||
|
expect do
|
||||||
|
Jobs::ExportUserArchive.new.execute(user_id: user.id, requesting_user_id: user2.id)
|
||||||
|
end.to raise_error(
|
||||||
|
Discourse::InvalidParameters,
|
||||||
|
"requesting_user_id: can only be admins when specified",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates the upload and sends the message to the specified requesting_user_id" do
|
||||||
|
expect do Jobs::ExportUserArchive.new.execute(user_id: user2.id) end.to change {
|
||||||
|
Upload.count
|
||||||
|
}.by(1)
|
||||||
|
|
||||||
|
system_message = user2.topics_allowed.last
|
||||||
|
|
||||||
|
expect(system_message.title).to eq(
|
||||||
|
I18n.t(
|
||||||
|
"system_messages.csv_export_succeeded.subject_template",
|
||||||
|
export_title: "User Archive",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
upload = system_message.first_post.uploads.first
|
||||||
|
|
||||||
|
expect(system_message.first_post.raw).to eq(
|
||||||
|
I18n.t(
|
||||||
|
"system_messages.csv_export_succeeded.text_body_template",
|
||||||
|
download_link:
|
||||||
|
"[#{upload.original_filename}|attachment](#{upload.short_url}) (#{upload.human_filesize})",
|
||||||
|
).chomp,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "user_archive posts" do
|
describe "user_archive posts" do
|
||||||
|
|
Loading…
Reference in New Issue
Block a user