diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 index 745a172d2..1d6a08359 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 @@ -1,4 +1,4 @@ -import { outputExportResult } from 'admin/lib/export-result'; +import { outputExportResult } from 'discourse/lib/export-result'; export default Ember.ArrayController.extend(Discourse.Presence, { loading: false, diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 index b06c74d04..0427fe0fb 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 @@ -1,4 +1,4 @@ -import { outputExportResult } from 'admin/lib/export-result'; +import { outputExportResult } from 'discourse/lib/export-result'; export default Ember.ArrayController.extend(Discourse.Presence, { loading: false, diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 index 49637494c..153a38337 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 @@ -1,4 +1,4 @@ -import { outputExportResult } from 'admin/lib/export-result'; +import { outputExportResult } from 'discourse/lib/export-result'; export default Ember.ArrayController.extend(Discourse.Presence, { loading: false, diff --git a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 index 9d0437493..c4ecd3d14 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 @@ -6,7 +6,7 @@ @namespace Discourse @module Discourse **/ -import { outputExportResult } from 'admin/lib/export-result'; +import { outputExportResult } from 'discourse/lib/export-result'; export default Ember.ArrayController.extend(Discourse.Presence, { loading: false, diff --git a/app/assets/javascripts/admin/routes/admin-users-list.js.es6 b/app/assets/javascripts/admin/routes/admin-users-list.js.es6 index 1932799c6..1fcc89095 100644 --- a/app/assets/javascripts/admin/routes/admin-users-list.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-users-list.js.es6 @@ -1,4 +1,4 @@ -import { outputExportResult } from 'admin/lib/export-result'; +import { outputExportResult } from 'discourse/lib/export-result'; export default Discourse.Route.extend({ diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 5fb5b5bb8..bf2b7d21b 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -1,5 +1,6 @@ import ObjectController from 'discourse/controllers/object'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; +import { outputExportResult } from 'discourse/lib/export-result'; export default ObjectController.extend(CanCheckEmails, { indexStream: false, @@ -51,6 +52,10 @@ export default ObjectController.extend(CanCheckEmails, { Discourse.AdminUser.find(this.get('username').toLowerCase()).then(function(user){ user.destroy({deletePosts: true}); }); + }, + + exportUserArchive: function() { + Discourse.ExportCsv.exportUserArchive().then(outputExportResult); } } }); diff --git a/app/assets/javascripts/admin/lib/export-result.js.es6 b/app/assets/javascripts/discourse/lib/export-result.js.es6 similarity index 100% rename from app/assets/javascripts/admin/lib/export-result.js.es6 rename to app/assets/javascripts/discourse/lib/export-result.js.es6 diff --git a/app/assets/javascripts/admin/models/export_csv.js b/app/assets/javascripts/discourse/models/export_csv.js similarity index 50% rename from app/assets/javascripts/admin/models/export_csv.js rename to app/assets/javascripts/discourse/models/export_csv.js index 082ddeb00..a2f559968 100644 --- a/app/assets/javascripts/admin/models/export_csv.js +++ b/app/assets/javascripts/discourse/models/export_csv.js @@ -9,13 +9,22 @@ Discourse.ExportCsv = Discourse.Model.extend({}); Discourse.ExportCsv.reopenClass({ + /** + Exports user archive + + @method export_user_archive + **/ + exportUserArchive: function() { + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'user', entity: 'user_archive'}}); + }, + /** Exports user list @method export_user_list **/ exportUserList: function() { - return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'user'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user'}}); }, /** @@ -24,7 +33,7 @@ Discourse.ExportCsv.reopenClass({ @method export_staff_action_logs **/ exportStaffActionLogs: function() { - return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'staff_action'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'staff_action'}}); }, /** @@ -33,7 +42,7 @@ Discourse.ExportCsv.reopenClass({ @method export_screened_email_list **/ exportScreenedEmailList: function() { - return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_email'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_email'}}); }, /** @@ -42,7 +51,7 @@ Discourse.ExportCsv.reopenClass({ @method export_screened_ip_list **/ exportScreenedIpList: function() { - return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_ip'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_ip'}}); }, /** @@ -51,6 +60,6 @@ Discourse.ExportCsv.reopenClass({ @method export_screened_url_list **/ exportScreenedUrlList: function() { - return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_url'}}); + return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_url'}}); } }); diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs index 1d5731b3d..524a0232f 100644 --- a/app/assets/javascripts/discourse/templates/user/user.hbs +++ b/app/assets/javascripts/discourse/templates/user/user.hbs @@ -186,6 +186,12 @@ {{/if}} + + {{#if viewingSelf}} +
+ +
+ {{/if}}
diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 920c91263..63f9997d0 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -56,6 +56,7 @@ //= require ./discourse/helpers/cold-age-class //= require ./discourse/helpers/loading-spinner //= require ./discourse/helpers/category-link +//= require ./discourse/lib/export-result //= require ./discourse/dialects/dialect //= require ./discourse/lib/emoji/emoji @@ -69,4 +70,3 @@ //= require_tree ./discourse/templates //= require_tree ./discourse/routes //= require_tree ./discourse/initializers - diff --git a/app/assets/javascripts/main_include_admin.js b/app/assets/javascripts/main_include_admin.js index 00050ed3d..dd77d21b0 100644 --- a/app/assets/javascripts/main_include_admin.js +++ b/app/assets/javascripts/main_include_admin.js @@ -1,8 +1,8 @@ //= require list-view -//= require admin/lib/export-result //= require admin/models/user-field //= require admin/controllers/admin-email-skipped //= require admin/controllers/change-site-customization-details +//= require discourse/lib/export-result //= require_tree ./admin //= require resumable.js diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 41e6923ab..1eea26e44 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -139,6 +139,11 @@ } } + .user-archive { + margin-top: 10px; + margin-bottom: 10px; + } + .user-right.groups { margin-top: 0; } diff --git a/app/controllers/admin/export_csv_controller.rb b/app/controllers/admin/export_csv_controller.rb deleted file mode 100644 index 0ab4aefe0..000000000 --- a/app/controllers/admin/export_csv_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -class Admin::ExportCsvController < Admin::AdminController - - skip_before_filter :check_xhr, only: [:show] - - def export_entity - params.require(:entity) - # export csv file in a background thread - Jobs.enqueue(:export_csv_file, entity: params[:entity], user_id: current_user.id) - render json: success_json - end - - # download - def show - filename = params.fetch(:id) - if export_csv_path = ExportCsv.get_download_path(filename) - send_file export_csv_path - else - render nothing: true, status: 404 - end - end - -end diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb new file mode 100644 index 000000000..b10840540 --- /dev/null +++ b/app/controllers/export_csv_controller.rb @@ -0,0 +1,32 @@ +class ExportCsvController < ApplicationController + + skip_before_filter :check_xhr, only: [:show] + + def export_entity + params.require(:entity) + params.require(:entity_type) + if params[:entity_type] == "admin" + guardian.ensure_can_export_admin_entity!(current_user) + end + + Jobs.enqueue(:export_csv_file, entity: params[:entity], user_id: current_user.id) + render json: success_json + end + + # download + def show + params.require(:id) + filename = params.fetch(:id) + export_id = filename.split('_')[1].split('.')[0] + export_initiated_by_user_id = 0 + export_initiated_by_user_id = CsvExportLog.where(id: export_id)[0].user_id unless CsvExportLog.where(id: export_id).empty? + export_csv_path = CsvExportLog.get_download_path(filename) + + if export_csv_path && export_initiated_by_user_id == current_user.id + send_file export_csv_path + else + render nothing: true, status: 404 + end + end + +end diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index f8c2a2f79..c0daa07bd 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -5,6 +5,7 @@ module Jobs class ExportCsvFile < Jobs::Base HEADER_ATTRS_FOR = {} + HEADER_ATTRS_FOR['user_archive'] = ['raw','like_count','reply_count','created_at'] HEADER_ATTRS_FOR['user'] = ['id','name','username','email','title','created_at','trust_level','active','admin','moderator','ip_address'] HEADER_ATTRS_FOR['user_stats'] = ['topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received'] HEADER_ATTRS_FOR['user_sso'] = ['external_id','external_email', 'external_username', 'external_name', 'external_avatar_url'] @@ -18,10 +19,16 @@ module Jobs def initialize @file_name = "" + @entity_type = "admin" end def execute(args) entity = args[:entity] + + if entity == "user_archive" + @entity_type = "user" + end + @current_user = User.find_by(id: args[:user_id]) export_method = "#{entity}_export".to_sym @@ -41,6 +48,13 @@ module Jobs notify_user end + def user_archive_export + user_archive_data = Post.where(user_id: @current_user.id).select(HEADER_ATTRS_FOR['user_archive']).with_deleted.to_a + user_archive_data.map do |user_archive| + get_user_archive_fields(user_archive) + end + end + def user_export query = ::AdminUserIndexQuery.new user_data = query.find_users_query.to_a @@ -113,6 +127,16 @@ module Jobs return group_names end + def get_user_archive_fields(user_archive) + user_archive_array = [] + + HEADER_ATTRS_FOR['user_archive'].each do |attr| + user_archive_array.push(user_archive.attributes[attr]) + end + + user_archive_array + end + def get_user_fields(user) user_array = [] @@ -215,15 +239,17 @@ module Jobs def set_file_path - @file_name = "export_#{SecureRandom.hex(4)}.csv" + @file = CsvExportLog.create(export_type: @entity_type, user_id: @current_user.id) + @file_name = "export_#{@file.id}.csv" + # ensure directory exists - dir = File.dirname("#{ExportCsv.base_directory}/#{@file_name}") + dir = File.dirname("#{CsvExportLog.base_directory}/#{@file_name}") FileUtils.mkdir_p(dir) unless Dir.exists?(dir) end def write_csv_file(data, header) # write to CSV file - CSV.open(File.expand_path("#{ExportCsv.base_directory}/#{@file_name}", __FILE__), "w") do |csv| + CSV.open(File.expand_path("#{CsvExportLog.base_directory}/#{@file_name}", __FILE__), "w") do |csv| csv << header data.each do |value| csv << value @@ -233,8 +259,8 @@ module Jobs def notify_user if @current_user - if @file_name != "" && File.exists?("#{ExportCsv.base_directory}/#{@file_name}") - SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/admin/export_csv/#{@file_name}", file_name: @file_name) + if @file_name != "" && File.exists?("#{CsvExportLog.base_directory}/#{@file_name}") + SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/export_csv/#{@file_name}", file_name: @file_name) else SystemMessage.create_from_system_user(@current_user, :csv_export_failed) end diff --git a/app/jobs/scheduled/clean_up_exports.rb b/app/jobs/scheduled/clean_up_exports.rb index 27469d10a..e61a96bd0 100644 --- a/app/jobs/scheduled/clean_up_exports.rb +++ b/app/jobs/scheduled/clean_up_exports.rb @@ -3,7 +3,7 @@ module Jobs every 2.day def execute(args) - ExportCsv.remove_old_exports # delete exported CSV files older than 2 days + CsvExportLog.remove_old_exports # delete exported CSV files older than 2 days end end end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 514c1ba4b..9145ce437 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -31,6 +31,7 @@ end # created_at :datetime not null # updated_at :datetime not null # allowed_ips :inet is an Array +# hidden :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/csv_export_log.rb b/app/models/csv_export_log.rb new file mode 100644 index 000000000..c16dfe022 --- /dev/null +++ b/app/models/csv_export_log.rb @@ -0,0 +1,40 @@ +class CsvExportLog < ActiveRecord::Base + + def self.get_download_path(filename) + path = File.join(CsvExportLog.base_directory, filename) + if File.exists?(path) + return path + else + nil + end + end + + def self.remove_old_exports + expired_exports = CsvExportLog.where('created_at < ?', 2.days.ago).to_a + expired_exports.map do |expired_export| + file_name = "export_#{expired_export.id}.csv" + file_path = "#{CsvExportLog.base_directory}/#{file_name}" + + if File.exist?(file_path) + File.delete(file_path) + end + CsvExportLog.find(expired_export.id).destroy + end + end + + def self.base_directory + File.join(Rails.root, "public", "uploads", "csv_exports", RailsMultisite::ConnectionManagement.current_db) + end + +end + +# == Schema Information +# +# Table name: csv_export_logs +# +# id :integer not null, primary key +# export_type :string(255) not null +# user_id :integer not null +# created_at :datetime +# updated_at :datetime +# diff --git a/app/models/export_csv.rb b/app/models/export_csv.rb deleted file mode 100644 index fcbae3705..000000000 --- a/app/models/export_csv.rb +++ /dev/null @@ -1,29 +0,0 @@ -class ExportCsv - - def self.get_download_path(filename) - path = File.join(ExportCsv.base_directory, filename) - if File.exists?(path) - return path - else - nil - end - end - - def self.remove_old_exports - if Dir.exists?(ExportCsv.base_directory) - Dir.foreach(ExportCsv.base_directory) do |file| - path = File.join(ExportCsv.base_directory, file) - next if File.directory? path - - if (File.mtime(path) < 2.days.ago) - File.delete(path) - end - end - end - end - - def self.base_directory - File.join(Rails.root, "public", "uploads", "csv_exports", RailsMultisite::ConnectionManagement.current_db) - end - -end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 33c117ab6..df0a64b66 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -292,7 +292,7 @@ en: profile: "Profile" mute: "Mute" edit: "Edit Preferences" - download_archive: "download archive of my posts" + download_archive: "Download archive of my posts" new_private_message: "New Private Message" private_message: "Private Message" private_messages: "Messages" diff --git a/config/routes.rb b/config/routes.rb index 308bbf3cf..81a8d29ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -164,15 +164,6 @@ Discourse::Application.routes.draw do end end - resources :export_csv, constraints: AdminConstraint.new do - collection do - get "export_entity" => "export_csv#export_entity" - end - member do - get "" => "export_csv#show", constraints: { id: /[^\/]+/ } - end - end - resources :badges, constraints: AdminConstraint.new do collection do get "types" => "badges#badge_types" @@ -441,6 +432,15 @@ Discourse::Application.routes.draw do get "invites/redeem/:token" => "invites#redeem_disposable_invite" delete "invites" => "invites#destroy" + resources :export_csv do + collection do + get "export_entity" => "export_csv#export_entity" + end + member do + get "" => "export_csv#show", constraints: { id: /[^\/]+/ } + end + end + get "onebox" => "onebox#show" get "error" => "forums#error" diff --git a/db/migrate/20141223145058_create_csv_export_logs.rb b/db/migrate/20141223145058_create_csv_export_logs.rb new file mode 100644 index 000000000..83291db28 --- /dev/null +++ b/db/migrate/20141223145058_create_csv_export_logs.rb @@ -0,0 +1,9 @@ +class CreateCsvExportLogs < ActiveRecord::Migration + def change + create_table :csv_export_logs do |t| + t.string :export_type, null: false + t.integer :user_id, null: false + t.timestamps + end + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb index f0998e057..97643ff3a 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -249,6 +249,10 @@ class Guardian @can_see_emails end + def can_export_admin_entity?(user) + user.staff? + end + private def is_my_own?(obj) diff --git a/spec/controllers/admin/export_csv_controller_spec.rb b/spec/controllers/admin/export_csv_controller_spec.rb deleted file mode 100644 index 644deb6d9..000000000 --- a/spec/controllers/admin/export_csv_controller_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require "spec_helper" - -describe Admin::ExportCsvController do - - it "is a subclass of AdminController" do - (Admin::ExportCsvController < Admin::AdminController).should == true - end - - let(:export_filename) { "export_b6a2bc87.csv" } - - context "while logged in as an admin" do - - before { @admin = log_in(:admin) } - - describe ".download" do - - it "uses send_file to transmit the export file" do - controller.stubs(:render) - export = ExportCsv.new() - ExportCsv.expects(:get_download_path).with(export_filename).returns(export) - subject.expects(:send_file).with(export) - get :show, id: export_filename - end - - it "returns 404 when the export file does not exist" do - ExportCsv.expects(:get_download_path).returns(nil) - get :show, id: export_filename - response.should be_not_found - end - - end - - end - -end diff --git a/spec/controllers/export_csv_controller_spec.rb b/spec/controllers/export_csv_controller_spec.rb new file mode 100644 index 000000000..4de76e21c --- /dev/null +++ b/spec/controllers/export_csv_controller_spec.rb @@ -0,0 +1,80 @@ +require "spec_helper" + +describe ExportCsvController do + let(:export_filename) { "export_999.csv" } + + + context "while logged in as normal user" do + before { @user = log_in(:user) } + + describe ".export_entity" do + it "enqueues export job" do + Jobs.expects(:enqueue).with(:export_csv_file, has_entries(entity: "user_archive", user_id: @user.id)) + xhr :post, :export_entity, entity: "user_archive", entity_type: "user" + response.should be_success + end + + it "returns 404 when normal user tries to export admin entity" do + xhr :post, :export_entity, entity: "staff_action", entity_type: "admin" + response.should_not be_success + end + end + + describe ".download" do + it "uses send_file to transmit the export file" do + file = CsvExportLog.create(export_type: "user", user_id: @user.id) + file_name = "export_#{file.id}.csv" + controller.stubs(:render) + export = CsvExportLog.new() + CsvExportLog.expects(:get_download_path).with(file_name).returns(export) + subject.expects(:send_file).with(export) + get :show, id: file_name + response.should be_success + end + + it "returns 404 when the user tries to export another user's csv file" do + get :show, id: export_filename + response.should be_not_found + end + + it "returns 404 when the export file does not exist" do + CsvExportLog.expects(:get_download_path).returns(nil) + get :show, id: export_filename + response.should be_not_found + end + end + end + + + context "while logged in as an admin" do + before { @admin = log_in(:admin) } + + describe ".export_entity" do + it "enqueues export job" do + Jobs.expects(:enqueue).with(:export_csv_file, has_entries(entity: "staff_action", user_id: @admin.id)) + xhr :post, :export_entity, entity: "staff_action", entity_type: "admin" + response.should be_success + end + end + + describe ".download" do + it "uses send_file to transmit the export file" do + file = CsvExportLog.create(export_type: "admin", user_id: @admin.id) + file_name = "export_#{file.id}.csv" + controller.stubs(:render) + export = CsvExportLog.new() + CsvExportLog.expects(:get_download_path).with(file_name).returns(export) + subject.expects(:send_file).with(export) + get :show, id: file_name + response.should be_success + end + + it "returns 404 when the export file does not exist" do + CsvExportLog.expects(:get_download_path).returns(nil) + get :show, id: export_filename + response.should be_not_found + end + end + end + +end