From 233bf9bc24139fdc48476a9ea9765435fe33a4b9 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 2 Sep 2015 21:28:50 +0200 Subject: [PATCH 001/133] Always use locale fallback on server --- config/initializers/i18n.rb | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb index 43b7299572..616d519bfc 100644 --- a/config/initializers/i18n.rb +++ b/config/initializers/i18n.rb @@ -22,16 +22,5 @@ class FallbackLocaleList < Hash end end -class NoFallbackLocaleList < FallbackLocaleList - def [](locale) - [locale] - end -end - - -if Rails.env.development? - I18n.fallbacks = NoFallbackLocaleList.new -else - I18n.fallbacks = FallbackLocaleList.new - I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) } -end +I18n.fallbacks = FallbackLocaleList.new +I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) } From d30f454261a21963dc43e21a214b87210ca6e489 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 11:41:22 +0800 Subject: [PATCH 002/133] FEATURE: Create UserProfilerView. --- app/models/user_profile_view.rb | 5 +++++ .../20150914021445_create_user_profile_views.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 app/models/user_profile_view.rb create mode 100644 db/migrate/20150914021445_create_user_profile_views.rb diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb new file mode 100644 index 0000000000..327da54530 --- /dev/null +++ b/app/models/user_profile_view.rb @@ -0,0 +1,5 @@ +class UserProfileView < ActiveRecord::Base + validates :user_profile_id, presence: true + validates :viewed_at, presence: true + validates :ip_address, presence: true +end diff --git a/db/migrate/20150914021445_create_user_profile_views.rb b/db/migrate/20150914021445_create_user_profile_views.rb new file mode 100644 index 0000000000..04c085a53b --- /dev/null +++ b/db/migrate/20150914021445_create_user_profile_views.rb @@ -0,0 +1,15 @@ +class CreateUserProfileViews < ActiveRecord::Migration + def change + create_table :user_profile_views do |t| + t.integer :user_profile_id, null: false + t.datetime :viewed_at, null: false + t.inet :ip_address, null: false + t.integer :user_id + end + + add_index :user_profile_views, :user_profile_id + add_index :user_profile_views, :user_id + add_index :user_profile_views, [:viewed_at, :ip_address, :user_profile_id], where: "user_id IS NULL", unique: true, name: 'unique_profile_view_ip' + add_index :user_profile_views, [:viewed_at, :user_id, :user_profile_id], where: "user_id IS NOT NULL", unique: true, name: 'unique_profile_view_user' + end +end From f41bcafe8dbe4d70efbf927d737635641244972e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 11:50:59 +0800 Subject: [PATCH 003/133] FEATURE: Add views to UserProfile. --- app/models/user_profile.rb | 1 + db/migrate/20150914034541_add_views_to_user_profile.rb | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 db/migrate/20150914034541_add_views_to_user_profile.rb diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 2941b92233..aaa5d1f699 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -112,6 +112,7 @@ end # badge_granted_title :boolean default(FALSE) # card_background :string(255) # card_image_badge_id :integer +# views :integer default(0), not null # # Indexes # diff --git a/db/migrate/20150914034541_add_views_to_user_profile.rb b/db/migrate/20150914034541_add_views_to_user_profile.rb new file mode 100644 index 0000000000..3a13f6c136 --- /dev/null +++ b/db/migrate/20150914034541_add_views_to_user_profile.rb @@ -0,0 +1,5 @@ +class AddViewsToUserProfile < ActiveRecord::Migration + def change + add_column :user_profiles, :views, :integer, default: 0, null: false + end +end From 7acc93b2a09c897b3a3e778535fa4ce3b1356106 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 15:51:17 +0800 Subject: [PATCH 004/133] FEATURE: Track user profile views. --- .../discourse/controllers/user-card.js.es6 | 1 + .../discourse/templates/user/user.hbs | 1 + app/controllers/users_controller.rb | 14 ++++++ app/models/user_profile.rb | 1 + app/models/user_profile_view.rb | 43 +++++++++++++++++-- app/serializers/user_serializer.rb | 7 ++- config/locales/server.en.yml | 1 + config/site_settings.yml | 1 + spec/controllers/users_controller_spec.rb | 26 ++++++++++- spec/models/user_profile_view_spec.rb | 39 +++++++++++++++++ 10 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 spec/models/user_profile_view_spec.rb diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6 index a7ade2d7f4..ce2e065a89 100644 --- a/app/assets/javascripts/discourse/controllers/user-card.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-card.js.es6 @@ -67,6 +67,7 @@ export default Ember.Controller.extend({ const args = { stats: false }; args.include_post_count_for = this.get('controllers.topic.model.id'); + args.skip_track_visit = true; return Discourse.User.findByUsername(username, args).then((user) => { if (user.topic_post_count) { diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs index 28634a17f6..a1f74c5dda 100644 --- a/app/assets/javascripts/discourse/templates/user/user.hbs +++ b/app/assets/javascripts/discourse/templates/user/user.hbs @@ -120,6 +120,7 @@ {{#if model.last_seen_at}}
{{i18n 'user.last_seen'}}
{{bound-date model.last_seen_at}}
{{/if}} +
{{i18n 'views'}}
{{model.profile_view_count}}
{{#if model.invited_by}}
{{i18n 'user.invited_by'}}
{{#link-to 'user' model.invited_by}}{{model.invited_by.username}}{{/link-to}}
{{/if}} diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bb361de6fd..a7bc554e17 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -39,6 +39,10 @@ class UsersController < ApplicationController user_serializer.topic_post_count = {topic_id => Post.where(topic_id: topic_id, user_id: @user.id).count } end + if !params[:skip_track_visit] && (@user != current_user) + track_visit_to_user_profile + end + # This is a hack to get around a Rails issue where values with periods aren't handled correctly # when used as part of a route. if params[:external_id] and params[:external_id].ends_with? '.json' @@ -641,4 +645,14 @@ class UsersController < ApplicationController render json: { success: false, message: I18n.t(key) } end + def track_visit_to_user_profile + user_profile_id = @user.user_profile.id + ip = request.remote_ip + user_id = (current_user.id if current_user) + + Scheduler::Defer.later 'Track profile view visit' do + UserProfileView.add(user_profile_id, ip, user_id) + end + end + end diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index aaa5d1f699..14aed1333d 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -7,6 +7,7 @@ class UserProfile < ActiveRecord::Base after_save :trigger_badges belongs_to :card_image_badge, class_name: 'Badge' + has_many :user_profile_views, dependent: :destroy BAKED_VERSION = 1 diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb index 327da54530..935737173e 100644 --- a/app/models/user_profile_view.rb +++ b/app/models/user_profile_view.rb @@ -1,5 +1,42 @@ class UserProfileView < ActiveRecord::Base - validates :user_profile_id, presence: true - validates :viewed_at, presence: true - validates :ip_address, presence: true + validates_presence_of :user_profile_id, :ip_address, :viewed_at + + belongs_to :user_profile + + def self.add(user_profile_id, ip, user_id=nil, at=nil, skip_redis=false) + at ||= Time.zone.now + redis_key = "user-profile-view:#{user_profile_id}:#{at.to_date}" + if user_id + redis_key << ":user-#{user_id}" + else + redis_key << ":ip-#{ip}" + end + + if skip_redis || $redis.setnx(redis_key, '1') + skip_redis || $redis.expire(redis_key, SiteSetting.user_profile_view_duration_hours.hours) + + self.transaction do + sql = "INSERT INTO user_profile_views (user_profile_id, ip_address, viewed_at, user_id) + SELECT :user_profile_id, :ip_address, :viewed_at, :user_id + WHERE NOT EXISTS ( + SELECT 1 FROM user_profile_views + /*where*/ + )" + + builder = SqlBuilder.new(sql) + + if !user_id + builder.where("viewed_at = :viewed_at AND ip_address = :ip_address AND user_profile_id = :user_profile_id AND user_id IS NULL") + else + builder.where("viewed_at = :viewed_at AND user_id = :user_id AND user_profile_id = :user_profile_id") + end + + result = builder.exec(user_profile_id: user_profile_id, ip_address: ip, viewed_at: at, user_id: user_id) + + if result.cmd_tuples > 0 + UserProfile.find(user_profile_id).increment!(:views) + end + end + end + end end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 334b20e2d2..550d5654d3 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -65,7 +65,8 @@ class UserSerializer < BasicUserSerializer :custom_fields, :user_fields, :topic_post_count, - :pending_count + :pending_count, + :profile_view_count has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer @@ -346,4 +347,8 @@ class UserSerializer < BasicUserSerializer 0 end + def profile_view_count + object.user_profile.views + end + end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d4859e8290..73654e2e49 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1072,6 +1072,7 @@ en: white_listed_spam_host_domains: "A list of domains excluded from spam host testing. New users will never be restricted from creating posts with links to these domains." staff_like_weight: "How much extra weighting factor to give staff likes." topic_view_duration_hours: "Count a new topic view once per IP/User every N hours" + user_profile_view_duration_hours: "Count a new user profile view once per IP/User every N hours" levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match." max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP." diff --git a/config/site_settings.yml b/config/site_settings.yml index efa9b24908..304c59fb91 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -853,6 +853,7 @@ uncategorized: previous_visit_timeout_hours: 1 staff_like_weight: 3 topic_view_duration_hours: 8 + user_profile_view_duration_hours: 8 # Summary mode summary_score_threshold: 15 diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 884f3f3ff2..3c4b7abb1f 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe UsersController do describe '.show' do - let!(:user) { log_in } + let(:user) { log_in } it 'returns success' do xhr :get, :show, username: user.username, format: :json @@ -31,6 +31,30 @@ describe UsersController do expect(response).to be_forbidden end + describe "user profile views" do + let(:other_user) { Fabricate(:user) } + + it "should track a user profile view for a signed in user" do + UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, user.id) + xhr :get, :show, username: other_user.username + end + + it "should not track a user profile view for a user viewing his own profile" do + UserProfileView.expects(:add).never + xhr :get, :show, username: user.username + end + + it "should track a user profile view for an anon user" do + UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, nil) + xhr :get, :show, username: other_user.username + end + + it "skips tracking" do + UserProfileView.expects(:add).never + xhr :get, :show, { username: user.username, skip_track_visit: true } + end + end + context "fetching a user by external_id" do before { user.create_single_sign_on_record(external_id: '997', last_payload: '') } diff --git a/spec/models/user_profile_view_spec.rb b/spec/models/user_profile_view_spec.rb new file mode 100644 index 0000000000..456cfe16f1 --- /dev/null +++ b/spec/models/user_profile_view_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +RSpec.describe UserProfileView do + let(:user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + let(:user_profile_id) { user.user_profile.id } + + def add(user_profile_id, ip, user_id=nil, at=nil) + described_class.add(user_profile_id, ip, user_id, at, true) + end + + it "should increase user's profile view count" do + expect{ add(user_profile_id, '1.1.1.1') }.to change{ described_class.count }.by(1) + expect(user.user_profile.reload.views).to eq(1) + expect{ add(user_profile_id, '1.1.1.1', other_user.id) }.to change{ described_class.count }.by(1) + + user_profile = user.user_profile.reload + expect(user_profile.views).to eq(2) + expect(user_profile.user_profile_views).to eq(described_class.all) + end + + it "should not create duplicated profile view for anon user" do + time = Time.zone.now + + 2.times do + add(user_profile_id, '1.1.1.1', nil, time) + expect(described_class.count).to eq(1) + end + end + + it "should not create duplicated profile view for signed in user" do + time = Time.zone.now + + ['1.1.1.1', '2.2.2.2'].each do |ip| + add(user_profile_id, ip, other_user.id, time) + expect(described_class.count).to eq(1) + end + end +end From 21725cc90752b8c033cabce5de427a9ccba4b7a5 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 15 Sep 2015 01:30:06 +0800 Subject: [PATCH 005/133] FEATURE: Admin dashboard data for user profile views. --- app/models/admin_dashboard_data.rb | 1 + app/models/report.rb | 8 ++++++++ app/models/user_profile_view.rb | 5 +++++ config/locales/server.en.yml | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb index 4253523763..33e34d9ffa 100644 --- a/app/models/admin_dashboard_data.rb +++ b/app/models/admin_dashboard_data.rb @@ -6,6 +6,7 @@ class AdminDashboardData GLOBAL_REPORTS ||= [ 'visits', 'signups', + 'profile_views', 'topics', 'posts', 'time_to_first_response', diff --git a/app/models/report.rb b/app/models/report.rb index 204bc3e9bc..812d47271c 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -98,6 +98,14 @@ class Report report_about report, User.real, :count_by_signup_date end + def self.report_profile_views(report) + start_date = report.start_date.to_date + end_date = report.end_date.to_date + basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date + report.total = UserProfile.sum(:views) + report.prev30Days = UserProfileView.where("viewed_at >= ? AND viewed_at < ?", start_date - 30.days, start_date + 1).count + end + def self.report_topics(report) basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id countable = Topic.listable_topics diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb index 935737173e..e3ae1ca728 100644 --- a/app/models/user_profile_view.rb +++ b/app/models/user_profile_view.rb @@ -39,4 +39,9 @@ class UserProfileView < ActiveRecord::Base end end end + + def self.profile_views_by_day(start_date, end_date) + profile_views = self.where("viewed_at >= ? AND viewed_at < ?", start_date, end_date + 1.day) + profile_views.group("date(viewed_at)").order("date(viewed_at)").count + end end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 73654e2e49..f41c27f58b 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -575,6 +575,10 @@ en: title: "New Users" xaxis: "Day" yaxis: "Number of new users" + profile_views: + title: "User Profile Views" + xaxis: "Day" + yaxis: "Number of user profiles viewed" topics: title: "Topics" xaxis: "Day" From 25c7450ea7cc080f56be65eef1a9db28a7aa4057 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 Sep 2015 10:55:06 +0800 Subject: [PATCH 006/133] Use existing function to extract error message. --- .../discourse/controllers/edit-category.js.es6 | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 index bfda086b64..0810068b47 100644 --- a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 @@ -1,5 +1,6 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import DiscourseURL from 'discourse/lib/url'; +import { extractError } from 'discourse/lib/ajax-error'; // Modal for editing / creating a category export default Ember.Controller.extend(ModalFunctionality, { @@ -73,11 +74,7 @@ export default Ember.Controller.extend(ModalFunctionality, { model.setProperties({slug: result.category.slug, id: result.category.id }); DiscourseURL.redirectTo("/c/" + Discourse.Category.slugFor(model)); }).catch(function(error) { - if (error && error.responseText) { - self.flash($.parseJSON(error.responseText).errors[0], 'error'); - } else { - self.flash(I18n.t('generic_error'), 'error'); - } + self.flash(extractError(error), 'error'); self.set('saving', false); }); }, @@ -94,13 +91,7 @@ export default Ember.Controller.extend(ModalFunctionality, { self.send('closeModal'); DiscourseURL.redirectTo("/categories"); }, function(error){ - - if (error && error.responseText) { - self.flash($.parseJSON(error.responseText).errors[0]); - } else { - self.flash(I18n.t('generic_error')); - } - + self.flash(extractError(error), 'error'); self.send('reopenModal'); self.displayErrors([I18n.t("category.delete_error")]); self.set('deleting', false); From c29b7ce498d7f1a4e622a8d3819339b47801f2ae Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 Sep 2015 10:58:31 +0800 Subject: [PATCH 007/133] FIX: Set saving to false about model has been saved. --- .../javascripts/discourse/controllers/edit-category.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 index 0810068b47..d77783bc0a 100644 --- a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 @@ -68,8 +68,8 @@ export default Ember.Controller.extend(ModalFunctionality, { this.set('saving', true); model.set('parentCategory', parentCategory); - self.set('saving', false); this.get('model').save().then(function(result) { + self.set('saving', false); self.send('closeModal'); model.setProperties({slug: result.category.slug, id: result.category.id }); DiscourseURL.redirectTo("/c/" + Discourse.Category.slugFor(model)); From f39b9124b659f579645eb60754fe81121221da38 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 Sep 2015 15:51:32 +0800 Subject: [PATCH 008/133] FEATURE: Log staff actions for Category changes. --- .../admin/models/staff_action_log.js | 3 +- app/controllers/categories_controller.rb | 32 +++++++- app/models/category.rb | 8 ++ app/models/category_group.rb | 2 + app/models/user_history.rb | 13 +++- app/serializers/user_history_serializer.rb | 1 + app/services/staff_action_logger.rb | 62 +++++++++++++++ config/locales/client.en.yml | 4 + ...71017_add_category_id_to_user_histories.rb | 6 ++ .../controllers/categories_controller_spec.rb | 13 ++++ spec/fabricators/category_group_fabricator.rb | 5 ++ spec/models/category_spec.rb | 9 +++ spec/services/staff_action_logger_spec.rb | 77 +++++++++++++++++++ 13 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20150917071017_add_category_id_to_user_histories.rb create mode 100644 spec/fabricators/category_group_fabricator.rb diff --git a/app/assets/javascripts/admin/models/staff_action_log.js b/app/assets/javascripts/admin/models/staff_action_log.js index dc52a7b170..0dde773661 100644 --- a/app/assets/javascripts/admin/models/staff_action_log.js +++ b/app/assets/javascripts/admin/models/staff_action_log.js @@ -11,6 +11,7 @@ Discourse.StaffActionLog = Discourse.Model.extend({ formatted += this.format('admin.logs.ip_address', 'ip_address'); formatted += this.format('admin.logs.topic_id', 'topic_id'); formatted += this.format('admin.logs.post_id', 'post_id'); + formatted += this.format('admin.logs.category_id', 'category_id'); if (!this.get('useCustomModalForDetails')) { formatted += this.format('admin.logs.staff_actions.new_value', 'new_value'); formatted += this.format('admin.logs.staff_actions.previous_value', 'previous_value'); @@ -19,7 +20,7 @@ Discourse.StaffActionLog = Discourse.Model.extend({ if (this.get('details')) formatted += Handlebars.Utils.escapeExpression(this.get('details')) + '
'; } return formatted; - }.property('ip_address', 'email', 'topic_id', 'post_id'), + }.property('ip_address', 'email', 'topic_id', 'post_id', 'category_id'), format: function(label, propertyName) { if (this.get(propertyName)) { diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b63ec503db..52437468d3 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -4,6 +4,7 @@ class CategoriesController < ApplicationController before_filter :ensure_logged_in, except: [:index, :show, :redirect] before_filter :fetch_category, only: [:show, :update, :destroy] + before_filter :initialize_staff_action_logger, only: [:create, :update, :destroy] skip_before_filter :check_xhr, only: [:index, :redirect] def redirect @@ -81,10 +82,18 @@ class CategoriesController < ApplicationController position = category_params.delete(:position) @category = Category.create(category_params.merge(user: current_user)) - return render_json_error(@category) unless @category.save - @category.move_to(position.to_i) if position - render_serialized(@category, CategorySerializer) + if @category.save + @category.move_to(position.to_i) if position + + Scheduler::Defer.later "Log staff action create category" do + @staff_action_logger.log_category_creation(@category) + end + + render_serialized(@category, CategorySerializer) + else + return render_json_error(@category) unless @category.save + end end def update @@ -103,8 +112,15 @@ class CategoriesController < ApplicationController end category_params.delete(:position) + old_permissions = Category.find(@category.id).permissions_params - cat.update_attributes(category_params) + if result = cat.update_attributes(category_params) + Scheduler::Defer.later "Log staff action change category settings" do + @staff_action_logger.log_category_settings_change(@category, category_params, old_permissions) + end + end + + result end end @@ -133,6 +149,10 @@ class CategoriesController < ApplicationController guardian.ensure_can_delete!(@category) @category.destroy + Scheduler::Defer.later "Log staff action delete category" do + @staff_action_logger.log_category_deletion(@category) + end + render json: success_json end @@ -175,4 +195,8 @@ class CategoriesController < ApplicationController def fetch_category @category = Category.find_by(slug: params[:id]) || Category.find_by(id: params[:id].to_i) end + + def initialize_staff_action_logger + @staff_action_logger = StaffActionLogger.new(current_user) + end end diff --git a/app/models/category.rb b/app/models/category.rb index 7e550aef28..68eed7090d 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -283,6 +283,14 @@ SQL set_permissions(permissions) end + def permissions_params + hash = {} + category_groups.includes(:group).each do |category_group| + hash[category_group.group_name] = category_group.permission_type + end + hash + end + def apply_permissions if @permissions category_groups.destroy_all diff --git a/app/models/category_group.rb b/app/models/category_group.rb index fa9fd3a21e..849fcba026 100644 --- a/app/models/category_group.rb +++ b/app/models/category_group.rb @@ -2,6 +2,8 @@ class CategoryGroup < ActiveRecord::Base belongs_to :category belongs_to :group + delegate :name, to: :group, prefix: true + def self.permission_types @permission_types ||= Enum.new(:full, :create_post, :readonly) end diff --git a/app/models/user_history.rb b/app/models/user_history.rb index 77867f7d46..038df0e9c2 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -7,6 +7,7 @@ class UserHistory < ActiveRecord::Base belongs_to :post belongs_to :topic + belongs_to :category validates_presence_of :action @@ -39,7 +40,10 @@ class UserHistory < ActiveRecord::Base :custom, :custom_staff, :anonymize_user, - :reviewed_post) + :reviewed_post, + :change_category_settings, + :delete_category, + :create_category) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. @@ -61,7 +65,10 @@ class UserHistory < ActiveRecord::Base :change_username, :custom_staff, :anonymize_user, - :reviewed_post] + :reviewed_post, + :change_category_settings, + :delete_category, + :create_category] end def self.staff_action_ids @@ -144,11 +151,13 @@ end # admin_only :boolean default(FALSE) # post_id :integer # custom_type :string(255) +# category_id :integer # # Indexes # # index_user_histories_on_acting_user_id_and_action_and_id (acting_user_id,action,id) # index_user_histories_on_action_and_id (action,id) +# index_user_histories_on_category_id (category_id) # index_user_histories_on_subject_and_id (subject,id) # index_user_histories_on_target_user_id_and_id (target_user_id,id) # diff --git a/app/serializers/user_history_serializer.rb b/app/serializers/user_history_serializer.rb index 2af3c7dc9b..039e25c61d 100644 --- a/app/serializers/user_history_serializer.rb +++ b/app/serializers/user_history_serializer.rb @@ -10,6 +10,7 @@ class UserHistorySerializer < ApplicationSerializer :new_value, :topic_id, :post_id, + :category_id, :action, :custom_type diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index cd681eea92..cbb56693a6 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -210,6 +210,64 @@ class StaffActionLogger })) end + def log_category_settings_change(category, category_params, old_permissions=nil) + validate_category(category) + + changed_attributes = category.previous_changes.slice(*category_params.keys) + + if old_permissions != category_params[:permissions] + changed_attributes.merge!({ permissions: [old_permissions.to_json, category_params[:permissions].to_json] }) + end + + changed_attributes.each do |key, value| + UserHistory.create(params.merge({ + action: UserHistory.actions[:change_category_settings], + category_id: category.id, + context: category.url, + subject: key, + previous_value: value[0], + new_value: value[1] + })) + end + end + + def log_category_deletion(category) + validate_category(category) + + details = [ + "created_at: #{category.created_at}", + "name: #{category.name}", + "permissions: #{category.permissions_params}" + ] + + if parent_category = category.parent_category + details << "parent_category: #{parent_category.name}" + end + + UserHistory.create(params.merge({ + action: UserHistory.actions[:delete_category], + category_id: category.id, + details: details.join("\n"), + context: category.url + })) + end + + def log_category_creation(category) + validate_category(category) + + details = [ + "created_at: #{category.created_at}", + "name: #{category.name}" + ] + + UserHistory.create(params.merge({ + action: UserHistory.actions[:create_category], + details: details.join("\n"), + category_id: category.id, + context: category.url + })) + end + private def params(opts=nil) @@ -217,4 +275,8 @@ class StaffActionLogger { acting_user_id: @admin.id, context: opts[:context] } end + def validate_category(category) + raise Discourse::InvalidParameters.new(:category) unless category && category.is_a?(Category) + end + end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 63f225130e..389764b1d1 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2163,6 +2163,7 @@ en: ip_address: "IP" topic_id: "Topic ID" post_id: "Post ID" + category_id: "Category ID" delete: 'Delete' edit: 'Edit' save: 'Save' @@ -2203,6 +2204,9 @@ en: impersonate: "impersonate" anonymize_user: "anonymize user" roll_up: "roll up IP blocks" + change_category_settings: "change category settings" + delete_category: "delete category" + create_category: "create category" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." diff --git a/db/migrate/20150917071017_add_category_id_to_user_histories.rb b/db/migrate/20150917071017_add_category_id_to_user_histories.rb new file mode 100644 index 0000000000..117edef54a --- /dev/null +++ b/db/migrate/20150917071017_add_category_id_to_user_histories.rb @@ -0,0 +1,6 @@ +class AddCategoryIdToUserHistories < ActiveRecord::Migration + def change + add_column :user_histories, :category_id, :integer + add_index :user_histories, :category_id + end +end diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb index f03cfd05e8..e545faedda 100644 --- a/spec/controllers/categories_controller_spec.rb +++ b/spec/controllers/categories_controller_spec.rb @@ -64,6 +64,7 @@ describe CategoriesController do expect(category.slug).to eq("hello-cat") expect(category.color).to eq("ff0") expect(category.auto_close_hours).to eq(72) + expect(UserHistory.count).to eq(1) end end end @@ -90,6 +91,7 @@ describe CategoriesController do it "deletes the record" do Guardian.any_instance.expects(:can_delete_category?).returns(true) expect { xhr :delete, :destroy, id: @category.slug}.to change(Category, :count).by(-1) + expect(UserHistory.count).to eq(1) end end @@ -215,6 +217,17 @@ describe CategoriesController do expect(@category.auto_close_hours).to eq(72) expect(@category.custom_fields).to eq({"dancing" => "frogs"}) end + + it 'logs the changes correctly' do + xhr :put , :update, id: @category.id, name: 'new name', + color: @category.color, text_color: @category.text_color, + slug: @category.slug, + permissions: { + "everyone" => CategoryGroup.permission_types[:create_post] + } + + expect(UserHistory.count).to eq(2) + end end end diff --git a/spec/fabricators/category_group_fabricator.rb b/spec/fabricators/category_group_fabricator.rb new file mode 100644 index 0000000000..898825b80e --- /dev/null +++ b/spec/fabricators/category_group_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:category_group) do + category + group + permission_type 1 +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index a00529fb21..8fdf5952af 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -36,6 +36,15 @@ describe Category do end end + describe "permissions_params" do + it "returns the right group names and permission type" do + category = Fabricate(:category) + group = Fabricate(:group) + category_group = Fabricate(:category_group, category: category, group: group) + expect(category.permissions_params).to eq({ "#{group.name}" => category_group.permission_type }) + end + end + describe "topic_create_allowed and post_create_allowed" do it "works" do diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb index cb7d6e1758..00eb85eee2 100644 --- a/spec/services/staff_action_logger_spec.rb +++ b/spec/services/staff_action_logger_spec.rb @@ -271,4 +271,81 @@ describe StaffActionLogger do expect(logged.topic_id).to be === 1234 end end + + describe 'log_category_settings_change' do + let(:category) { Fabricate(:category, name: 'haha') } + let(:category_group) { Fabricate(:category_group, category: category, permission_type: 1) } + + it "raises an error when category is missing" do + expect { logger.log_category_settings_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) + end + + it "creates new UserHistory records" do + attributes = { + name: 'new_name', + permissions: { category_group.group_name => 2 } + } + + category.update!(attributes) + + logger.log_category_settings_change(category, attributes, + { category_group.group_name => category_group.permission_type } + ) + + expect(UserHistory.count).to eq(2) + + permission_user_history = UserHistory.find_by_subject('permissions') + expect(permission_user_history.category_id).to eq(category.id) + expect(permission_user_history.previous_value).to eq({ category_group.group_name => 1 }.to_json) + expect(permission_user_history.new_value).to eq({ category_group.group_name => 2 }.to_json) + expect(permission_user_history.action).to eq(UserHistory.actions[:change_category_settings]) + expect(permission_user_history.context).to eq(category.url) + + name_user_history = UserHistory.find_by_subject('name') + expect(name_user_history.category).to eq(category) + expect(name_user_history.previous_value).to eq('haha') + expect(name_user_history.new_value).to eq('new_name') + end + end + + describe 'log_category_deletion' do + let(:parent_category) { Fabricate(:category) } + let(:category) { Fabricate(:category, parent_category: parent_category) } + + it "raises an error when category is missing" do + expect { logger.log_category_deletion(nil) }.to raise_error(Discourse::InvalidParameters) + end + + it "creates a new UserHistory record" do + logger.log_category_deletion(category) + + expect(UserHistory.count).to eq(1) + user_history = UserHistory.last + + expect(user_history.subject).to eq(nil) + expect(user_history.category).to eq(category) + expect(user_history.details).to include("parent_category: #{parent_category.name}") + expect(user_history.context).to eq(category.url) + expect(user_history.action).to eq(UserHistory.actions[:delete_category]) + end + end + + describe 'log_category_creation' do + let(:category) { Fabricate(:category) } + + it "raises an error when category is missing" do + expect { logger.log_category_deletion(nil) }.to raise_error(Discourse::InvalidParameters) + end + + it "creates a new UserHistory record" do + logger.log_category_creation(category) + + expect(UserHistory.count).to eq(1) + user_history = UserHistory.last + + expect(user_history.category).to eq(category) + expect(user_history.context).to eq(category.url) + expect(user_history.action).to eq(UserHistory.actions[:create_category]) + end + end end From 519a50e3a557b7fc48cdf86514d17e9bcefb00c9 Mon Sep 17 00:00:00 2001 From: getabetterpic Date: Fri, 18 Sep 2015 19:25:53 -0400 Subject: [PATCH 009/133] Fix bug where create topic is suggested when insufficient permissions When a user is viewing a category they do not have permission to create a topic in, they are still prompted to create a topic if they are at the bottom of the topics. This fixes the issue, as well as disabling the New Topic button if they don't have permission to create a topic for that category. --- .../discourse/routes/build-category-route.js.es6 | 14 +++++++++++--- .../discourse/templates/discovery/topics.hbs | 2 +- .../discourse/templates/navigation/category.hbs | 7 ++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 index e3d2e89a8b..b0dfc941ca 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -63,9 +63,15 @@ export default (filter, params) => { setupController(controller, model) { const topics = this.get('topics'), - periodId = topics.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''); + periodId = topics.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''), + canCreateTopic = topics.get('can_create_topic'), + canCreateTopicOnCategory = model.get('permission') === Discourse.PermissionType.FULL; - this.controllerFor('navigation/category').set('canCreateTopic', topics.get('can_create_topic')); + this.controllerFor('navigation/category').setProperties({ + canCreateTopicOnCategory: canCreateTopicOnCategory, + cannotCreateTopicOnCategory: !canCreateTopicOnCategory, + canCreateTopic: canCreateTopic + }); this.controllerFor('discovery/topics').setProperties({ model: topics, category: model, @@ -74,7 +80,9 @@ export default (filter, params) => { noSubcategories: params && !!params.no_subcategories, order: topics.get('params.order'), ascending: topics.get('params.ascending'), - expandAllPinned: true + expandAllPinned: true, + canCreateTopic: canCreateTopic, + canCreateTopicOnCategory: canCreateTopicOnCategory }); this.searchService.set('searchContext', model.get('searchContext')); diff --git a/app/assets/javascripts/discourse/templates/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/discovery/topics.hbs index f3e452a4ab..9ed3a83d17 100644 --- a/app/assets/javascripts/discourse/templates/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/discovery/topics.hbs @@ -67,7 +67,7 @@

{{footerMessage}} - {{#if model.can_create_topic}}{{i18n 'topic.suggest_create_topic'}}{{/if}} + {{#if canCreateTopicOnCategory}}{{i18n 'topic.suggest_create_topic'}}{{/if}}

{{else}} {{#if top}} diff --git a/app/assets/javascripts/discourse/templates/navigation/category.hbs b/app/assets/javascripts/discourse/templates/navigation/category.hbs index 7d874edf17..428abe827b 100644 --- a/app/assets/javascripts/discourse/templates/navigation/category.hbs +++ b/app/assets/javascripts/discourse/templates/navigation/category.hbs @@ -11,7 +11,12 @@ {{/if}} {{#if canCreateTopic}} - {{d-button id="create-topic" class="btn-default" action="createTopic" icon="plus" label="topic.create"}} + {{d-button id="create-topic" + class="btn-default" + action="createTopic" + icon="plus" + label="topic.create" + disabled=cannotCreateTopicOnCategory}} {{/if}} {{#if canEditCategory}} From 4cb070f28c83ee7de523275598868349444a18c1 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 19 Sep 2015 22:41:48 +0200 Subject: [PATCH 010/133] FIX: Use translated badge names for slugs FIX: Use configured slug generater method for badges --- .../discourse/routes/badges-show.js.es6 | 2 +- app/serializers/badge_serializer.rb | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/routes/badges-show.js.es6 b/app/assets/javascripts/discourse/routes/badges-show.js.es6 index 5f835d76d0..42bc89e9a2 100644 --- a/app/assets/javascripts/discourse/routes/badges-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/badges-show.js.es6 @@ -12,7 +12,7 @@ export default Discourse.Route.extend({ serialize(model) { return { id: model.get("id"), - slug: model.get("name").replace(/[^A-Za-z0-9_]+/g, "-").toLowerCase() + slug: model.get("slug") }; }, diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb index 415404d5cd..8223ecd785 100644 --- a/app/serializers/badge_serializer.rb +++ b/app/serializers/badge_serializer.rb @@ -1,7 +1,7 @@ class BadgeSerializer < ApplicationSerializer attributes :id, :name, :description, :grant_count, :allow_title, :multiple_grant, :icon, :image, :listable, :enabled, :badge_grouping_id, - :system, :long_description + :system, :long_description, :slug has_one :badge_type @@ -17,7 +17,7 @@ class BadgeSerializer < ApplicationSerializer if object.long_description.present? object.long_description else - key = "badges.long_descriptions.#{object.name.downcase.gsub(" ", "_")}" + key = "badges.long_descriptions.#{i18n_name}" if I18n.exists?(key) I18n.t(key) else @@ -25,4 +25,19 @@ class BadgeSerializer < ApplicationSerializer end end end + + def slug + Slug.for(display_name, '') + end + + private + + def i18n_name + object.name.downcase.gsub(' ', '_') + end + + def display_name + key = "admin_js.badges.badge.#{i18n_name}.name" + I18n.t(key, default: object.name) + end end From 71eab8f4df5bc65af8bc7df97e28e7eecf419304 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Sun, 20 Sep 2015 23:00:30 +0800 Subject: [PATCH 011/133] FEATURE: Add web manifest for Chrome users. --- app/controllers/manifest_json_controller.rb | 15 +++++++++++++++ app/views/layouts/application.html.erb | 1 + config/routes.rb | 1 + spec/controllers/manifest_json_controller_spec.rb | 12 ++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 app/controllers/manifest_json_controller.rb create mode 100644 spec/controllers/manifest_json_controller_spec.rb diff --git a/app/controllers/manifest_json_controller.rb b/app/controllers/manifest_json_controller.rb new file mode 100644 index 0000000000..5462ec1fd6 --- /dev/null +++ b/app/controllers/manifest_json_controller.rb @@ -0,0 +1,15 @@ +class ManifestJsonController < ApplicationController + layout false + skip_before_filter :preload_json, :check_xhr + + def index + manifest = { + short_name: SiteSetting.title, + display: 'browser', + orientation: 'portrait', + start_url: "#{Discourse.base_uri}/" + } + + render json: manifest.to_json + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8a29518367..65cf09b535 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -31,6 +31,7 @@ <%- end %> <%= render_google_universal_analytics_code %> + <%= yield :head %> diff --git a/config/routes.rb b/config/routes.rb index 66125570f4..75d3dcf7cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -533,6 +533,7 @@ Discourse::Application.routes.draw do get "favicon/proxied" => "static#favicon", format: false get "robots.txt" => "robots_txt#index" + get "manifest.json" => "manifest_json#index", as: :manifest Discourse.filters.each do |filter| root to: "list##{filter}", constraints: HomePageConstraint.new("#{filter}"), :as => "list_#{filter}" diff --git a/spec/controllers/manifest_json_controller_spec.rb b/spec/controllers/manifest_json_controller_spec.rb new file mode 100644 index 0000000000..41df4e221c --- /dev/null +++ b/spec/controllers/manifest_json_controller_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +RSpec.describe ManifestJsonController do + context 'index' do + it 'returns the right output' do + title = 'MyApp' + SiteSetting.title = title + get :index + expect(response.body).to include(title) + end + end +end From bcb070f1cade11f3a1f4895f5d69bdb250c98292 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Tue, 22 Sep 2015 16:36:15 -0700 Subject: [PATCH 012/133] FIX: progress bar popup was misaligned --- app/assets/stylesheets/desktop/topic.scss | 3 +-- app/assets/stylesheets/mobile/topic.scss | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index c3929fd250..49364cba63 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -123,11 +123,10 @@ a:hover.reply-new { border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); padding: 5px; background: $secondary; - @include box-shadow(0 0px 2px rgba(0,0,0, .2)); position: relative; left: 345px; - width: 133px; + width: 135px; padding: 5px; button.full { diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 9e0d6e2164..bd0b706504 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -64,11 +64,10 @@ border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); padding: 5px; background: $secondary; - box-shadow: 0 0px 2px rgba(0,0,0, .2); position: absolute; bottom: 34px; - width: 133px; + width: 135px; button.full { width: 100%; From a61765b9e4dfb73442151833a9d76dee36391403 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 13:13:34 +1000 Subject: [PATCH 013/133] PERF: improve perf of initial payload also reduce querying in topic query --- app/models/site.rb | 11 +++++++++-- lib/guardian/category_guardian.rb | 7 +++++-- lib/topic_query.rb | 5 +++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index c6a3313f17..6c4c05dfd8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -41,7 +41,7 @@ class Site @categories ||= begin categories = Category .secured(@guardian) - .includes(:topic_only_relative_url, :subcategories) + .includes(:topic_only_relative_url) .order(:position) unless SiteSetting.allow_uncategorized_topics @@ -50,6 +50,13 @@ class Site categories = categories.to_a + with_children = Set.new + categories.each do |c| + if c.parent_category_id + with_children << c.parent_category_id + end + end + allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) by_id = {} @@ -62,7 +69,7 @@ class Site categories.each do |category| category.notification_level = category_user[category.id] category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create.include?(category.id) - category.has_children = category.subcategories.present? + category.has_children = with_children.include?(category.id) by_id[category.id] = category end diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index 63bbd5be79..c24c652b61 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -54,8 +54,11 @@ module CategoryGuardian # all allowed category ids def allowed_category_ids - unrestricted = Category.where(read_restricted: false).pluck(:id) - unrestricted.concat(secure_category_ids) + @allowed_category_ids ||= + begin + unrestricted = Category.where(read_restricted: false).pluck(:id) + unrestricted.concat(secure_category_ids) + end end def topic_create_allowed_category_ids diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 73d47fd4ad..434fdbb0ee 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -46,6 +46,7 @@ class TopicQuery options.assert_valid_keys(VALID_OPTIONS) @options = options.dup @user = user + @guardian = Guardian.new(@user) end def joined_topic_user(list=nil) @@ -359,7 +360,7 @@ class TopicQuery when 'unlisted' result = result.where('NOT topics.visible') when 'deleted' - guardian = Guardian.new(@user) + guardian = @guardian if guardian.is_staff? result = result.where('topics.deleted_at IS NOT NULL') require_deleted_clause = false @@ -391,7 +392,7 @@ class TopicQuery result = result.where('topics.posts_count <= ?', options[:max_posts]) if options[:max_posts].present? result = result.where('topics.posts_count >= ?', options[:min_posts]) if options[:min_posts].present? - Guardian.new(@user).filter_allowed_categories(result) + @guardian.filter_allowed_categories(result) end def remove_muted_categories(list, user, opts=nil) From 613761d1cddcd9e96c418748f0055e6136154bae Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 15:24:30 +1000 Subject: [PATCH 014/133] FEATURE: upgrade to Rails 4.2.4 --- .travis.yml | 3 - Gemfile | 19 +---- Gemfile.lock | 106 ++++++++++++++---------- app/models/post.rb | 2 +- app/models/report.rb | 2 +- app/models/topic.rb | 2 +- config/application.rb | 5 +- config/environments/production.rb | 2 +- config/environments/profile.rb | 2 +- config/environments/test.rb | 2 +- lib/email.rb | 9 +- spec/models/user_email_observer_spec.rb | 43 ++++++---- 12 files changed, 103 insertions(+), 94 deletions(-) diff --git a/.travis.yml b/.travis.yml index 62961abc4a..6fdc835355 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ env: - RUBY_GC_MALLOC_LIMIT=50000000 matrix: - "RAILS_MASTER=0" - - "RAILS42=1" - "RAILS_MASTER=1" addons: @@ -21,7 +20,6 @@ addons: matrix: allow_failures: - env: "RAILS_MASTER=1" - - env: "RAILS42=1" - rvm: rbx-2 fast_finish: true @@ -51,7 +49,6 @@ before_script: - bundle exec rake db:create db:migrate install: - - bash -c "if [ '$RAILS42' == '1' ]; then bundle update --retry=3 --jobs=3 rails rails-observers; fi" - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails rails-observers seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" diff --git a/Gemfile b/Gemfile index 2332689189..1476657d6f 100644 --- a/Gemfile +++ b/Gemfile @@ -6,30 +6,19 @@ def rails_master? ENV["RAILS_MASTER"] == '1' end -def rails_42? - ENV["RAILS42"] == '1' -end - if rails_master? gem 'arel', git: 'https://github.com/rails/arel.git' gem 'rails', git: 'https://github.com/rails/rails.git' gem 'rails-observers', git: 'https://github.com/rails/rails-observers.git' gem 'seed-fu', git: 'https://github.com/SamSaffron/seed-fu.git', branch: 'discourse' -elsif rails_42? - gem 'rails', '~> 4.2.1' - gem 'rails-observers', git: 'https://github.com/rails/rails-observers.git' - gem 'seed-fu', '~> 2.3.5' else - gem 'rails', '~> 4.1.10' + gem 'rails', '~> 4.2' gem 'rails-observers' - gem 'seed-fu', '~> 2.3.3' + gem 'seed-fu', '~> 2.3.5' end -# Rails 4.1.6+ will relax the mail gem version requirement to `~> 2.5, >= 2.5.4`. -# However, mail gem 2.6.x currently does not work with discourse because of the -# reference to `Mail::RFC2822Parser` in `lib/email.rb`. This ensure discourse -# would continue to work with Rails 4.1.6+ when it is released. -gem 'mail', '~> 2.5.4' +gem 'mail' +gem 'mime-types', require: 'mime/types/columnar' #gem 'redis-rails' gem 'hiredis' diff --git a/Gemfile.lock b/Gemfile.lock index 46f0d552f3..3c7f5cba96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,38 +6,47 @@ PATH GEM remote: https://rubygems.org/ specs: - actionmailer (4.1.10) - actionpack (= 4.1.10) - actionview (= 4.1.10) + actionmailer (4.2.4) + actionpack (= 4.2.4) + actionview (= 4.2.4) + activejob (= 4.2.4) mail (~> 2.5, >= 2.5.4) - actionpack (4.1.10) - actionview (= 4.1.10) - activesupport (= 4.1.10) - rack (~> 1.5.2) + rails-dom-testing (~> 1.0, >= 1.0.5) + actionpack (4.2.4) + actionview (= 4.2.4) + activesupport (= 4.2.4) + rack (~> 1.6) rack-test (~> 0.6.2) - actionview (4.1.10) - activesupport (= 4.1.10) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (4.2.4) + activesupport (= 4.2.4) builder (~> 3.1) erubis (~> 2.7.0) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) active_model_serializers (0.8.3) activemodel (>= 3.0) - activemodel (4.1.10) - activesupport (= 4.1.10) + activejob (4.2.4) + activesupport (= 4.2.4) + globalid (>= 0.3.0) + activemodel (4.2.4) + activesupport (= 4.2.4) builder (~> 3.1) - activerecord (4.1.10) - activemodel (= 4.1.10) - activesupport (= 4.1.10) - arel (~> 5.0.0) - activesupport (4.1.10) - i18n (~> 0.6, >= 0.6.9) + activerecord (4.2.4) + activemodel (= 4.2.4) + activesupport (= 4.2.4) + arel (~> 6.0) + activesupport (4.2.4) + i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) - thread_safe (~> 0.1) + thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) annotate (2.6.6) activerecord (>= 2.3.0) rake (~> 10.4.2, >= 10.4.2) - arel (5.0.1.20140414130214) + arel (6.0.3) aws-sdk (2.0.45) aws-sdk-resources (= 2.0.45) aws-sdk-core (2.0.45) @@ -118,6 +127,8 @@ GEM gctools (0.2.3) given_core (3.5.4) sorcerer (>= 0.3.7) + globalid (0.3.6) + activesupport (>= 4.1.0) guess_html_encoding (0.0.11) handlebars-source (2.0.0) hashie (3.4.0) @@ -149,19 +160,20 @@ GEM libv8 (3.16.14.7) listen (0.7.3) logster (1.0.0.3.pre) + loofah (2.0.3) + nokogiri (>= 1.5.9) lru_redux (1.1.0) - mail (2.5.4) - mime-types (~> 1.16) - treetop (~> 1.4.8) + mail (2.6.3) + mime-types (>= 1.16, < 3) memory_profiler (0.9.3) message_bus (1.0.16) rack (>= 1.1.3) redis metaclass (0.0.4) method_source (0.8.2) - mime-types (1.25.1) + mime-types (2.6.2) mini_portile (0.6.2) - minitest (5.6.1) + minitest (5.8.0) mocha (1.1.0) metaclass (~> 0.0.1) mock_redis (0.14.0) @@ -218,7 +230,6 @@ GEM redis ruby-openid pg (0.18.1) - polyglot (0.3.5) progress (3.1.0) pry (0.10.1) coderay (~> 1.1.0) @@ -231,7 +242,7 @@ GEM puma (2.11.1) rack (>= 1.1, < 2.0) r2 (0.2.5) - rack (1.5.5) + rack (1.6.4) rack-mini-profiler (0.9.6) rack (>= 1.1.3) rack-openid (1.3.1) @@ -241,21 +252,30 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.1.10) - actionmailer (= 4.1.10) - actionpack (= 4.1.10) - actionview (= 4.1.10) - activemodel (= 4.1.10) - activerecord (= 4.1.10) - activesupport (= 4.1.10) + rails (4.2.4) + actionmailer (= 4.2.4) + actionpack (= 4.2.4) + actionview (= 4.2.4) + activejob (= 4.2.4) + activemodel (= 4.2.4) + activerecord (= 4.2.4) + activesupport (= 4.2.4) bundler (>= 1.3.0, < 2.0) - railties (= 4.1.10) - sprockets-rails (~> 2.0) + railties (= 4.2.4) + sprockets-rails + rails-deprecated_sanitizer (1.0.3) + activesupport (>= 4.2.0.alpha) + rails-dom-testing (1.0.7) + activesupport (>= 4.2.0.beta, < 5.0) + nokogiri (~> 1.6.0) + rails-deprecated_sanitizer (>= 1.0.1) + rails-html-sanitizer (1.0.2) + loofah (~> 2.0) rails-observers (0.1.2) activemodel (~> 4.0) - railties (4.1.10) - actionpack (= 4.1.10) - activesupport (= 4.1.10) + railties (4.2.4) + actionpack (= 4.2.4) + activesupport (= 4.2.4) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) raindrops (0.13.0) @@ -374,9 +394,6 @@ GEM timecop (0.7.3) timers (4.0.1) hitimes - treetop (1.4.15) - polyglot - polyglot (>= 0.3.1) trollop (2.1.1) tzinfo (1.2.2) thread_safe (~> 0.1) @@ -427,9 +444,10 @@ DEPENDENCIES listen (= 0.7.3) logster lru_redux - mail (~> 2.5.4) + mail memory_profiler message_bus + mime-types minitest mocha mock_redis @@ -453,7 +471,7 @@ DEPENDENCIES r2 (~> 0.2.5) rack-mini-profiler rack-protection - rails (~> 4.1.10) + rails (~> 4.2) rails-observers rails_multisite! rake @@ -473,7 +491,7 @@ DEPENDENCIES sanitize sass sass-rails (~> 4.0.5) - seed-fu (~> 2.3.3) + seed-fu (~> 2.3.5) shoulda sidekiq sidekiq-statistic diff --git a/app/models/post.rb b/app/models/post.rb index c486e327d9..5693369e8d 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -90,7 +90,7 @@ class Post < ActiveRecord::Base end def limit_posts_per_day - if user.first_day_user? && post_number && post_number > 1 + if user && user.first_day_user? && post_number && post_number > 1 RateLimiter.new(user, "first-day-replies-per-day", SiteSetting.max_replies_in_first_day, 1.day.to_i) end end diff --git a/app/models/report.rb b/app/models/report.rb index 204bc3e9bc..8d1d971077 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -58,7 +58,7 @@ class Report if filter == :page_view_total ApplicationRequest.where(req_type: [ ApplicationRequest.req_types.reject{|k,v| k =~ /mobile/}.map{|k,v| v if k =~ /page_view/}.compact - ]) + ].flatten) else ApplicationRequest.where(req_type: ApplicationRequest.req_types[filter]) end diff --git a/app/models/topic.rb b/app/models/topic.rb index 0cf7dad300..71111defcc 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -259,7 +259,7 @@ class Topic < ActiveRecord::Base # Additional rate limits on topics: per day and private messages per day def limit_topics_per_day apply_per_day_rate_limit_for("topics", :max_topics_per_day) - limit_first_day_topics_per_day if user.first_day_user? + limit_first_day_topics_per_day if user && user.first_day_user? end def limit_private_messages_per_day diff --git a/config/application.rb b/config/application.rb index 203442efff..f1df9881c6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -122,10 +122,7 @@ module Discourse # see: http://stackoverflow.com/questions/11894180/how-does-one-correctly-add-custom-sql-dml-in-migrations/11894420#11894420 config.active_record.schema_format = :sql - if Rails.version >= "4.2.0" && Rails.version < "5.0.0" - # Opt-into the default behavior in Rails 5 - config.active_record.raise_in_transactional_callbacks = false - end + config.active_record.raise_in_transactional_callbacks = true # per https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet config.pbkdf2_iterations = 64000 diff --git a/config/environments/production.rb b/config/environments/production.rb index 013e37f597..c637fb67b1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -10,7 +10,7 @@ Discourse::Application.configure do config.action_controller.perform_caching = true # Disable Rails's static asset server (Apache or nginx will already do this) - config.serve_static_assets = GlobalSetting.serve_static_assets + config.serve_static_files = GlobalSetting.serve_static_assets config.assets.js_compressor = :uglifier diff --git a/config/environments/profile.rb b/config/environments/profile.rb index 1784a23528..3f672a9c47 100644 --- a/config/environments/profile.rb +++ b/config/environments/profile.rb @@ -13,7 +13,7 @@ Discourse::Application.configure do config.action_controller.perform_caching = true # in profile mode we serve static assets - config.serve_static_assets = true + config.serve_static_files = true # Compress JavaScripts and CSS config.assets.compress = true diff --git a/config/environments/test.rb b/config/environments/test.rb index b885d72ab3..16cab97ce1 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -8,7 +8,7 @@ Discourse::Application.configure do config.cache_classes = true # Configure static asset server for tests with Cache-Control for performance - config.serve_static_assets = true + config.serve_static_files = true # Show full error reports and disable caching config.consider_all_requests_local = true diff --git a/lib/email.rb b/lib/email.rb index 1b728aee7e..f0fbc28e57 100644 --- a/lib/email.rb +++ b/lib/email.rb @@ -10,13 +10,14 @@ module Email return false unless String === email - parser = Mail::RFC2822Parser.new - parser.root = :addr_spec - result = parser.parse(email) + parsed = Mail::Address.new(email) + # Don't allow for a TLD by itself list (sam@localhost) # The Grammar is: (local_part "@" domain) / local_part ... need to discard latter - result && result.respond_to?(:domain) && result.domain.dot_atom_text.elements.size > 1 + parsed.address == email && parsed.local != parsed.address && parsed.domain && parsed.domain.split(".").length > 1 + rescue Mail::Field::ParseError + false end def self.downcase(email) diff --git a/spec/models/user_email_observer_spec.rb b/spec/models/user_email_observer_spec.rb index 74dec64b5e..f9307e1c4c 100644 --- a/spec/models/user_email_observer_spec.rb +++ b/spec/models/user_email_observer_spec.rb @@ -2,10 +2,17 @@ require 'spec_helper' describe UserEmailObserver do - context 'user_mentioned' do + # something is off with fabricator + def create_notification(type=nil, user=nil) + user ||= Fabricate(:user) + type ||= Notification.types[:mentioned] + Notification.create(data: '', user: user, notification_type: type) + end - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user) } + context 'user_mentioned' do + let!(:notification) do + create_notification + end it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_mentioned, user_id: notification.user_id, notification_id: notification.id) @@ -13,19 +20,19 @@ describe UserEmailObserver do end it "enqueue a delayed job for users that are online" do - user.last_seen_at = 1.minute.ago + notification.user.last_seen_at = 1.minute.ago Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_mentioned, user_id: notification.user_id, notification_id: notification.id) UserEmailObserver.send(:new).after_commit(notification) end it "doesn't enqueue an email if the user has mention emails disabled" do - user.expects(:email_direct?).returns(false) + notification.user.expects(:email_direct?).returns(false) Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_mentioned)).never UserEmailObserver.send(:new).after_commit(notification) end it "doesn't enqueue an email if the user account is deactivated" do - user.active = false + notification.user.active = false Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_mentioned)).never UserEmailObserver.send(:new).after_commit(notification) end @@ -34,8 +41,8 @@ describe UserEmailObserver do context 'posted' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 9) } + let!(:notification) { create_notification(9) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_posted, user_id: notification.user_id, notification_id: notification.id) @@ -58,8 +65,8 @@ describe UserEmailObserver do context 'user_replied' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 2) } + let!(:notification) { create_notification(2) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_replied, user_id: notification.user_id, notification_id: notification.id) @@ -82,8 +89,8 @@ describe UserEmailObserver do context 'user_quoted' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 3) } + let!(:notification) { create_notification(3) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_quoted, user_id: notification.user_id, notification_id: notification.id) @@ -106,8 +113,8 @@ describe UserEmailObserver do context 'email_user_invited_to_private_message' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 7) } + let!(:notification) { create_notification(7) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_invited_to_private_message, user_id: notification.user_id, notification_id: notification.id) @@ -130,8 +137,8 @@ describe UserEmailObserver do context 'private_message' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 6) } + let!(:notification) { create_notification(6) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_private_message, user_id: notification.user_id, notification_id: notification.id) @@ -154,8 +161,8 @@ describe UserEmailObserver do context 'user_invited_to_topic' do - let(:user) { Fabricate(:user) } - let!(:notification) { Fabricate(:notification, user: user, notification_type: 13) } + let!(:notification) { create_notification(13) } + let(:user) { notification.user } it "enqueues a job for the email" do Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_invited_to_topic, user_id: notification.user_id, notification_id: notification.id) From 59b5670e9c5f05689b8801c4b6b727886519d283 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 16:31:46 +1000 Subject: [PATCH 015/133] gem updates --- Gemfile.lock | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3c7f5cba96..ba3992a6c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,7 +84,6 @@ GEM discourse-qunit-rails (0.0.8) railties docile (1.1.5) - dotenv (1.0.2) email_reply_parser (0.5.8) ember-data-source (1.0.0.beta.16.1) ember-source (~> 1.8) @@ -108,7 +107,7 @@ GEM fakeweb (1.3.0) faraday (0.9.1) multipart-post (>= 1.2, < 3) - fast_blank (0.0.2) + fast_blank (1.0.0) fast_stack (0.1.0) rake rake-compiler @@ -120,8 +119,7 @@ GEM ffi (1.9.6) flamegraph (0.1.0) fast_stack - foreman (0.77.0) - dotenv (~> 1.0.2) + foreman (0.78.0) thor (~> 0.19.1) fspath (2.1.1) gctools (0.2.3) @@ -131,11 +129,11 @@ GEM activesupport (>= 4.1.0) guess_html_encoding (0.0.11) handlebars-source (2.0.0) - hashie (3.4.0) + hashie (3.4.2) highline (1.7.1) hike (1.2.3) hiredis (0.6.0) - hitimes (1.2.2) + hitimes (1.2.3) htmlentities (4.3.3) i18n (0.7.0) image_optim (0.20.2) @@ -165,7 +163,7 @@ GEM lru_redux (1.1.0) mail (2.6.3) mime-types (>= 1.16, < 3) - memory_profiler (0.9.3) + memory_profiler (0.9.4) message_bus (1.0.16) rack (>= 1.1.3) redis @@ -195,7 +193,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (~> 1.2) - oj (2.12.9) + oj (2.12.14) omniauth (1.2.2) hashie (>= 1.2, < 4) rack (~> 1.0) @@ -239,8 +237,7 @@ GEM pry (>= 0.9.10, < 0.11.0) pry-rails (0.3.3) pry (>= 0.9.10) - puma (2.11.1) - rack (>= 1.1, < 2.0) + puma (2.14.0) r2 (0.2.5) rack (1.6.4) rack-mini-profiler (0.9.6) From 3853e3cfdc17ae778ea578a6f8ece2f1bca65685 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 16:47:17 +1000 Subject: [PATCH 016/133] PERF: omit 2 queries on every full page load --- app/models/color_scheme.rb | 22 ++++++++++++++++++++-- spec/models/color_scheme_spec.rb | 9 +++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index d4a1b02dec..7e43107940 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -1,7 +1,12 @@ require_dependency 'sass/discourse_stylesheets' +require_dependency 'distributed_cache' class ColorScheme < ActiveRecord::Base + def self.hex_cache + @hex_cache ||= DistributedCache.new("scheme_hex_for_name") + end + attr_accessor :is_base has_many :color_scheme_colors, -> { order('id ASC') }, dependent: :destroy @@ -12,6 +17,8 @@ class ColorScheme < ActiveRecord::Base after_destroy :destroy_versions after_save :publish_discourse_stylesheet + after_save :dump_hex_cache + after_destroy :dump_hex_cache validates_associated :color_scheme_colors @@ -64,8 +71,14 @@ class ColorScheme < ActiveRecord::Base end def self.hex_for_name(name) - # Can't use `where` here because base doesn't allow it - (enabled || base).colors.find {|c| c.name == name }.try(:hex) + val = begin + hex_cache[name] ||= begin + # Can't use `where` here because base doesn't allow it + (enabled || base).colors.find {|c| c.name == name }.try(:hex) || :nil + end + end + + val == :nil ? nil : val end def colors=(arr) @@ -101,6 +114,11 @@ class ColorScheme < ActiveRecord::Base DiscourseStylesheets.cache.clear end + + def dump_hex_cache + self.class.hex_cache.clear + end + end # == Schema Information diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb index 5ae8fface0..bdc55ee13b 100644 --- a/spec/models/color_scheme_spec.rb +++ b/spec/models/color_scheme_spec.rb @@ -42,6 +42,10 @@ describe ColorScheme do end context "hex_for_name without anything enabled" do + before do + ColorScheme.hex_cache.clear + end + it "returns nil for a missing attribute" do expect(described_class.hex_for_name('undefined')).to eq nil end @@ -55,8 +59,8 @@ describe ColorScheme do describe "destroy" do it "also destroys old versions" do c1 = described_class.create(valid_params.merge(version: 2)) - c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1)) - other = described_class.create(valid_params) + _c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1)) + _other = described_class.create(valid_params) expect { c1.destroy }.to change { described_class.count }.by(-2) @@ -69,6 +73,7 @@ describe ColorScheme do end it "returns the enabled color scheme" do + ColorScheme.hex_cache.clear expect(described_class.hex_for_name('$primary_background_color')).to eq nil c = described_class.create(valid_params.merge(enabled: true)) expect(described_class.enabled.id).to eq c.id From 5043a5d9aeb9adf908acebe77610605949e941b1 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 17:04:26 +1000 Subject: [PATCH 017/133] more gem updates --- Gemfile.lock | 88 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ba3992a6c8..6cd3e47806 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,19 +71,59 @@ GEM builder (3.2.2) byebug (5.0.0) columnize (= 0.9.0) - celluloid (0.16.0) - timers (~> 4.0.0) + celluloid (0.17.1.2) + bundler + celluloid-essentials + celluloid-extras + celluloid-fsm + celluloid-pool + celluloid-supervision + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) + celluloid-essentials (0.20.2.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) + celluloid-extras (0.20.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) + celluloid-fsm (0.20.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) + celluloid-pool (0.20.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) + celluloid-supervision (0.20.1.1) + bundler + dotenv + nenv + rspec-logsplit (>= 0.1.2) + timers (>= 4.1.1) certified (1.0.0) coderay (1.1.0) columnize (0.9.0) connection_pool (2.2.0) - crass (1.0.1) - daemons (1.2.2) + crass (1.0.2) + daemons (1.2.3) debug_inspector (0.0.2) diff-lcs (1.2.5) discourse-qunit-rails (0.0.8) railties docile (1.1.5) + dotenv (2.0.2) email_reply_parser (0.5.8) ember-data-source (1.0.0.beta.16.1) ember-source (~> 1.8) @@ -99,9 +139,9 @@ GEM railties (>= 3.1) ember-source (1.12.1) erubis (2.7.0) - eventmachine (1.0.7) + eventmachine (1.0.8) excon (0.45.3) - execjs (2.5.2) + execjs (2.6.0) exifr (1.2.2) fabrication (2.9.8) fakeweb (1.3.0) @@ -116,7 +156,7 @@ GEM rake-compiler fast_xs (0.8.0) fastimage_discourse (1.6.6) - ffi (1.9.6) + ffi (1.9.10) flamegraph (0.1.0) fast_stack foreman (0.78.0) @@ -130,7 +170,7 @@ GEM guess_html_encoding (0.0.11) handlebars-source (2.0.0) hashie (3.4.2) - highline (1.7.1) + highline (1.7.7) hike (1.2.3) hiredis (0.6.0) hitimes (1.2.3) @@ -151,7 +191,7 @@ GEM thor (>= 0.14, < 2.0) json (1.8.3) jwt (1.3.0) - kgio (2.9.3) + kgio (2.10.0) librarian (0.1.2) highline thor (~> 0.15) @@ -174,13 +214,14 @@ GEM minitest (5.8.0) mocha (1.1.0) metaclass (~> 0.0.1) - mock_redis (0.14.0) + mock_redis (0.15.2) moneta (0.8.0) - msgpack (0.5.11) + msgpack (0.6.2) multi_json (1.11.2) multi_xml (0.5.5) multipart-post (2.0.0) mustache (1.0.2) + nenv (0.2.0) netrc (0.10.3) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) @@ -227,7 +268,7 @@ GEM openid-redis-store (0.0.2) redis ruby-openid - pg (0.18.1) + pg (0.18.3) progress (3.1.0) pry (0.10.1) coderay (~> 1.1.0) @@ -235,7 +276,7 @@ GEM slop (~> 3.4) pry-nav (0.2.4) pry (>= 0.9.10, < 0.11.0) - pry-rails (0.3.3) + pry-rails (0.3.4) pry (>= 0.9.10) puma (2.14.0) r2 (0.2.5) @@ -275,7 +316,7 @@ GEM activesupport (= 4.2.4) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - raindrops (0.13.0) + raindrops (0.15.0) rake (10.4.2) rake-compiler (0.9.4) rake @@ -308,6 +349,7 @@ GEM rspec-given (3.5.4) given_core (= 3.5.4) rspec (>= 2.12) + rspec-logsplit (0.1.3) rspec-mocks (3.2.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.2.0) @@ -344,8 +386,8 @@ GEM shoulda-context (1.2.1) shoulda-matchers (2.7.0) activesupport (>= 3.0.0) - sidekiq (3.4.2) - celluloid (~> 0.16.0) + sidekiq (3.5.0) + celluloid (~> 0.17.0) connection_pool (~> 2.2, >= 2.2.0) json (~> 1.0) redis (~> 3.2, >= 3.2.1) @@ -358,10 +400,10 @@ GEM multi_json (~> 1.0) simplecov-html (~> 0.8.0) simplecov-html (0.8.0) - sinatra (1.4.5) + sinatra (1.4.6) rack (~> 1.4) rack-protection (~> 1.4) - tilt (~> 1.3, >= 1.3.4) + tilt (>= 1.3, < 3) slop (3.6.0) sorcerer (1.0.2) spork (1.0.0rc4) @@ -381,26 +423,26 @@ GEM therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref - thin (1.6.3) + thin (1.6.4) daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0) + eventmachine (~> 1.0, >= 1.0.4) rack (~> 1.0) thor (0.19.1) thread_safe (0.3.5) tilt (1.4.1) timecop (0.7.3) - timers (4.0.1) + timers (4.1.1) hitimes trollop (2.1.1) tzinfo (1.2.2) thread_safe (~> 0.1) - uglifier (2.7.1) + uglifier (2.7.2) execjs (>= 0.3.0) json (>= 1.8.0) unf (0.1.4) unf_ext unf_ext (0.0.6) - unicorn (4.8.3) + unicorn (4.9.0) kgio (~> 2.6) rack raindrops (~> 0.7) From 4ad54f601fcb3036d0e1c0b99e3f54593c50fc73 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 17:10:15 +1000 Subject: [PATCH 018/133] more gem updates --- Gemfile.lock | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6cd3e47806..bacc70fb12 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,9 +43,9 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - annotate (2.6.6) - activerecord (>= 2.3.0) - rake (~> 10.4.2, >= 10.4.2) + annotate (2.6.10) + activerecord (>= 3.2, <= 4.3) + rake (~> 10.4) arel (6.0.3) aws-sdk (2.0.45) aws-sdk-resources (= 2.0.45) @@ -69,8 +69,7 @@ GEM binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) builder (3.2.2) - byebug (5.0.0) - columnize (= 0.9.0) + byebug (6.0.2) celluloid (0.17.1.2) bundler celluloid-essentials @@ -114,7 +113,6 @@ GEM timers (>= 4.1.1) certified (1.0.0) coderay (1.1.0) - columnize (0.9.0) connection_pool (2.2.0) crass (1.0.2) daemons (1.2.3) @@ -140,9 +138,9 @@ GEM ember-source (1.12.1) erubis (2.7.0) eventmachine (1.0.8) - excon (0.45.3) + excon (0.45.4) execjs (2.6.0) - exifr (1.2.2) + exifr (1.2.3.1) fabrication (2.9.8) fakeweb (1.3.0) faraday (0.9.1) @@ -174,7 +172,7 @@ GEM hike (1.2.3) hiredis (0.6.0) hitimes (1.2.3) - htmlentities (4.3.3) + htmlentities (4.3.4) i18n (0.7.0) image_optim (0.20.2) exifr (~> 1.1, >= 1.1.3) @@ -184,8 +182,7 @@ GEM progress (~> 3.0, >= 3.0.1) image_size (1.4.1) in_threads (1.3.1) - jmespath (1.0.2) - multi_json (~> 1.0) + jmespath (1.1.3) jquery-rails (3.1.2) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) @@ -225,7 +222,7 @@ GEM netrc (0.10.3) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) - nokogumbo (1.2.0) + nokogumbo (1.4.1) nokogiri oauth (0.4.7) oauth2 (1.0.0) @@ -281,7 +278,7 @@ GEM puma (2.14.0) r2 (0.2.5) rack (1.6.4) - rack-mini-profiler (0.9.6) + rack-mini-profiler (0.9.7) rack (>= 1.1.3) rack-openid (1.3.1) rack (>= 1.1.0) @@ -320,14 +317,14 @@ GEM rake (10.4.2) rake-compiler (0.9.4) rake - rb-fsevent (0.9.4) + rb-fsevent (0.9.6) rb-inotify (0.9.5) ffi (>= 0.5.0) rbtrace (0.4.7) ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) - redcarpet (3.2.2) + redcarpet (3.3.2) redis (3.2.1) redis-namespace (1.5.2) redis (~> 3.0, >= 3.0.4) @@ -367,10 +364,10 @@ GEM ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) - sanitize (3.1.2) - crass (~> 1.0.1) + sanitize (4.0.0) + crass (~> 1.0.2) nokogiri (>= 1.4.4) - nokogumbo (= 1.2.0) + nokogumbo (= 1.4.1) sass (3.2.19) sass-rails (4.0.5) railties (>= 4.0.0, < 5.0) @@ -430,7 +427,7 @@ GEM thor (0.19.1) thread_safe (0.3.5) tilt (1.4.1) - timecop (0.7.3) + timecop (0.8.0) timers (4.1.1) hitimes trollop (2.1.1) From 4ee3ed336d3ef835bcb713b82c75efb9bc26da12 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 17:15:49 +1000 Subject: [PATCH 019/133] update more gems --- Gemfile.lock | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bacc70fb12..bc593115e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,14 +47,12 @@ GEM activerecord (>= 3.2, <= 4.3) rake (~> 10.4) arel (6.0.3) - aws-sdk (2.0.45) - aws-sdk-resources (= 2.0.45) - aws-sdk-core (2.0.45) - builder (~> 3.0) + aws-sdk (2.1.23) + aws-sdk-resources (= 2.1.23) + aws-sdk-core (2.1.23) jmespath (~> 1.0) - multi_json (~> 1.0) - aws-sdk-resources (2.0.45) - aws-sdk-core (= 2.0.45) + aws-sdk-resources (2.1.23) + aws-sdk-core (= 2.1.23) babel-source (5.8.19) babel-transpiler (0.7.0) babel-source (>= 4.0, < 6) @@ -192,7 +190,7 @@ GEM librarian (0.1.2) highline thor (~> 0.15) - libv8 (3.16.14.7) + libv8 (3.16.14.11) listen (0.7.3) logster (1.0.0.3.pre) loofah (2.0.3) @@ -315,7 +313,7 @@ GEM thor (>= 0.18.1, < 2.0) raindrops (0.15.0) rake (10.4.2) - rake-compiler (0.9.4) + rake-compiler (0.9.5) rake rb-fsevent (0.9.6) rb-inotify (0.9.5) @@ -328,7 +326,7 @@ GEM redis (3.2.1) redis-namespace (1.5.2) redis (~> 3.0, >= 3.0.4) - ref (1.0.5) + ref (2.0.0) rest-client (1.7.2) mime-types (>= 1.16, < 3.0) netrc (~> 0.7) @@ -360,7 +358,7 @@ GEM rspec-support (~> 3.2.0) rspec-support (3.2.2) rtlit (0.0.5) - ruby-openid (2.5.0) + ruby-openid (2.7.0) ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) @@ -519,7 +517,7 @@ DEPENDENCIES rest-client rinku rmmseg-cpp - rspec (~> 3.2.0) + rspec rspec-given rspec-rails rtlit From f3af3934fd7fb2d1404d5f4e26fd7548c4f77d7f Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 17:18:43 +1000 Subject: [PATCH 020/133] update auth gems --- Gemfile.lock | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bc593115e5..40d3a2e855 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,7 +185,7 @@ GEM railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) json (1.8.3) - jwt (1.3.0) + jwt (1.5.1) kgio (2.10.0) librarian (0.1.2) highline @@ -233,7 +233,7 @@ GEM omniauth (1.2.2) hashie (>= 1.2, < 4) rack (~> 1.0) - omniauth-facebook (2.0.0) + omniauth-facebook (2.0.1) omniauth-oauth2 (~> 1.2) omniauth-github-discourse (1.1.2) omniauth (~> 1.0) @@ -241,20 +241,18 @@ GEM omniauth-google-oauth2 (0.2.5) omniauth (> 1.0) omniauth-oauth2 (~> 1.1) - omniauth-oauth (1.0.1) + omniauth-oauth (1.1.0) oauth omniauth (~> 1.0) - omniauth-oauth2 (1.2.0) - faraday (>= 0.8, < 0.10) - multi_json (~> 1.3) + omniauth-oauth2 (1.3.1) oauth2 (~> 1.0) omniauth (~> 1.2) omniauth-openid (1.0.1) omniauth (~> 1.0) rack-openid (~> 1.3.1) - omniauth-twitter (1.0.1) - multi_json (~> 1.3) - omniauth-oauth (~> 1.0) + omniauth-twitter (1.2.1) + json (~> 1.3) + omniauth-oauth (~> 1.1) onebox (1.5.26) moneta (~> 0.8) multi_json (~> 1.11) @@ -517,7 +515,7 @@ DEPENDENCIES rest-client rinku rmmseg-cpp - rspec + rspec (~> 3.2.0) rspec-given rspec-rails rtlit From 86cf86ba747345f5c112036fa6e14dc9d093d42c Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2015 17:21:28 +1000 Subject: [PATCH 021/133] update code coverage gem --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 40d3a2e855..dad42251ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -388,11 +388,11 @@ GEM sidekiq-statistic (1.1.0) sidekiq (~> 3.3, >= 3.3.4) simple-rss (1.3.1) - simplecov (0.9.1) + simplecov (0.10.0) docile (~> 1.1.0) - multi_json (~> 1.0) - simplecov-html (~> 0.8.0) - simplecov-html (0.8.0) + json (~> 1.8) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.0) sinatra (1.4.6) rack (~> 1.4) rack-protection (~> 1.4) From a253bf8698da64bcf0eba52b3e966fa3527ad207 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 23 Sep 2015 16:08:38 +0800 Subject: [PATCH 022/133] FIX: Don't subscribe to Desktop Notifications on mobile. --- .../initializers/subscribe-user-notifications.js.es6 | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index 92ab828c68..79157a123a 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -29,10 +29,6 @@ export default { }); } - bus.subscribe("/notification-alert/" + user.get('id'), function(data){ - onNotification(data, user); - }); - bus.subscribe("/notification/" + user.get('id'), function(data) { const oldUnread = user.get('unread_notifications'); const oldPM = user.get('unread_private_messages'); @@ -85,7 +81,13 @@ export default { }); if (!Ember.testing) { - initDesktopNotifications(bus); + if (!Discourse.Mobile.mobileView) { + bus.subscribe("/notification-alert/" + user.get('id'), function(data){ + onNotification(data, user); + }); + + initDesktopNotifications(bus); + } } } } From b01743620a6dcef5495455ffeb4e44762af72e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 23 Sep 2015 11:16:17 +0200 Subject: [PATCH 023/133] fix deprecation --- lib/topic_creator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index cff51e7c89..d8a7bb96df 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -150,7 +150,7 @@ class TopicCreator def add_users(topic, usernames) return unless usernames - User.where(username: usernames.split(',')).each do |user| + User.where(username: usernames.split(',').flatten).each do |user| check_can_send_permission!(topic, user) @added_users << user topic.topic_allowed_users.build(user_id: user.id) From c62c42185e3884857e43eb2c34e2b39dd2a8a5b5 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Wed, 23 Sep 2015 03:16:03 -0700 Subject: [PATCH 024/133] update install guide for Discourse 1.4 --- docs/INSTALL-cloud.md | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md index 88306633d2..d5c899cbc9 100644 --- a/docs/INSTALL-cloud.md +++ b/docs/INSTALL-cloud.md @@ -6,13 +6,13 @@ - Enter your domain `discourse.example.com` as the name. -- The default of **1 GB** RAM works fine for small Discourse communities. We do recommend 2 GB RAM for medium communities. +- The default of **1 GB** RAM works fine for small Discourse communities. We recommend 2 GB RAM for larger communities. - The default of **Ubuntu 14.04 LTS x64** works fine. At minimum, a 64-bit Linux OS with a kernel version of 3.10+ is required. - The default of **New York** is a good choice for most US and European audiences. Or select a region that is geographically closer to your audience. -Create your new Droplet. You will receive a mail from Digital Ocean with the root password to your Droplet. (However, if you know [how to use SSH keys](https://www.google.com/search?q=digitalocean+ssh+keys), you may not need a password to log in.) +Create your new Droplet. You will receive an email with the root password to your Droplet. (However, if you know [how to use SSH keys](https://www.google.com/search?q=digitalocean+ssh+keys), you may not need a password to log in.) # Access Your Cloud Server @@ -22,23 +22,20 @@ Connect to your Droplet via SSH, or use [Putty][put] on Windows: Replace `192.168.1.1` with the IP address of your Droplet. - - You will be asked for permission to connect, type `yes`, then enter the root password from the email Digital Ocean sent you when the Droplet was set up. You may be prompted to change the root password, too. - + # Set up Swap (if needed) - If you're using the minimum 1 GB install, you *must* [set up a swap file](https://meta.discourse.org/t/create-a-swapfile-for-your-linux-server/13880). - - If you're using 2 GB+ memory, you can probably get by without a swap file. # Install Docker / Git wget -qO- https://get.docker.com/ | sh - +This command installs the latest versions of Docker and Git on your server. Alternately, you can manually install the respective [Docker package for your OS](https://docs.docker.com/installation/). # Install Discourse @@ -49,15 +46,15 @@ Create a `/var/discourse` folder, clone the [Official Discourse Docker Image][dd cd /var/discourse cp samples/standalone.yml containers/app.yml - + # Edit Discourse Configuration -Edit the Discourse configuration at `app.yml`: +Edit the Discourse config file `app.yml`: nano containers/app.yml -We recommend Nano because it works like a typical GUI text editor, just use your arrow keys. +We recommend Nano because it's simple; just use your arrow keys to edit. - Set `DISCOURSE_DEVELOPER_EMAILS` to your email address. @@ -67,7 +64,7 @@ We recommend Nano because it works like a typical GUI text editor, just use your - If you are using a 1 GB instance, set `UNICORN_WORKERS` to 2 and `db_shared_buffers` to 128MB so you have more memory room. - + After completing your edits, press CtrlO then Enter to save and CtrlX to exit. @@ -93,33 +90,32 @@ After that completes, start Discourse: ./launcher start app - + Congratulations! You now have your own instance of Discourse! It should be accessible via the domain name `discourse.example.com` you entered earlier, provided you configured DNS. If not, you can also visit the server IP directly, e.g. `http://192.168.1.1`. - + # Register New Account and Become Admin -There is a reminder at the top about `DISCOURSE_DEVELOPER_EMAILS`; register a new account via one of those email addresses, and your account will automatically be made an Admin. +There is a reminder at the top about the `DISCOURSE_DEVELOPER_EMAILS` you entered previously in `app.yml`; register a new account via one of those email addresses, and your account will automatically be made an Admin. (If you *don't* get any email from your install, and are unable to register a new admin account, please see our [Email Troubleshooting checklist](https://meta.discourse.org/t/troubleshooting-email-on-a-new-discourse-install/16326).) - + -You should see Staff topics and the [Admin Quick Start Guide](https://github.com/discourse/discourse/blob/master/docs/ADMIN-QUICK-START-GUIDE.md). It contains the next steps for further configuring and customizing your Discourse install. +You should see Staff topics and the Admin Quick Start Guide. It contains the next steps for further configuring and customizing your Discourse install. Read it closely. (If you are still unable to register a new admin account via email, see [Create Admin Account from Console](https://meta.discourse.org/t/create-admin-account-from-console/17274), but please note that *you will have a broken site* unless you get email working on your instance.) - # Post-Install Maintenance We strongly suggest you: -- turn on automatic security updates via the `dpkg-reconfigure -plow unattended-upgrades` command -- enable stronger passwords via the `apt-get install libpam-cracklib` package +- turn on automatic security updates for your OS. In Ubuntu use the `dpkg-reconfigure -plow unattended-upgrades` command. +- if you are using a password and not a SSH key, be sure to enforce a strong root password. In Ubuntu use the `apt-get install libpam-cracklib` package. To **upgrade Discourse to the latest version**, visit `/admin/upgrade` and follow the instructions. @@ -174,7 +170,7 @@ Do you want... - To embed Discourse [in your WordPress install](https://github.com/discourse/wp-discourse), or [on your static HTML site](https://meta.discourse.org/t/embedding-discourse-comments-via-javascript/31963)? -If anything needs to be improved in this guide, feel free to ask on [meta.discourse.org][meta], or even better, submit a pull request. +Help us improve this guide! Feel free to ask about it on [meta.discourse.org][meta], or even better, submit a pull request. [dd]: https://github.com/discourse/discourse_docker [man]: https://mandrillapp.com From 22830a597486f882a6cee9746c6092cbbc4e1b33 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Wed, 23 Sep 2015 03:26:41 -0700 Subject: [PATCH 025/133] simplify install guide a tiny bit --- docs/INSTALL-cloud.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md index d5c899cbc9..27a532dd86 100644 --- a/docs/INSTALL-cloud.md +++ b/docs/INSTALL-cloud.md @@ -24,7 +24,7 @@ Replace `192.168.1.1` with the IP address of your Droplet. You will be asked for permission to connect, type `yes`, then enter the root password from the email Digital Ocean sent you when the Droplet was set up. You may be prompted to change the root password, too. - + # Set up Swap (if needed) @@ -46,8 +46,6 @@ Create a `/var/discourse` folder, clone the [Official Discourse Docker Image][dd cd /var/discourse cp samples/standalone.yml containers/app.yml - - # Edit Discourse Configuration Edit the Discourse config file `app.yml`: @@ -64,7 +62,7 @@ We recommend Nano because it's simple; just use your arrow keys to edit. - If you are using a 1 GB instance, set `UNICORN_WORKERS` to 2 and `db_shared_buffers` to 128MB so you have more memory room. - + After completing your edits, press CtrlO then Enter to save and CtrlX to exit. @@ -90,13 +88,13 @@ After that completes, start Discourse: ./launcher start app - + Congratulations! You now have your own instance of Discourse! It should be accessible via the domain name `discourse.example.com` you entered earlier, provided you configured DNS. If not, you can also visit the server IP directly, e.g. `http://192.168.1.1`. - + # Register New Account and Become Admin @@ -104,7 +102,7 @@ There is a reminder at the top about the `DISCOURSE_DEVELOPER_EMAILS` you entere (If you *don't* get any email from your install, and are unable to register a new admin account, please see our [Email Troubleshooting checklist](https://meta.discourse.org/t/troubleshooting-email-on-a-new-discourse-install/16326).) - + You should see Staff topics and the Admin Quick Start Guide. It contains the next steps for further configuring and customizing your Discourse install. Read it closely. @@ -114,10 +112,10 @@ You should see Staff topics and the Admin Quick Start Guide. It contains the nex We strongly suggest you: -- turn on automatic security updates for your OS. In Ubuntu use the `dpkg-reconfigure -plow unattended-upgrades` command. -- if you are using a password and not a SSH key, be sure to enforce a strong root password. In Ubuntu use the `apt-get install libpam-cracklib` package. +- Turn on automatic security updates for your OS. In Ubuntu use the `dpkg-reconfigure -plow unattended-upgrades` command. +- If you are using a password and not a SSH key, be sure to enforce a strong root password. In Ubuntu use the `apt-get install libpam-cracklib` package. -To **upgrade Discourse to the latest version**, visit `/admin/upgrade` and follow the instructions. +You will get email reminders as new versions of Discourse are released. Please stay current to get the latest features and security fixes. To **upgrade Discourse to the latest version**, visit `/admin/upgrade` in your browser and click the Upgrade button. The `launcher` command in the `/var/discourse` folder can be used for various kinds of maintenance: From ab5969a5c1fc4e2a26b2e31a66063f73ea717c68 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Wed, 23 Sep 2015 03:33:37 -0700 Subject: [PATCH 026/133] emphasize reading the admin quick start guide --- docs/INSTALL-cloud.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md index 27a532dd86..2a0cef7417 100644 --- a/docs/INSTALL-cloud.md +++ b/docs/INSTALL-cloud.md @@ -104,7 +104,7 @@ There is a reminder at the top about the `DISCOURSE_DEVELOPER_EMAILS` you entere -You should see Staff topics and the Admin Quick Start Guide. It contains the next steps for further configuring and customizing your Discourse install. Read it closely. +You should see Staff topics and **READ ME FIRST: Admin Quick Start Guide**. This guide contains the next steps for further configuring and customizing your Discourse install as an administrator. Read it closely! (If you are still unable to register a new admin account via email, see [Create Admin Account from Console](https://meta.discourse.org/t/create-admin-account-from-console/17274), but please note that *you will have a broken site* unless you get email working on your instance.) From 07ead4418782ebc03c0e6de610b93eb1494ce434 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 23 Sep 2015 18:02:28 +0530 Subject: [PATCH 027/133] UX: make old twitter oneboxes look good --- app/assets/stylesheets/common/base/onebox.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index 2db1ab7206..b8b2acde7e 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -248,7 +248,7 @@ aside.onebox.twitterstatus .onebox-body { .thumbnail { float: left; } - .tweet { + p, .tweet { float: left; display: inline-block; white-space: pre-wrap; From e37ecb9d2faca8683a99b39a0df2dbdfbb1e0441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 23 Sep 2015 15:35:22 +0200 Subject: [PATCH 028/133] FIX: pikaday wasn't working when using the mouse with a touch-enabled monitor --- .../discourse/components/date-picker.js.es6 | 22 +++++++++---------- public/javascripts/pikaday.js | 6 +++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/discourse/components/date-picker.js.es6 b/app/assets/javascripts/discourse/components/date-picker.js.es6 index c3e92ad474..3f7c446f5c 100644 --- a/app/assets/javascripts/discourse/components/date-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker.js.es6 @@ -1,30 +1,30 @@ /* global Pikaday:true */ import loadScript from "discourse/lib/load-script"; +import { on } from "ember-addons/ember-computed-decorators"; export default Em.Component.extend({ tagName: "input", classNames: ["date-picker"], _picker: null, - _loadDatePicker: function() { - const self = this, - input = this.$()[0]; + @on("didInsertElement") + _loadDatePicker() { + const input = this.$()[0]; - loadScript("/javascripts/pikaday.js").then(function() { - self._picker = new Pikaday({ + loadScript("/javascripts/pikaday.js").then(() => { + this._picker = new Pikaday({ field: input, format: "YYYY-MM-DD", defaultDate: moment().add(1, "day").toDate(), minDate: new Date(), - onSelect: function(date) { - self.set("value", moment(date).format("YYYY-MM-DD")); - }, + onSelect: date => this.set("value", moment(date).format("YYYY-MM-DD")), }); }); - }.on("didInsertElement"), + }, - _destroy: function() { + @on("willDestroyElement") + _destroy() { this._picker = null; - }.on("willDestroyElement"), + }, }); diff --git a/public/javascripts/pikaday.js b/public/javascripts/pikaday.js index dc2b5e11f5..c0596d22d3 100644 --- a/public/javascripts/pikaday.js +++ b/public/javascripts/pikaday.js @@ -428,7 +428,6 @@ } }, 100); } - return; } else if (hasClass(target, 'pika-prev')) { self.prevMonth(); @@ -438,6 +437,7 @@ } } if (!hasClass(target, 'pika-select')) { + // if this is touch event prevent mouse events emulation if (e.preventDefault) { e.preventDefault(); } else { @@ -543,7 +543,8 @@ self.el = document.createElement('div'); self.el.className = 'pika-single' + (opts.isRTL ? ' is-rtl' : '') + (opts.theme ? ' ' + opts.theme : ''); - addEvent(self.el, 'ontouchend' in document ? 'touchend' : 'mousedown', self._onMouseDown, true); + addEvent(self.el, 'mousedown', self._onMouseDown, true); + addEvent(self.el, 'touchend', self._onMouseDown, true); addEvent(self.el, 'change', self._onChange); if (opts.field) { @@ -1058,6 +1059,7 @@ { this.hide(); removeEvent(this.el, 'mousedown', this._onMouseDown, true); + removeEvent(this.el, 'touchend', this._onMouseDown, true); removeEvent(this.el, 'change', this._onChange); if (this._o.field) { removeEvent(this._o.field, 'change', this._onInputChange); From ef0804fbb02dfeaa3257ee3f2c290227aa2585a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 23 Sep 2015 16:05:41 +0200 Subject: [PATCH 029/133] FIX: only disable the composer grip when the device is touch-only --- app/assets/stylesheets/desktop/compose.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index a874ce8994..9bcc420850 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -94,8 +94,8 @@ // hide cancel upload link on IE9 (not supported) .ie9 #cancel-file-upload { display: none; } -// todo, enable if we ever implement touch grippie... I question the value though (Sam) -.touch #reply-control.open .grippie { +// only disabled when the device is touch-only +.touch.mobile-device #reply-control.open .grippie { display: none; } From dcdf76a66248a5e05980213e98e3ce69a86828a1 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 23 Sep 2015 11:34:23 -0400 Subject: [PATCH 030/133] FIX: Category Logo preview should not repeat --- .../discourse/components/image-uploader.js.es6 | 9 +++++---- .../templates/components/edit-category-images.hbs | 2 +- app/assets/stylesheets/common/base/upload.scss | 6 ++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/components/image-uploader.js.es6 b/app/assets/javascripts/discourse/components/image-uploader.js.es6 index 4ca6bfdab2..3fc6650a47 100644 --- a/app/assets/javascripts/discourse/components/image-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/image-uploader.js.es6 @@ -1,13 +1,14 @@ +import property from 'ember-addons/ember-computed-decorators'; import UploadMixin from "discourse/mixins/upload"; export default Em.Component.extend(UploadMixin, { classNames: ["image-uploader"], - backgroundStyle: function() { - const imageUrl = this.get("imageUrl"); + @property('imageUrl') + backgroundStyle(imageUrl) { if (Em.isNone(imageUrl)) { return; } - return ("background-image: url(" + imageUrl + ")").htmlSafe(); - }.property("imageUrl"), + return `background-image: url(${imageUrl})`.htmlSafe(); + }, uploadDone(upload) { this.set("imageUrl", upload.url); diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-images.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-images.hbs index f4d7995dfd..84139b501d 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-images.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-images.hbs @@ -1,6 +1,6 @@
- {{image-uploader imageUrl=category.logo_url type="category_logo"}} + {{image-uploader imageUrl=category.logo_url type="category_logo" class="no-repeat"}}
diff --git a/app/assets/stylesheets/common/base/upload.scss b/app/assets/stylesheets/common/base/upload.scss index 6afba003ef..9777fd3204 100644 --- a/app/assets/stylesheets/common/base/upload.scss +++ b/app/assets/stylesheets/common/base/upload.scss @@ -2,3 +2,9 @@ background-size: cover; background: $primary center center; } + +.image-uploader.no-repeat { + .uploaded-image-preview { + background-repeat: no-repeat; + } +} From 6b48647fc738cd46b8bf64c2fb85db124214439e Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 23 Sep 2015 11:48:58 -0400 Subject: [PATCH 031/133] FIX: Double load sometimes on topic lists --- app/assets/javascripts/discourse/mixins/scrolling.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/mixins/scrolling.js.es6 b/app/assets/javascripts/discourse/mixins/scrolling.js.es6 index e25af304e4..718a770b67 100644 --- a/app/assets/javascripts/discourse/mixins/scrolling.js.es6 +++ b/app/assets/javascripts/discourse/mixins/scrolling.js.es6 @@ -19,7 +19,7 @@ const ScrollingDOMMethods = { }, screenNotFull() { - return $(window).height() >= $(document).height(); + return $(window).height() > $(document).height(); } }; From 7d4dbc9962cd4dec7a22e6d19b9754090966c2b1 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 23 Sep 2015 12:10:15 -0400 Subject: [PATCH 032/133] Give example values for CSS rules in embedding --- .../admin/components/embedding-setting.js.es6 | 5 +++++ .../admin/templates/components/embedding-setting.hbs | 2 +- app/assets/javascripts/admin/templates/embedding.hbs | 9 +++++++-- config/locales/client.en.yml | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/admin/components/embedding-setting.js.es6 b/app/assets/javascripts/admin/components/embedding-setting.js.es6 index 904afacfee..039e5d5dc3 100644 --- a/app/assets/javascripts/admin/components/embedding-setting.js.es6 +++ b/app/assets/javascripts/admin/components/embedding-setting.js.es6 @@ -6,6 +6,11 @@ export default Ember.Component.extend({ @computed('field') inputId(field) { return field.dasherize(); }, + @computed('placeholder') + placeholderValue(placeholder) { + return placeholder ? I18n.t(placeholder) : null; + }, + @computed('field') translationKey(field) { return `admin.embedding.${field}`; }, diff --git a/app/assets/javascripts/admin/templates/components/embedding-setting.hbs b/app/assets/javascripts/admin/templates/components/embedding-setting.hbs index 36dbb88692..e3a99c3202 100644 --- a/app/assets/javascripts/admin/templates/components/embedding-setting.hbs +++ b/app/assets/javascripts/admin/templates/components/embedding-setting.hbs @@ -5,7 +5,7 @@ {{else}} - {{input value=value id=inputId}} + {{input value=value id=inputId placeholder=placeholderValue}} {{/if}}
diff --git a/app/assets/javascripts/admin/templates/embedding.hbs b/app/assets/javascripts/admin/templates/embedding.hbs index 15d021a9c1..d41970fb84 100644 --- a/app/assets/javascripts/admin/templates/embedding.hbs +++ b/app/assets/javascripts/admin/templates/embedding.hbs @@ -46,8 +46,13 @@

{{i18n "admin.embedding.crawling_settings"}}

{{i18n "admin.embedding.crawling_description"}}

- {{embedding-setting field="embed_whitelist_selector" value=embedding.embed_whitelist_selector}} - {{embedding-setting field="embed_blacklist_selector" value=embedding.embed_blacklist_selector}} + {{embedding-setting field="embed_whitelist_selector" + value=embedding.embed_whitelist_selector + placeholder="admin.embedding.whitelist_example"}} + + {{embedding-setting field="embed_blacklist_selector" + value=embedding.embed_blacklist_selector + placeholder="admin.embedding.blacklist_example"}}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index f5fa118088..a0af30ff9a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2592,7 +2592,9 @@ en: embed_username_key_from_feed: "Key to pull discourse username from feed" embed_truncate: "Truncate the embedded posts" embed_whitelist_selector: "CSS selector for elements that are allowed in embeds" + whitelist_example: "article, #story, .post" embed_blacklist_selector: "CSS selector for elements that are removed from embeds" + blacklist_example: ".ad-unit, header" feed_polling_enabled: "Import posts via RSS/ATOM" feed_polling_url: "URL of RSS/ATOM feed to crawl" save: "Save Embedding Settings" From 5ca26a77071850e30dc771cdfd0161892a40bb04 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Mon, 21 Sep 2015 16:56:25 -0400 Subject: [PATCH 033/133] FEATURE: add site setting use_admin_ip_whitelist to enable/disable the whitelisting of admins by IP address --- .../screened_ip_address_form_component.js | 23 +++++-- app/models/screened_ip_address.rb | 1 + config/locales/server.en.yml | 1 + config/site_settings.yml | 3 + spec/controllers/session_controller_spec.rb | 1 + spec/models/screened_ip_address_spec.rb | 61 ++++++++++++------- 6 files changed, 62 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/admin/components/screened_ip_address_form_component.js b/app/assets/javascripts/admin/components/screened_ip_address_form_component.js index 69c517f00b..1bf90b0227 100644 --- a/app/assets/javascripts/admin/components/screened_ip_address_form_component.js +++ b/app/assets/javascripts/admin/components/screened_ip_address_form_component.js @@ -18,14 +18,25 @@ Discourse.ScreenedIpAddressFormComponent = Ember.Component.extend({ formSubmitted: false, actionName: 'block', - actionNames: function() { - return [ - {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, - {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')}, - {id: 'allow_admin', name: I18n.t('admin.logs.screened_ips.actions.allow_admin')} - ]; + adminWhitelistEnabled: function() { + return Discourse.SiteSettings.use_admin_ip_whitelist; }.property(), + actionNames: function() { + if (this.get('adminWhitelistEnabled')) { + return [ + {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, + {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')}, + {id: 'allow_admin', name: I18n.t('admin.logs.screened_ips.actions.allow_admin')} + ]; + } else { + return [ + {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, + {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')} + ]; + } + }.property('adminWhitelistEnabled'), + actions: { submit: function() { if (!this.get('formSubmitted')) { diff --git a/app/models/screened_ip_address.rb b/app/models/screened_ip_address.rb index 287a7dd061..82d63cdcd9 100644 --- a/app/models/screened_ip_address.rb +++ b/app/models/screened_ip_address.rb @@ -75,6 +75,7 @@ class ScreenedIpAddress < ActiveRecord::Base end def self.block_admin_login?(user, ip_address) + return false unless SiteSetting.use_admin_ip_whitelist return false if user.nil? return false if !user.admin? return false if ScreenedIpAddress.where(action_type: actions[:allow_admin]).count == 0 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c3a0b253bf..21e4d67e85 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -862,6 +862,7 @@ en: enable_noscript_support: "Enable standard webcrawler search engine support via the noscript tag" allow_moderators_to_create_categories: "Allow moderators to create new categories" cors_origins: "Allowed origins for cross-origin requests (CORS). Each origin must include http:// or https://. The DISCOURSE_ENABLE_CORS env variable must be set to true to enable CORS." + use_admin_ip_whitelist: "Admins can only log in if they are at an IP address defined in the Screened IPs list (Admin > Logs > Screened Ips)." top_menu: "Determine which items appear in the homepage navigation, and in what order. Example latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determine which items appear on the post menu, and in what order. Example like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "The menu items to hide by default in the post menu unless an expansion ellipsis is clicked on." diff --git a/config/site_settings.yml b/config/site_settings.yml index f0822e1aaa..3b9acbcbaa 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -656,6 +656,9 @@ security: cors_origins: default: '' type: list + use_admin_ip_whitelist: + default: false + client: true onebox: enable_flash_video_onebox: false diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 59a838a52f..26d9b7f579 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -507,6 +507,7 @@ describe SessionController do let(:permitted_ip_address) { '111.234.23.11' } before do Fabricate(:screened_ip_address, ip_address: permitted_ip_address, action_type: ScreenedIpAddress.actions[:allow_admin]) + SiteSetting.stubs(:use_admin_ip_whitelist).returns(true) end it 'is successful for admin at the ip address' do diff --git a/spec/models/screened_ip_address_spec.rb b/spec/models/screened_ip_address_spec.rb index ef176fb2cd..37385784f3 100644 --- a/spec/models/screened_ip_address_spec.rb +++ b/spec/models/screened_ip_address_spec.rb @@ -240,20 +240,29 @@ describe ScreenedIpAddress do describe '#block_admin_login?' do context 'no allow_admin records exist' do - it "returns false when user is nil" do - expect(described_class.block_admin_login?(nil, '123.12.12.12')).to eq(false) - end - it "returns false for non-admin user" do + it "returns false when use_admin_ip_whitelist is false" do expect(described_class.block_admin_login?(Fabricate.build(:user), '123.12.12.12')).to eq(false) end - it "returns false for admin user" do - expect(described_class.block_admin_login?(Fabricate.build(:admin), '123.12.12.12')).to eq(false) - end + context "use_admin_ip_whitelist is true" do + before { SiteSetting.stubs(:use_admin_ip_whitelist).returns(true) } - it "returns false for admin user and ip_address arg is nil" do - expect(described_class.block_admin_login?(Fabricate.build(:admin), nil)).to eq(false) + it "returns false when user is nil" do + expect(described_class.block_admin_login?(nil, '123.12.12.12')).to eq(false) + end + + it "returns false for non-admin user" do + expect(described_class.block_admin_login?(Fabricate.build(:user), '123.12.12.12')).to eq(false) + end + + it "returns false for admin user" do + expect(described_class.block_admin_login?(Fabricate.build(:admin), '123.12.12.12')).to eq(false) + end + + it "returns false for admin user and ip_address arg is nil" do + expect(described_class.block_admin_login?(Fabricate.build(:admin), nil)).to eq(false) + end end end @@ -263,24 +272,32 @@ describe ScreenedIpAddress do Fabricate(:screened_ip_address, ip_address: @permitted_ip_address, action_type: described_class.actions[:allow_admin]) end - it "returns false when user is nil" do - expect(described_class.block_admin_login?(nil, @permitted_ip_address)).to eq(false) + it "returns false when use_admin_ip_whitelist is false" do + expect(described_class.block_admin_login?(Fabricate.build(:admin), '123.12.12.12')).to eq(false) end - it "returns false for an admin user at the allowed ip address" do - expect(described_class.block_admin_login?(Fabricate.build(:admin), @permitted_ip_address)).to eq(false) - end + context "use_admin_ip_whitelist is true" do + before { SiteSetting.stubs(:use_admin_ip_whitelist).returns(true) } - it "returns true for an admin user at another ip address" do - expect(described_class.block_admin_login?(Fabricate.build(:admin), '123.12.12.12')).to eq(true) - end + it "returns false when user is nil" do + expect(described_class.block_admin_login?(nil, @permitted_ip_address)).to eq(false) + end - it "returns false for regular user at allowed ip address" do - expect(described_class.block_admin_login?(Fabricate.build(:user), @permitted_ip_address)).to eq(false) - end + it "returns false for an admin user at the allowed ip address" do + expect(described_class.block_admin_login?(Fabricate.build(:admin), @permitted_ip_address)).to eq(false) + end - it "returns false for regular user at another ip address" do - expect(described_class.block_admin_login?(Fabricate.build(:user), '123.12.12.12')).to eq(false) + it "returns true for an admin user at another ip address" do + expect(described_class.block_admin_login?(Fabricate.build(:admin), '123.12.12.12')).to eq(true) + end + + it "returns false for regular user at allowed ip address" do + expect(described_class.block_admin_login?(Fabricate.build(:user), @permitted_ip_address)).to eq(false) + end + + it "returns false for regular user at another ip address" do + expect(described_class.block_admin_login?(Fabricate.build(:user), '123.12.12.12')).to eq(false) + end end end end From 454a628d5869bfa641e12d35bd4d4f2908311d68 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 Sep 2015 23:51:15 +0200 Subject: [PATCH 034/133] Add tests for I18n.exists? freedom patch --- lib/freedom_patches/i18n_fallbacks.rb | 2 ++ spec/helpers/i18n_fallbacks_spec.rb | 52 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 spec/helpers/i18n_fallbacks_spec.rb diff --git a/lib/freedom_patches/i18n_fallbacks.rb b/lib/freedom_patches/i18n_fallbacks.rb index b8aa691835..859e816c08 100644 --- a/lib/freedom_patches/i18n_fallbacks.rb +++ b/lib/freedom_patches/i18n_fallbacks.rb @@ -1,3 +1,5 @@ +# This should be used until https://github.com/svenfuchs/i18n/pull/326 is merged into the I18n gem. + module I18n module Backend module Fallbacks diff --git a/spec/helpers/i18n_fallbacks_spec.rb b/spec/helpers/i18n_fallbacks_spec.rb new file mode 100644 index 0000000000..01cd56077b --- /dev/null +++ b/spec/helpers/i18n_fallbacks_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe 'freedom patch for I18n::Backend::Fallbacks' do + before do + SiteSetting.default_locale = 'de' + I18n.locale = :de + + store_translations(:en, :foo => 'Foo in :en', :bar => 'Bar in :en') + store_translations(:de, :bar => 'Bar in :de') + store_translations(:'de-AT', :baz => 'Baz in :de-AT') + end + + def store_translations(locale, data) + I18n.backend.store_translations(locale, data) + end + + describe '#exists?' do + it 'returns true when a key is given that exists in the default locale' do + expect(I18n.exists?(:bar)).to be true + end + + it 'returns true when a key is given that exists in a fallback locale of the default locale' do + expect(I18n.exists?(:foo)).to be true + end + + it 'returns false when a non-existing key is given' do + expect(I18n.exists?(:bogus)).to be false + end + + it 'returns true when an existing key and an existing locale is given' do + expect(I18n.exists?(:foo, :en)).to be true + expect(I18n.exists?(:bar, :de)).to be true + expect(I18n.exists?(:baz, :'de-AT')).to be true + end + + it 'returns false when a non-existing key and an existing locale is given' do + expect(I18n.exists?(:bogus, :en)).to be false + expect(I18n.exists?(:bogus, :de)).to be false + expect(I18n.exists?(:bogus, :'de-AT')).to be false + end + + it 'returns true when a key is given which is missing from the given locale and exists in a fallback locale' do + expect(I18n.exists?(:foo, :de)).to be true + expect(I18n.exists?(:foo, :'de-AT')).to be true + end + + it 'returns true when a key is given which is missing from the given locale and all its fallback locales' do + expect(I18n.exists?(:baz, :de)).to be false + expect(I18n.exists?(:bogus, :'de-AT')).to be false + end + end +end From 4d6c99cb3d51bfad73e485dcacc142740a8f70a4 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 23 Sep 2015 14:42:59 -0400 Subject: [PATCH 035/133] FIX: On mobile flags could cover the topic map --- .../javascripts/discourse/components/actions-summary.js.es6 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/discourse/components/actions-summary.js.es6 b/app/assets/javascripts/discourse/components/actions-summary.js.es6 index f817f6da58..7e0e95f60d 100644 --- a/app/assets/javascripts/discourse/components/actions-summary.js.es6 +++ b/app/assets/javascripts/discourse/components/actions-summary.js.es6 @@ -83,6 +83,8 @@ export default Ember.Component.extend(StringBuffer, { autoUpdatingRelativeAge(new Date(post.get('postDeletedAt'))) + "
"); } + + buffer.push("
"); }, actionTypeById(actionTypeId) { From 690f839619c09590459d39635af40a64057c2edf Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 23 Sep 2015 15:21:36 -0400 Subject: [PATCH 036/133] FIX: uncategorized topics list is 404 page when allow_uncategorized_topics is turned off --- .../javascripts/discourse/components/category-chooser.js.es6 | 2 +- app/models/site.rb | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/components/category-chooser.js.es6 b/app/assets/javascripts/discourse/components/category-chooser.js.es6 index df61b75dde..f5b85e7992 100644 --- a/app/assets/javascripts/discourse/components/category-chooser.js.es6 +++ b/app/assets/javascripts/discourse/components/category-chooser.js.es6 @@ -36,7 +36,7 @@ export default ComboboxView.extend({ @computed("rootNone") none(rootNone) { - if (Discourse.User.currentProp('staff') || Discourse.SiteSettings.allow_uncategorized_topics) { + if (Discourse.SiteSettings.allow_uncategorized_topics) { if (rootNone) { return "category.none"; } else { diff --git a/app/models/site.rb b/app/models/site.rb index 6c4c05dfd8..5f3cf7981d 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -44,10 +44,6 @@ class Site .includes(:topic_only_relative_url) .order(:position) - unless SiteSetting.allow_uncategorized_topics - categories = categories.where('categories.id <> ?', SiteSetting.uncategorized_category_id) - end - categories = categories.to_a with_children = Set.new From bda6b48ac1ecd28683e43e598fc5fa58b6418b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 23 Sep 2015 22:44:53 +0200 Subject: [PATCH 037/133] new posts:fix_letter_avatars rake task --- lib/tasks/posts.rake | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake index 5f661e5500..37fb02bfaf 100644 --- a/lib/tasks/posts.rake +++ b/lib/tasks/posts.rake @@ -8,6 +8,24 @@ task 'posts:refresh_oneboxes' => :environment do ENV['RAILS_DB'] ? rebake_posts(invalidate_oneboxes: true) : rebake_posts_all_sites(invalidate_oneboxes: true) end +desc 'Rebake all posts with a quote using a letter_avatar' +task 'posts:fix_letter_avatars' => :environment do + return unless SiteSetting.external_system_avatars_enabled + + search = Post.where("user_id <> -1") + .where("raw LIKE '%/letter\_avatar/%' OR cooked LIKE '%/letter\_avatar/%'") + + rebaked = 0 + total = search.count + + search.order(updated_at: :asc).find_each do |post| + rebake_post(post) + print_status(rebaked += 1, total) + end + + puts "", "#{rebaked} posts done!", "" +end + def rebake_posts_all_sites(opts = {}) RailsMultisite::ConnectionManagement.each_connection do |db| rebake_posts(opts) @@ -33,7 +51,7 @@ def rebake_posts(opts = {}) puts "", "#{rebaked} posts done!", "-" * 50 end -def rebake_post(post, opts) +def rebake_post(post, opts = {}) post.rebake!(opts) rescue => e puts "", "Failed to rebake (topic_id: #{post.topic_id}, post_id: #{post.id})", e, e.backtrace.join("\n") From 25e9aa76534d8d1073772422e37a08397c6318ea Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 23 Sep 2015 22:52:43 +0200 Subject: [PATCH 038/133] FIX: Use user's locale for badge notifications --- app/models/badge.rb | 26 ++++++++++++++++++++++++++ app/serializers/badge_serializer.rb | 28 ---------------------------- app/services/badge_granter.rb | 10 ++++++---- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/models/badge.rb b/app/models/badge.rb index 818b4dfae3..76393865dc 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -329,12 +329,38 @@ SQL Badge.find_each(&:reset_grant_count!) end + def display_name + if self.system? + key = "admin_js.badges.badge.#{i18n_name}.name" + I18n.t(key, default: self.name) + else + self.name + end + end + + def long_description + if self[:long_description].present? + self[:long_description] + else + key = "badges.long_descriptions.#{i18n_name}" + I18n.t(key, default: '') + end + end + + def slug + Slug.for(self.display_name, '-') + end + protected def ensure_not_system unless id self.id = [Badge.maximum(:id) + 1, 100].max end end + + def i18n_name + self.name.downcase.gsub(' ', '_') + end end # == Schema Information diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb index 8223ecd785..ede876c908 100644 --- a/app/serializers/badge_serializer.rb +++ b/app/serializers/badge_serializer.rb @@ -12,32 +12,4 @@ class BadgeSerializer < ApplicationSerializer def include_long_description? options[:include_long_description] end - - def long_description - if object.long_description.present? - object.long_description - else - key = "badges.long_descriptions.#{i18n_name}" - if I18n.exists?(key) - I18n.t(key) - else - "" - end - end - end - - def slug - Slug.for(display_name, '') - end - - private - - def i18n_name - object.name.downcase.gsub(' ', '_') - end - - def display_name - key = "admin_js.badges.badge.#{i18n_name}.name" - I18n.t(key, default: object.name) - end end diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 670ada829e..75c0f7ef58 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -41,10 +41,12 @@ class BadgeGranter end if SiteSetting.enable_badges? - notification = @user.notifications.create( - notification_type: Notification.types[:granted_badge], - data: { badge_id: @badge.id, badge_name: @badge.name }.to_json) - user_badge.update_attributes notification_id: notification.id + I18n.with_locale(@user.effective_locale) do + notification = @user.notifications.create( + notification_type: Notification.types[:granted_badge], + data: { badge_id: @badge.id, badge_name: @badge.display_name }.to_json) + user_badge.update_attributes notification_id: notification.id + end end end end From 445bd033d2b8537152f4f5c719695a01f86863e6 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 24 Sep 2015 00:36:09 +0200 Subject: [PATCH 039/133] FIX: Use correct badge slug within notifications --- .../discourse/components/notification-item.js.es6 | 10 ++++++++-- app/services/badge_granter.rb | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/components/notification-item.js.es6 b/app/assets/javascripts/discourse/components/notification-item.js.es6 index 2aa93e141f..8b4730074d 100644 --- a/app/assets/javascripts/discourse/components/notification-item.js.es6 +++ b/app/assets/javascripts/discourse/components/notification-item.js.es6 @@ -22,8 +22,14 @@ export default Ember.Component.extend({ const it = this.get('notification'); const badgeId = it.get("data.badge_id"); if (badgeId) { - const badgeName = it.get("data.badge_name"); - return Discourse.getURL('/badges/' + badgeId + '/' + badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase()); + var badgeSlug = it.get("data.badge_slug"); + + if (!badgeSlug) { + const badgeName = it.get("data.badge_name"); + badgeSlug = badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase(); + } + + return Discourse.getURL('/badges/' + badgeId + '/' + badgeSlug); } const topicId = it.get('topic_id'); diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 75c0f7ef58..b6d964a655 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -44,7 +44,7 @@ class BadgeGranter I18n.with_locale(@user.effective_locale) do notification = @user.notifications.create( notification_type: Notification.types[:granted_badge], - data: { badge_id: @badge.id, badge_name: @badge.display_name }.to_json) + data: { badge_id: @badge.id, badge_name: @badge.display_name, badge_slug: @badge.slug }.to_json) user_badge.update_attributes notification_id: notification.id end end From 7d5e2d46c55464955c7165612bf2c5f984346fb1 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 24 Sep 2015 01:20:44 +0200 Subject: [PATCH 040/133] FIX: Only enabled badges can be granted FIX: Sort badges by displayName --- .../admin/controllers/admin-user-badges.js.es6 | 4 ++-- .../admin/controllers/admin-user-badges-test.js.es6 | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 index b85fbaf069..de9b3cc982 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 @@ -56,12 +56,12 @@ export default Ember.ArrayController.extend({ var badges = []; this.get('badges').forEach(function(badge) { - if (badge.get('multiple_grant') || !granted[badge.get('id')]) { + if (badge.get('enabled') && (badge.get('multiple_grant') || !granted[badge.get('id')])) { badges.push(badge); } }); - return _.sortBy(badges, "name"); + return _.sortBy(badges, badge => badge.get('displayName')); }.property('badges.@each', 'model.@each'), /** diff --git a/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 b/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 index 5c06c69c52..4a0a676651 100644 --- a/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 +++ b/test/javascripts/admin/controllers/admin-user-badges-test.js.es6 @@ -5,14 +5,17 @@ moduleFor('controller:admin-user-badges', { }); test("grantableBadges", function() { - const badgeFirst = Badge.create({id: 3, name: "A Badge"}); - const badgeMiddle = Badge.create({id: 1, name: "My Badge"}); - const badgeLast = Badge.create({id: 2, name: "Zoo Badge"}); - const controller = this.subject({ badges: [badgeLast, badgeFirst, badgeMiddle] }); + const badgeFirst = Badge.create({id: 3, name: "A Badge", enabled: true}); + const badgeMiddle = Badge.create({id: 1, name: "My Badge", enabled: true}); + const badgeLast = Badge.create({id: 2, name: "Zoo Badge", enabled: true}); + const badgeDisabled = Badge.create({id: 4, name: "Disabled Badge", enabled: false}); + const controller = this.subject({ badges: [badgeLast, badgeFirst, badgeMiddle, badgeDisabled] }); const sortedNames = [badgeFirst.name, badgeMiddle.name, badgeLast.name]; const badgeNames = controller.get('grantableBadges').map(function(badge) { return badge.name; }); + + not(badgeNames.contains(badgeDisabled), "excludes disabled badges"); deepEqual(badgeNames, sortedNames, "sorts badges by name"); }); From 9507573d5e2f737744f9e3e0c0cf201caa6ffb8c Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Wed, 23 Sep 2015 17:39:00 -0700 Subject: [PATCH 041/133] FIX: 1.4 welcome PM images needed update --- public/images/welcome/reply-post-2x.png | Bin 549 -> 430 bytes .../welcome/topic-notification-control-2x.png | Bin 39580 -> 50219 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/images/welcome/reply-post-2x.png b/public/images/welcome/reply-post-2x.png index 4d3362c0ab285d493d5fbc03da8629f834eb065e..659cf7cff39bbcb9a3650eddbddc597b5c9c2321 100644 GIT binary patch delta 404 zcmV;F0c-xH1g-;+B!8t)OjJex|Nl--PMe#X&CSjJ{{G3y$@TU1_xJafmX@ihsZdZ* z@bK{I>FI=oguT7JaBy&2TU%*qX}Y?)dwYA{-rkgyl!1YP)YR1b`}^SF;89Ugz`($7 zZ*S7l(!|8XhlhvU+}v1LSgWh6>+9>FprF;&)$Q%=fPjEqU4LDJgM(&fX7ls&Wo2dh z`ubB-Q;CU*u&}Usd3lYEjh~;Nva+&nZf@-C?5I%$4FCWD#7RU!RCr$P)WvSZFcgJh zA5)k!%vff4|3|CTyT~HaG-q4QzyD-g_O<%2U7JRsCxr$KEnsK?Lkk#Mz|aDj)_U)p zTb+YHMZep-#eV}Mr1@rR2_ePr2_vLx)+cR=i!BfADnJ&{CSEJrG@dqPd-?XIu-Kt1 zANa<+dy63*2ft){s)UT}@OepYe#tl>rZT-3!pwdxg!y68M>z}8(+hxCMHKxBspqD_WAYAlC&F^Yd?+_3v`|O^$n~500006 z%F@_%tJ-hL`v8^N0Eo}g@bJ;_@&Jq10FKgBj?G(+%$CLAoaXHoIR-=p06soC-2ac?|1_K1I-c0k*4WYC z+d8l60EpEzi_Axh%t4#eG>*+Wh|ImR&$hDA(dppP>EhAw;uDC>6PVr6@cICl-9ekx zwN4e{0002+Nq-%umBoK*R zbPNKK-1)#D5Q$ZfXUWU9q!t{hiDa7WgQ1TIgf!L)2!CsA2ZS`4R?5Kqc59erVl2bG z_QoB^7CHQ%qxzIA(MPO2_dX$C5q*%u=ZPr{`E^lR2Vs}ek~c2WK%^J z7DcR86}j?6Zh9*6wvVw?ki4rUmA;x>yIO)jOn))kQIgk#(?5Cd{{VPwG6>#pty=&9 O002ovP6b4+LSTZebt*Ri diff --git a/public/images/welcome/topic-notification-control-2x.png b/public/images/welcome/topic-notification-control-2x.png index 71b2b1d1bdd5096b271a6cb16834e6da497c754c..e330e1c0d2e015122ac864bcb8eed1bf8b2f44b5 100644 GIT binary patch literal 50219 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 literal 39580 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