From a566ed42ae645334d72805cb9eff4b7602265a0d Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 10 Oct 2018 13:00:08 -0400 Subject: [PATCH] FEATURE: Option to disable user presence and profile This allows users who are privacy conscious to disable the presence features of the forum as well as their public profile. --- .../controllers/preferences/interface.js.es6 | 3 +- .../discourse/controllers/user.js.es6 | 13 ++++--- .../javascripts/discourse/models/user.js.es6 | 3 +- .../discourse/routes/app-route-map.js.es6 | 1 + .../discourse/routes/user-activity.js.es6 | 7 +++- .../discourse/routes/user-summary.js.es6 | 7 +++- .../templates/preferences/interface.hbs | 2 +- .../javascripts/discourse/templates/user.hbs | 6 ++-- .../templates/user/profile-hidden.hbs | 1 + app/assets/stylesheets/desktop/user.scss | 4 +++ app/controllers/user_actions_controller.rb | 2 ++ app/controllers/users_controller.rb | 25 +++++++++---- app/serializers/current_user_serializer.rb | 7 +++- app/serializers/hidden_profile_serializer.rb | 7 ++++ app/serializers/user_option_serializer.rb | 1 + app/services/user_updater.rb | 1 + config/locales/client.en.yml | 2 ++ config/routes.rb | 1 + ...de_profile_and_presence_to_user_options.rb | 5 +++ lib/guardian/user_guardian.rb | 11 ++++++ .../composer-presence-display.js.es6 | 6 ++++ plugins/discourse-presence/plugin.rb | 3 +- .../spec/requests/presence_controller_spec.rb | 6 ++++ .../components/guardian/user_guardian_spec.rb | 35 +++++++++++++++++++ spec/models/user_option_spec.rb | 7 ++++ spec/requests/user_actions_controller_spec.rb | 9 +++++ spec/requests/users_controller_spec.rb | 26 +++++++++++++- 27 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 app/assets/javascripts/discourse/templates/user/profile-hidden.hbs create mode 100644 app/serializers/hidden_profile_serializer.rb create mode 100644 db/migrate/20181010150631_add_hide_profile_and_presence_to_user_options.rb diff --git a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 index 8cf32d9cc64..97cab16b0db 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 @@ -31,7 +31,8 @@ export default Ember.Controller.extend(PreferencesTabController, { "disable_jump_reply", "automatically_unpin_topics", "allow_private_messages", - "homepage_id" + "homepage_id", + "hide_profile_and_presence" ]; if (makeDefault) { diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 96b2a333f23..80162bff88c 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -16,9 +16,9 @@ export default Ember.Controller.extend(CanCheckEmails, { return currentUser && username === currentUser.get("username"); }, - @computed("viewingSelf") - canExpandProfile(viewingSelf) { - return viewingSelf; + @computed("viewingSelf", "model.profile_hidden") + canExpandProfile(viewingSelf, profileHidden) { + return !profileHidden && viewingSelf; }, @computed("model.profileBackground") @@ -26,8 +26,11 @@ export default Ember.Controller.extend(CanCheckEmails, { return !Ember.isEmpty(background.toString()); }, - @computed("indexStream", "viewingSelf", "forceExpand") - collapsedInfo(indexStream, viewingSelf, forceExpand) { + @computed("model.profile_hidden", "indexStream", "viewingSelf", "forceExpand") + collapsedInfo(profileHidden, indexStream, viewingSelf, forceExpand) { + if (profileHidden) { + return true; + } return (!indexStream || viewingSelf) && !forceExpand; }, diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index b3e91306d34..acb2ad2ff41 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -284,7 +284,8 @@ const User = RestModel.extend({ "include_tl0_in_digests", "theme_ids", "allow_private_messages", - "homepage_id" + "homepage_id", + "hide_profile_and_presence" ]; if (fields) { diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index df489fa5a12..30dd8fc7552 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -102,6 +102,7 @@ export default function() { "user", { path: "/u/:username", resetNamespace: true }, function() { + this.route("profile-hidden"); this.route("summary"); this.route( "userActivity", diff --git a/app/assets/javascripts/discourse/routes/user-activity.js.es6 b/app/assets/javascripts/discourse/routes/user-activity.js.es6 index 546e135cbef..a827e3d9988 100644 --- a/app/assets/javascripts/discourse/routes/user-activity.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity.js.es6 @@ -1,6 +1,11 @@ export default Discourse.Route.extend({ model() { - return this.modelFor("user"); + let user = this.modelFor("user"); + if (user.get("profile_hidden")) { + return this.replaceWith("user.profile-hidden"); + } + + return user; }, setupController(controller, user) { diff --git a/app/assets/javascripts/discourse/routes/user-summary.js.es6 b/app/assets/javascripts/discourse/routes/user-summary.js.es6 index 39d4571973d..95db3c3ae1f 100644 --- a/app/assets/javascripts/discourse/routes/user-summary.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-summary.js.es6 @@ -2,7 +2,12 @@ export default Discourse.Route.extend({ showFooter: true, model() { - return this.modelFor("user").summary(); + let user = this.modelFor("user"); + if (user.get("profile_hidden")) { + return this.replaceWith("user.profile-hidden"); + } + + return user.summary(); }, actions: { diff --git a/app/assets/javascripts/discourse/templates/preferences/interface.hbs b/app/assets/javascripts/discourse/templates/preferences/interface.hbs index 968737b6b60..da84c8c605a 100644 --- a/app/assets/javascripts/discourse/templates/preferences/interface.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/interface.hbs @@ -37,10 +37,10 @@ {{preference-checkbox labelKey="user.enable_quoting" checked=model.user_option.enable_quoting}} {{preference-checkbox labelKey="user.dynamic_favicon" checked=model.user_option.dynamic_favicon}} {{preference-checkbox labelKey="user.disable_jump_reply" checked=model.user_option.disable_jump_reply}} - {{#if siteSettings.automatically_unpin_topics}} {{preference-checkbox labelKey="user.automatically_unpin_topics" checked=model.user_option.automatically_unpin_topics}} {{/if}} + {{preference-checkbox labelKey="user.hide_profile_and_presence" checked=model.user_option.hide_profile_and_presence}} {{plugin-outlet name="user-preferences-interface" args=(hash model=model save=(action "save"))}} diff --git a/app/assets/javascripts/discourse/templates/user.hbs b/app/assets/javascripts/discourse/templates/user.hbs index 9cc4c27311c..fbbb9d1a660 100644 --- a/app/assets/javascripts/discourse/templates/user.hbs +++ b/app/assets/javascripts/discourse/templates/user.hbs @@ -195,8 +195,10 @@ {{#mobile-nav class='main-nav' desktopClass="nav nav-pills user-nav" currentPath=currentPath}} -
  • {{#link-to 'user.summary'}}{{i18n 'user.summary.title'}}{{/link-to}}
  • -
  • {{#link-to 'userActivity'}}{{i18n 'user.activity_stream'}}{{/link-to}}
  • + {{#unless model.profile_hidden}} +
  • {{#link-to 'user.summary'}}{{i18n 'user.summary.title'}}{{/link-to}}
  • +
  • {{#link-to 'userActivity'}}{{i18n 'user.activity_stream'}}{{/link-to}}
  • + {{/unless}} {{#if showNotificationsTab}}
  • {{#link-to 'userNotifications'}} diff --git a/app/assets/javascripts/discourse/templates/user/profile-hidden.hbs b/app/assets/javascripts/discourse/templates/user/profile-hidden.hbs new file mode 100644 index 00000000000..680e87157d5 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/profile-hidden.hbs @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index c9470628d29..bb6eb683412 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -41,6 +41,10 @@ } } +.user-profile-hidden { + font-size: 1.5em; + text-align: center; +} .user-navigation { display: table-cell; vertical-align: top; diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index 417147ac5e0..d72b28d49f4 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -7,6 +7,8 @@ class UserActionsController < ApplicationController per_chunk = 30 user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) + raise Discourse::NotFound unless guardian.can_see_profile?(user) + action_types = (params[:filter] || "").split(",").map(&:to_i) opts = { user_id: user.id, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 12ada62ff06..c72ae0864c4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -53,14 +53,18 @@ class UsersController < ApplicationController include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts) ) - user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user') + user_serializer = nil + if guardian.can_see_profile?(@user) + user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user') + # TODO remove this options from serializer + user_serializer.omit_stats = true - # TODO remove this options from serializer - user_serializer.omit_stats = true - - topic_id = params[:include_post_count_for].to_i - if topic_id != 0 - user_serializer.topic_post_count = { topic_id => Post.secured(guardian).where(topic_id: topic_id, user_id: @user.id).count } + topic_id = params[:include_post_count_for].to_i + if topic_id != 0 + user_serializer.topic_post_count = { topic_id => Post.secured(guardian).where(topic_id: topic_id, user_id: @user.id).count } + end + else + user_serializer = HiddenProfileSerializer.new(@user, scope: guardian, root: 'user') end if !params[:skip_track_visit] && (@user != current_user) @@ -204,8 +208,15 @@ class UsersController < ApplicationController end end + def profile_hidden + render nothing: true + end + def summary user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) + + raise Discourse::NotFound unless guardian.can_see_profile?(user) + summary = UserSummary.new(user, guardian) serializer = UserSummarySerializer.new(summary, scope: guardian) render_json_dump(serializer) diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 2ddfe2533ee..d7c683ddb82 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -42,7 +42,8 @@ class CurrentUserSerializer < BasicUserSerializer :can_create_topic, :link_posting_access, :external_id, - :top_category_ids + :top_category_ids, + :hide_profile_and_presence def link_posting_access scope.link_posting_access @@ -68,6 +69,10 @@ class CurrentUserSerializer < BasicUserSerializer object.user_stat.topic_reply_count end + def hide_profile_and_presence + object.user_option.hide_profile_and_presence + end + def enable_quoting object.user_option.enable_quoting end diff --git a/app/serializers/hidden_profile_serializer.rb b/app/serializers/hidden_profile_serializer.rb new file mode 100644 index 00000000000..50075aee591 --- /dev/null +++ b/app/serializers/hidden_profile_serializer.rb @@ -0,0 +1,7 @@ +class HiddenProfileSerializer < BasicUserSerializer + attributes :profile_hidden? + + def profile_hidden? + true + end +end diff --git a/app/serializers/user_option_serializer.rb b/app/serializers/user_option_serializer.rb index 23b86fddd2a..9df725e84cb 100644 --- a/app/serializers/user_option_serializer.rb +++ b/app/serializers/user_option_serializer.rb @@ -23,6 +23,7 @@ class UserOptionSerializer < ApplicationSerializer :theme_key_seq, :allow_private_messages, :homepage_id, + :hide_profile_and_presence def auto_track_topics_after_msecs object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 8724bc5b906..755844f0ffe 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -37,6 +37,7 @@ class UserUpdater :theme_ids, :allow_private_messages, :homepage_id, + :hide_profile_and_presence ] def initialize(actor, user) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 7ced35f7812..63e17921742 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -618,6 +618,7 @@ en: private_messages: "Messages" activity_stream: "Activity" preferences: "Preferences" + profile_hidden: "This user's public profile is hidden." expand_profile: "Expand" collapse_profile: "Collapse" bookmarks: "Bookmarks" @@ -887,6 +888,7 @@ en: website: "Web Site" email_settings: "Email" + hide_profile_and_presence: "Hide my public profile and presence features" like_notification_frequency: title: "Notify when liked" always: "Always" diff --git a/config/routes.rb b/config/routes.rb index d1d9f36575a..11a6106c46f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -432,6 +432,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username } + get "#{root_path}/:username/profile-hidden" => "users#profile_hidden" end get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json } diff --git a/db/migrate/20181010150631_add_hide_profile_and_presence_to_user_options.rb b/db/migrate/20181010150631_add_hide_profile_and_presence_to_user_options.rb new file mode 100644 index 00000000000..b5eef3b307c --- /dev/null +++ b/db/migrate/20181010150631_add_hide_profile_and_presence_to_user_options.rb @@ -0,0 +1,5 @@ +class AddHideProfileAndPresenceToUserOptions < ActiveRecord::Migration[5.2] + def change + add_column :user_options, :hide_profile_and_presence, :boolean, default: false, null: false + end +end diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 32dc6809084..aa502955fa7 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -93,4 +93,15 @@ module UserGuardian user && can_administer_user?(user) end + def can_see_profile?(user) + return false if user.blank? + + # If a user has hidden their profile, restrict it to them and staff + if user.user_option.try(:hide_profile_and_presence?) + return is_me?(user) || is_staff? + end + + true + end + end diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 index 431fec7d093..5c69c900998 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 @@ -113,6 +113,12 @@ export default Ember.Component.extend({ publish(data) { this._lastPublish = new Date(); + + // Don't publish presence if disabled + if (this.currentUser.hide_profile_and_presence) { + return Ember.RSVP.Promise.resolve(); + } + return ajax("/presence/publish", { type: "POST", data }); }, diff --git a/plugins/discourse-presence/plugin.rb b/plugins/discourse-presence/plugin.rb index 2739eb9e2fe..02900e3f277 100644 --- a/plugins/discourse-presence/plugin.rb +++ b/plugins/discourse-presence/plugin.rb @@ -107,8 +107,7 @@ after_initialize do ACTIONS ||= [-"edit", -"reply"].freeze def publish - - raise Discourse::NotFound if !current_user + raise Discourse::NotFound if current_user.blank? || current_user.user_option.hide_profile_and_presence? data = params.permit( :response_needed, diff --git a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb b/plugins/discourse-presence/spec/requests/presence_controller_spec.rb index 8a003a9e4e1..89e702021e9 100644 --- a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb +++ b/plugins/discourse-presence/spec/requests/presence_controller_spec.rb @@ -37,6 +37,12 @@ describe ::Presence::PresencesController do expect { post '/presence/publish.json' }.not_to raise_error end + it "does not publish for users with disabled presence features" do + user1.user_option.update_column(:hide_profile_and_presence, true) + post '/presence/publish.json' + expect(response.code).to eq("404") + end + it "uses guardian to secure endpoint" do private_post = Fabricate(:private_message_post) diff --git a/spec/components/guardian/user_guardian_spec.rb b/spec/components/guardian/user_guardian_spec.rb index 3281b140afb..248a81ff8e3 100644 --- a/spec/components/guardian/user_guardian_spec.rb +++ b/spec/components/guardian/user_guardian_spec.rb @@ -95,6 +95,41 @@ describe UserGuardian do expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true) end end + end + describe "#can_see_profile?" do + + it "is false for no user" do + expect(Guardian.new.can_see_profile?(nil)).to eq(false) + end + + it "is true for a user whose profile is public" do + expect(Guardian.new.can_see_profile?(user)).to eq(true) + end + + context "hidden profile" do + let(:hidden_user) do + result = Fabricate(:user) + result.user_option.update_column(:hide_profile_and_presence, true) + result + end + + it "is false for another user" do + expect(Guardian.new(user).can_see_profile?(hidden_user)).to eq(false) + end + + it "is false for an anonymous user" do + expect(Guardian.new.can_see_profile?(hidden_user)).to eq(false) + end + + it "is true for the user themselves" do + expect(Guardian.new(hidden_user).can_see_profile?(hidden_user)).to eq(true) + end + + it "is true for a staff user" do + expect(Guardian.new(admin).can_see_profile?(hidden_user)).to eq(true) + end + + end end end diff --git a/spec/models/user_option_spec.rb b/spec/models/user_option_spec.rb index 832de55276c..16b558d37fe 100644 --- a/spec/models/user_option_spec.rb +++ b/spec/models/user_option_spec.rb @@ -27,7 +27,14 @@ describe UserOption do user.user_option.expects(:redirected_to_top).returns(nil) expect(user.user_option.should_be_redirected_to_top).to eq(false) end + end + describe "defaults" do + let(:user) { Fabricate(:user) } + + it "should not hide the profile and presence by default" do + expect(user.user_option.hide_profile_and_presence).to eq(false) + end end describe "#mailing_list_mode" do diff --git a/spec/requests/user_actions_controller_spec.rb b/spec/requests/user_actions_controller_spec.rb index 3bae24ecfa6..471a66271ed 100644 --- a/spec/requests/user_actions_controller_spec.rb +++ b/spec/requests/user_actions_controller_spec.rb @@ -9,6 +9,15 @@ describe UserActionsController do expect(response.status).to eq(400) end + it "returns a 404 for a user with a hidden profile" do + UserActionCreator.enable + post = Fabricate(:post) + post.user.user_option.update_column(:hide_profile_and_presence, true) + + get "/user_actions.json", params: { username: post.user.username } + expect(response.code).to eq("404") + end + it 'renders list correctly' do UserActionCreator.enable post = Fabricate(:post) diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index c7e76f97acb..f3bb8996b13 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2171,6 +2171,14 @@ describe UsersController do expect(json["user_summary"]["topic_count"]).to eq(1) expect(json["user_summary"]["post_count"]).to eq(0) end + + it "returns 404 for a hidden profile" do + user = Fabricate(:user) + user.user_option.update_column(:hide_profile_and_presence, true) + + get "/u/#{user.username_lower}/summary.json" + expect(response.status).to eq(404) + end end describe '#confirm_admin' do @@ -2417,7 +2425,23 @@ describe UsersController do it "returns success" do get "/u/#{user.username}.json" expect(response.status).to eq(200) - expect(JSON.parse(response.body)["user"]["username"]).to eq(user.username) + parsed = JSON.parse(response.body)["user"] + + expect(parsed['username']).to eq(user.username) + expect(parsed["profile_hidden"]).to be_blank + expect(parsed["trust_level"]).to be_present + end + + it "returns a hidden profile" do + user.user_option.update_column(:hide_profile_and_presence, true) + + get "/u/#{user.username}.json" + expect(response.status).to eq(200) + parsed = JSON.parse(response.body)["user"] + + expect(parsed["username"]).to eq(user.username) + expect(parsed["profile_hidden"]).to eq(true) + expect(parsed["trust_level"]).to be_blank end it "should redirect to login page for anonymous user when profiles are hidden" do