diff --git a/app/assets/javascripts/admin/addon/components/admin-user-exports-table.gjs b/app/assets/javascripts/admin/addon/components/admin-user-exports-table.gjs new file mode 100644 index 00000000000..48fe68132ff --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-user-exports-table.gjs @@ -0,0 +1,117 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { notEmpty } from "@ember/object/computed"; +import { service } from "@ember/service"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import DButton from "discourse/components/d-button"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse/lib/decorators"; +import { exportEntity } from "discourse/lib/export-csv"; +import { i18n } from "discourse-i18n"; +import UserExport from "admin/models/user-export"; + +const EXPORT_PROGRESS_CHANNEL = "/user-export-progress"; + +export default class extends Component { + @service dialog; + @service messageBus; + @service toasts; + + @tracked userExport = null; + @tracked userExportReloading = false; + + @notEmpty("userExport") userExportAvailable; + + constructor() { + super(...arguments); + this.messageBus.subscribe(EXPORT_PROGRESS_CHANNEL, this.onExportProgress); + + this.model = this.args.model; + this.userExport = UserExport.create(this.model.latest_export?.user_export); + } + + willDestroy() { + super.willDestroy(...arguments); + this.messageBus.unsubscribe(EXPORT_PROGRESS_CHANNEL, this.onExportProgress); + } + + @bind + onExportProgress(data) { + if (data.user_export_id === this.model.id) { + this.userExportReloading = false; + if (data.failed) { + this.dialog.alert(i18n("admin.user.exports.download.export_failed")); + } else { + this.userExport = UserExport.create(data.export_data.user_export); + this.toasts.success({ + autoClose: false, + data: { message: i18n("admin.user.exports.download.success") }, + }); + } + } + } + + @action + triggerUserExport() { + this.dialog.yesNoConfirm({ + message: i18n("admin.user.exports.download.confirm"), + didConfirm: () => { + this.userExportReloading = true; + try { + exportEntity("user_archive", { + export_user_id: this.model.id, + }); + + this.toasts.success({ + duration: 3000, + data: { message: i18n("admin.user.exports.download.started") }, + }); + } catch (err) { + popupAjaxError(err); + } + }, + }); + } + + get userExportExpiry() { + return i18n("admin.user.exports.download.expires_in", { + count: this.userExport.retain_hours, + }); + } + + +} diff --git a/app/assets/javascripts/admin/addon/models/user-export.js b/app/assets/javascripts/admin/addon/models/user-export.js new file mode 100644 index 00000000000..57782e6b326 --- /dev/null +++ b/app/assets/javascripts/admin/addon/models/user-export.js @@ -0,0 +1,13 @@ +import { ajax } from "discourse/lib/ajax"; +import RestModel from "discourse/models/rest"; + +export default class UserExport extends RestModel { + static async findLatest(user_id) { + const result = await ajax( + `/export_csv/latest_user_archive/${user_id}.json` + ); + if (result !== null) { + return UserExport.create(result.user_export); + } + } +} diff --git a/app/assets/javascripts/admin/addon/templates/user-index.hbs b/app/assets/javascripts/admin/addon/templates/user-index.hbs index bfffbd524f1..74ad7d1740f 100644 --- a/app/assets/javascripts/admin/addon/templates/user-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/user-index.hbs @@ -866,6 +866,10 @@ {{/if}} +{{#if this.currentUser.admin}} + +{{/if}} + response({ challenge: "123" }) ); + + pretender.get("/export_csv/latest_user_archive/:id.json", () => + response(null) + ); } export function resetPretender() { diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb index a053de20b30..706af6557b1 100644 --- a/app/controllers/export_csv_controller.rb +++ b/app/controllers/export_csv_controller.rb @@ -4,8 +4,9 @@ class ExportCsvController < ApplicationController skip_before_action :preload_json, :check_xhr, only: [:show] def export_entity - guardian.ensure_can_export_entity!(export_params[:entity]) entity = export_params[:entity] + entity_id = params.dig(:args, :export_user_id)&.to_i if entity == "user_archive" + guardian.ensure_can_export_entity!(entity, entity_id) raise Discourse::InvalidParameters.new(:entity) unless entity.is_a?(String) && entity.size < 100 (export_params[:args] || {}).each do |key, value| @@ -15,7 +16,13 @@ class ExportCsvController < ApplicationController end if entity == "user_archive" - Jobs.enqueue(:export_user_archive, user_id: current_user.id, args: export_params[:args]) + requesting_user_id = current_user.id if entity_id + Jobs.enqueue( + :export_user_archive, + user_id: entity_id || current_user.id, + requesting_user_id:, + args: export_params[:args], + ) else Jobs.enqueue( :export_csv_file, @@ -30,6 +37,19 @@ class ExportCsvController < ApplicationController render_json_error I18n.t("csv_export.rate_limit_error") end + def latest_user_archive + user_id = params[:user_id].to_i + # If we can't export the entity, we shouldn't be able to see it either + guardian.ensure_can_export_entity!("user_archive", user_id) + + render json: + UserExport + .where(user_id:) + .where("created_at > ?", UserExport::DESTROY_CREATED_BEFORE.ago) + .order(created_at: :desc) + .first + end + private def export_params diff --git a/app/jobs/onceoff/clean_up_user_export_topics.rb b/app/jobs/onceoff/clean_up_user_export_topics.rb index e6d62c87c5f..8d1a073e036 100644 --- a/app/jobs/onceoff/clean_up_user_export_topics.rb +++ b/app/jobs/onceoff/clean_up_user_export_topics.rb @@ -18,7 +18,7 @@ module Jobs # "[%{export_title}] 資料匯出已完成" gets converted to "%-topic", do not match that slug. slugs = slugs.reject { |s| s == "%-topic" } - topics = Topic.with_deleted.where(<<~SQL, slugs, UserExport::DESTROY_CREATED_BEFORE) + topics = Topic.with_deleted.where(<<~SQL, slugs, UserExport::DESTROY_CREATED_BEFORE.ago) slug LIKE ANY(ARRAY[?]) AND archetype = 'private_message' AND subtype = 'system_message' AND diff --git a/app/jobs/regular/export_user_archive.rb b/app/jobs/regular/export_user_archive.rb index 2bfa9018aa5..1944341164c 100644 --- a/app/jobs/regular/export_user_archive.rb +++ b/app/jobs/regular/export_user_archive.rb @@ -184,9 +184,9 @@ module Jobs begin # create upload - upload = create_upload_for_user(user_export, zip_filename) + create_upload_for_user(user_export, zip_filename) ensure - post = notify_user(upload, export_title) + post = notify_user(user_export, export_title) if user_export.present? && post.present? topic = post.topic @@ -623,19 +623,34 @@ module Jobs %w[composer_open_duration_msecs is_poll reply_to_post_number tags title typing_duration_msecs] end - def notify_user(upload, export_title) + def notify_user(export, export_title) post = nil if @requesting_user post = - if upload.persisted? + if export.upload&.persisted? + ::MessageBus.publish( + "/user-export-progress", + { + user_export_id: @archive_for_user.id, + export_data: UserExportSerializer.new(export, scope: guardian).as_json, + }, + user_ids: [@requesting_user.id], + ) + SystemMessage.create_from_system_user( @requesting_user, :csv_export_succeeded, - download_link: UploadMarkdown.new(upload).attachment_markdown, + download_link: UploadMarkdown.new(export.upload).attachment_markdown, export_title: export_title, ) else + ::MessageBus.publish( + "/user-export-progress", + { user_export_id: @archive_for_user.id, failed: true }, + user_ids: [@requesting_user.id], + ) + SystemMessage.create_from_system_user(@requesting_user, :csv_export_failed) end end diff --git a/app/models/user_export.rb b/app/models/user_export.rb index 32fcca5f75a..fed7843518c 100644 --- a/app/models/user_export.rb +++ b/app/models/user_export.rb @@ -13,11 +13,11 @@ class UserExport < ActiveRecord::Base end end - DESTROY_CREATED_BEFORE = 2.days.ago + DESTROY_CREATED_BEFORE = 2.days def self.remove_old_exports UserExport - .where("created_at < ?", DESTROY_CREATED_BEFORE) + .where("created_at < ?", DESTROY_CREATED_BEFORE.ago) .find_each do |user_export| UserExport.transaction do begin @@ -32,6 +32,10 @@ class UserExport < ActiveRecord::Base end end + def retain_hours + (created_at + DESTROY_CREATED_BEFORE - Time.zone.now).to_i / 1.hour + end + def self.base_directory File.join( Rails.root, diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index a47d3ab66ec..da260fed3c4 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -36,7 +36,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer :can_delete_sso_record, :api_key_count, :external_ids, - :similar_users_count + :similar_users_count, + :latest_export has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects @@ -167,4 +168,15 @@ class AdminDetailedUserSerializer < AdminUserSerializer def can_delete_sso_record scope.can_delete_sso_record?(object) end + + def latest_export + export = + UserExport + .where(user_id: object&.id) + .where("created_at > ?", UserExport::DESTROY_CREATED_BEFORE.ago) + .order(created_at: :desc) + .first + + UserExportSerializer.new(export, scope:).as_json if export + end end diff --git a/app/serializers/user_export_serializer.rb b/app/serializers/user_export_serializer.rb new file mode 100644 index 00000000000..5de24335c22 --- /dev/null +++ b/app/serializers/user_export_serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class UserExportSerializer < ApplicationSerializer + attributes :id, :filename, :uri, :filesize, :extension, :retain_hours, :human_filesize + + def filename + object.upload.original_filename + end + + def uri + object.upload.short_path + end + + def filesize + object.upload.filesize + end + + def extension + object.upload.extension + end + + def human_filesize + object.upload.human_filesize + end +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 7f3f5001b75..b0af5e8391a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -6963,6 +6963,19 @@ en: approve_bulk_success: "Success! All selected users have been approved and notified." time_read: "Read Time" post_edits_count: "Post Edits" + exports: + title: User exports + download: + description: Download latest export + expires_in: + one: Expires in %{count} hour + other: Expires in %{count} hours + not_available: No export available + button: Request archive + confirm: Do you really want to create an archive of this user's activity and preferences? + started: We've started collecting collecting the archive, the download link will update when the process is complete. + success: The archive is ready for download. + export_failed: We're sorry, but the export failed. Please check the logs for further information. anonymize: "Anonymize User" anonymize_confirm: "Are you sure you want to anonymize this account? This will change the username and email, and reset all profile information." delete_associated_accounts_confirm: "Are you sure you want to delete all associated accounts from this account? The user may not be able to log in if their email has changed." diff --git a/config/routes.rb b/config/routes.rb index a02f3df9be5..949b0c4a965 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1517,6 +1517,7 @@ Discourse::Application.routes.draw do post "/export_csv/export_entity" => "export_csv#export_entity", :as => "export_entity_export_csv_index" + get "/export_csv/latest_user_archive/:user_id.json" => "export_csv#latest_user_archive" get "onebox" => "onebox#show" get "inline-onebox" => "inline_onebox#show" diff --git a/lib/guardian.rb b/lib/guardian.rb index 27bbfe61ada..91d0783204a 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -536,14 +536,15 @@ class Guardian @user.in_any_groups?(SiteSetting.send_email_messages_allowed_groups_map) end - def can_export_entity?(entity) + def can_export_entity?(entity, entity_id = nil) return false if anonymous? return true if is_admin? return can_see_emails? if entity == "screened_email" - return entity != "user_list" if is_moderator? + return entity != "user_list" if is_moderator? && (entity != "user_archive" || entity_id.nil?) # Regular users can only export their archives return false unless entity == "user_archive" + return false unless entity_id == @user.id || entity_id.nil? UserExport.where( user_id: @user.id, created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day), diff --git a/spec/lib/guardian_spec.rb b/spec/lib/guardian_spec.rb index 5e8a6c102fd..57ac8738d88 100644 --- a/spec/lib/guardian_spec.rb +++ b/spec/lib/guardian_spec.rb @@ -3532,6 +3532,12 @@ RSpec.describe Guardian do it "does not allow anonymous to export" do expect(anonymous_guardian.can_export_entity?("user_archive")).to be_falsey end + + it "only allows admins to export user_archive of other users" do + expect(user_guardian.can_export_entity?("user_archive", another_user.id)).to be_falsey + expect(moderator_guardian.can_export_entity?("user_archive", another_user.id)).to be_falsey + expect(admin_guardian.can_export_entity?("user_archive", another_user.id)).to be_truthy + end end describe "#can_ignore_user?" do diff --git a/spec/models/user_export_spec.rb b/spec/models/user_export_spec.rb index 6b536c3f83f..ac358222c12 100644 --- a/spec/models/user_export_spec.rb +++ b/spec/models/user_export_spec.rb @@ -39,4 +39,22 @@ RSpec.describe UserExport do expect(Topic.exists?(id: topic_2.id)).to eq(true) end end + + describe "#retain_hours" do + it "should return the correct number of hours" do + csv_file_1 = Fabricate(:upload, created_at: 1.day.ago) + topic_1 = Fabricate(:topic, created_at: 1.day.ago) + Fabricate(:post, topic: topic_1) + export = + UserExport.create!( + file_name: "test", + user: user, + upload_id: csv_file_1.id, + topic_id: topic_1.id, + created_at: 1.day.ago, + ) + + expect(export.retain_hours).to eq(23) + end + end end diff --git a/spec/requests/api/schemas/json/admin_user_response.json b/spec/requests/api/schemas/json/admin_user_response.json index f32f1f96951..a796e8f2a46 100644 --- a/spec/requests/api/schemas/json/admin_user_response.json +++ b/spec/requests/api/schemas/json/admin_user_response.json @@ -134,6 +134,9 @@ "full_suspend_reason": { "type": ["string", "null"] }, + "latest_export": { + "type": ["object", "null"] + }, "silence_reason": { "type": ["string", "null"] }, diff --git a/spec/requests/export_csv_controller_spec.rb b/spec/requests/export_csv_controller_spec.rb index b54f90452ae..e3a5ad39ed7 100644 --- a/spec/requests/export_csv_controller_spec.rb +++ b/spec/requests/export_csv_controller_spec.rb @@ -3,6 +3,7 @@ RSpec.describe ExportCsvController do context "while logged in as normal user" do fab!(:user) + fab!(:user2) { Fabricate(:user) } before { sign_in(user) } describe "#export_entity" do @@ -28,6 +29,18 @@ RSpec.describe ExportCsvController do expect(Jobs::ExportCsvFile.jobs.size).to eq(0) end + it "does not allow a normal user to export another user's archive" do + post "/export_csv/export_entity.json", + params: { + entity: "user_archive", + args: { + export_user_id: user2.id, + }, + } + expect(response.status).to eq(422) + expect(Jobs::ExportUserArchive.jobs.size).to eq(0) + end + it "correctly logs the entity export" do post "/export_csv/export_entity.json", params: { entity: "user_archive" } @@ -37,9 +50,32 @@ RSpec.describe ExportCsvController do expect(log_entry.subject).to eq("user_archive") end end + + describe "#latest_user_archive" do + it "returns the latest user archive" do + export = generate_exports(user) + + get "/export_csv/latest_user_archive/#{user.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["user_export"]["id"]).to eq(export.id) + end + + it "returns nothing when the user has no archives" do + get "/export_csv/latest_user_archive/#{user.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body).to eq(nil) + end + + it "does not allow a normal user to view another user's archive" do + generate_exports(user2) + get "/export_csv/latest_user_archive/#{user2.id}.json" + expect(response.status).to eq(403) + end + end end context "while logged in as an admin" do + fab!(:user) fab!(:admin) before { sign_in(admin) } @@ -65,6 +101,21 @@ RSpec.describe ExportCsvController do expect(job_data["user_id"]).to eq(admin.id) end + it "allows user archives for other users" do + post "/export_csv/export_entity.json", + params: { + entity: "user_archive", + args: { + export_user_id: user.id, + }, + } + expect(response.status).to eq(200) + expect(Jobs::ExportUserArchive.jobs.size).to eq(1) + + job_data = Jobs::ExportUserArchive.jobs.first["args"].first + expect(job_data["user_id"]).to eq(user.id) + end + it "correctly logs the entity export" do post "/export_csv/export_entity.json", params: { entity: "user_list" } @@ -84,9 +135,19 @@ RSpec.describe ExportCsvController do expect(response.status).to eq(400) end end + + describe "#latest_user_archive" do + it "allows an admin to view another user's archive" do + export = generate_exports(user) + get "/export_csv/latest_user_archive/#{user.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["user_export"]["id"]).to eq(export.id) + end + end end context "while logged in as a moderator" do + fab!(:user) fab!(:moderator) before { sign_in(moderator) } @@ -114,6 +175,18 @@ RSpec.describe ExportCsvController do expect(job_data["user_id"]).to eq(moderator.id) end + it "does not allow moderators to export another user's archive" do + post "/export_csv/export_entity.json", + params: { + entity: "user_archive", + args: { + export_user_id: user.id, + }, + } + expect(response.status).to eq(422) + expect(Jobs::ExportUserArchive.jobs.size).to eq(0) + end + it "allows moderator to export other entities" do post "/export_csv/export_entity.json", params: { entity: "staff_action" } expect(response.status).to eq(200) @@ -124,5 +197,36 @@ RSpec.describe ExportCsvController do expect(job_data["user_id"]).to eq(moderator.id) end end + + describe "#latest_user_archive" do + it "does not allow a moderator to view another user's archive" do + generate_exports(user) + get "/export_csv/latest_user_archive/#{user.id}.json" + expect(response.status).to eq(403) + end + end + end + + def generate_exports(user) + csv_file_1 = Fabricate(:upload, created_at: 1.day.ago) + topic_1 = Fabricate(:topic, created_at: 1.day.ago) + Fabricate(:post, topic: topic_1) + UserExport.create!( + file_name: "test", + user: user, + upload_id: csv_file_1.id, + topic_id: topic_1.id, + created_at: 1.day.ago, + ) + + csv_file_2 = Fabricate(:upload, created_at: 12.hours.ago) + topic_2 = Fabricate(:topic, created_at: 12.hours.ago) + UserExport.create!( + file_name: "test2", + user: user, + upload_id: csv_file_2.id, + topic_id: topic_2.id, + created_at: 12.hours.ago, + ) end end diff --git a/spec/serializers/user_export_serializer_spec.rb b/spec/serializers/user_export_serializer_spec.rb new file mode 100644 index 00000000000..c2c02e9dcbe --- /dev/null +++ b/spec/serializers/user_export_serializer_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe UserExportSerializer do + subject(:serializer) { UserExportSerializer.new(user_export, root: false) } + + fab!(:user_export) do + user = Fabricate(:user) + csv_file_1 = Fabricate(:upload, created_at: 1.day.ago) + topic_1 = Fabricate(:topic, created_at: 1.day.ago) + Fabricate(:post, topic: topic_1) + UserExport.create!( + file_name: "test", + user: user, + upload_id: csv_file_1.id, + topic_id: topic_1.id, + created_at: 1.day.ago, + ) + end + + it "should render without errors" do + json_data = JSON.parse(serializer.to_json) + + expect(json_data["id"]).to eql user_export.id + expect(json_data["filename"]).to eql user_export.upload.original_filename + expect(json_data["uri"]).to eql user_export.upload.short_path + expect(json_data["filesize"]).to eql user_export.upload.filesize + expect(json_data["extension"]).to eql user_export.upload.extension + expect(json_data["retain_hours"]).to eql user_export.retain_hours + expect(json_data["human_filesize"]).to eql user_export.upload.human_filesize + end +end