diff --git a/app/assets/javascripts/admin/components/site-settings/uploaded-image-list.js.es6 b/app/assets/javascripts/admin/components/site-settings/uploaded-image-list.js.es6
new file mode 100644
index 00000000000..31a659a054a
--- /dev/null
+++ b/app/assets/javascripts/admin/components/site-settings/uploaded-image-list.js.es6
@@ -0,0 +1,15 @@
+import showModal from "discourse/lib/show-modal";
+
+export default Ember.Component.extend({
+ actions: {
+ showUploadModal({ value, setting }) {
+ showModal("admin-uploaded-image-list", {
+ admin: true,
+ title: `admin.site_settings.${setting.setting}.title`,
+ model: { value, setting },
+ }).setProperties({
+ save: v => this.set("value", v)
+ });
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6
new file mode 100644
index 00000000000..2fe70259c83
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6
@@ -0,0 +1,27 @@
+import { on, observes } from "ember-addons/ember-computed-decorators";
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+
+export default Ember.Controller.extend(ModalFunctionality, {
+
+ @on("init")
+ @observes("model.value")
+ _setup() {
+ const value = this.get("model.value");
+ this.set("images", value && value.length ? value.split("\n") : []);
+ },
+
+ actions: {
+ uploadDone({ url }) {
+ this.get("images").addObject(url);
+ },
+
+ remove(url) {
+ this.get("images").removeObject(url);
+ },
+
+ close() {
+ this.save(this.get("images").join("\n"));
+ this.send("closeModal");
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6
index 8f50892f9e8..997917def11 100644
--- a/app/assets/javascripts/admin/mixins/setting-component.js.es6
+++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6
@@ -9,7 +9,8 @@ const CUSTOM_TYPES = [
"host_list",
"category_list",
"value_list",
- "category"
+ "category",
+ "uploaded_image_list",
];
export default Ember.Mixin.create({
diff --git a/app/assets/javascripts/admin/templates/components/site-settings/uploaded-image-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/uploaded-image-list.hbs
new file mode 100644
index 00000000000..3de2abc489a
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/site-settings/uploaded-image-list.hbs
@@ -0,0 +1,2 @@
+{{d-button label="admin.site_settings.uploaded_image_list.label" action="showUploadModal" actionParam=(hash value=value setting=setting)}}
+
- {{radio-button id="system-avatar" name="avatar" value="system" selection=selected}}
-
-
-
- {{radio-button id="uploaded-avatar" name="avatar" value="uploaded" selection=selected}}
-
- {{avatar-uploader user_id=id
- uploadedAvatarTemplate=custom_avatar_template
- uploadedAvatarId=custom_avatar_upload_id
- uploading=uploading
- done="uploadComplete"}}
+ {{#if siteSettings.selectable_avatars_enabled}}
+
+ {{#each selectableAvatars as |avatar|}}
+
+ {{bound-avatar-template avatar "huge"}}
+
+ {{/each}}
+ {{else}}
+
+ {{radio-button id="system-avatar" name="avatar" value="system" selection=selected}}
+
+
+
+ {{radio-button id="gravatar" name="avatar" value="gravatar" selection=selected}}
+
+ {{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
+ {{#if gravatarFailed}}
+
{{I18n 'user.change_avatar.gravatar_failed'}}
+ {{/if}}
+
+ {{#if allowAvatarUpload}}
+
+ {{radio-button id="uploaded-avatar" name="avatar" value="uploaded" selection=selected}}
+
+ {{avatar-uploader user_id=id
+ uploadedAvatarTemplate=custom_avatar_template
+ uploadedAvatarId=custom_avatar_upload_id
+ uploading=uploading
+ done="uploadComplete"}}
+
+ {{/if}}
{{/if}}
{{/d-modal-body}}
-
+{{#unless siteSettings.selectable_avatars_enabled}}
+
+{{/unless}}
diff --git a/app/assets/stylesheets/common/admin/settings.scss b/app/assets/stylesheets/common/admin/settings.scss
index 90df1c5e43d..9c40a476b1d 100644
--- a/app/assets/stylesheets/common/admin/settings.scss
+++ b/app/assets/stylesheets/common/admin/settings.scss
@@ -106,3 +106,4 @@
color: $danger;
}
}
+
diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss
index 6170668509c..96a503612da 100644
--- a/app/assets/stylesheets/common/base/user.scss
+++ b/app/assets/stylesheets/common/base/user.scss
@@ -661,3 +661,20 @@
#user-card .staged {
font-style: italic;
}
+
+.selectable-avatars {
+ max-height: 350px;
+ margin-bottom: 1em;
+ text-align: justify;
+ .selectable-avatar {
+ margin: 5px;
+ display: inline-block;
+ .avatar {
+ width: 60px;
+ height: 60px;
+ &:hover {
+ box-shadow: 0 0 10px $primary;
+ }
+ }
+ }
+}
diff --git a/app/controllers/site_controller.rb b/app/controllers/site_controller.rb
index 1e622449c95..42d19813af9 100644
--- a/app/controllers/site_controller.rb
+++ b/app/controllers/site_controller.rb
@@ -25,6 +25,16 @@ class SiteController < ApplicationController
render json: custom_emoji
end
+ def selectable_avatars
+ avatars = if SiteSetting.selectable_avatars_enabled?
+ (SiteSetting.selectable_avatars.presence || "").split("\n")
+ else
+ []
+ end
+
+ render json: avatars, root: false
+ end
+
def basic_info
results = {
logo_url: UrlHelper.absolute(SiteSetting.logo_url),
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 8880d6304b3..6382c4bafb1 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -11,8 +11,9 @@ class UsersController < ApplicationController
requires_login only: [
:username, :update, :user_preferences_redirect, :upload_user_image,
- :pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state,
- :preferences, :create_second_factor, :update_second_factor, :create_second_factor_backup
+ :pick_avatar, :destroy_user_image, :destroy, :check_emails,
+ :topic_tracking_state, :preferences, :create_second_factor,
+ :update_second_factor, :create_second_factor_backup, :select_avatar
]
skip_before_action :check_xhr, only: [
@@ -885,6 +886,46 @@ class UsersController < ApplicationController
render json: success_json
end
+ def select_avatar
+ user = fetch_user_from_params
+ guardian.ensure_can_edit!(user)
+
+ url = params[:url]
+
+ if url.blank?
+ return render json: failed_json, status: 422
+ end
+
+ unless SiteSetting.selectable_avatars_enabled
+ return render json: failed_json, status: 422
+ end
+
+ if SiteSetting.selectable_avatars.blank?
+ return render json: failed_json, status: 422
+ end
+
+ unless SiteSetting.selectable_avatars[url]
+ return render json: failed_json, status: 422
+ end
+
+ unless upload = Upload.find_by(url: url)
+ return render json: failed_json, status: 422
+ end
+
+ user.uploaded_avatar_id = upload.id
+ user.save!
+
+ avatar = user.user_avatar || user.create_user_avatar
+ avatar.custom_upload_id = upload.id
+ avatar.save!
+
+ render json: {
+ avatar_template: user.avatar_template,
+ custom_avatar_template: user.avatar_template,
+ uploaded_avatar_id: upload.id,
+ }
+ end
+
def destroy_user_image
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb
index a4bb083ce33..09e1624ad77 100644
--- a/app/jobs/scheduled/clean_up_uploads.rb
+++ b/app/jobs/scheduled/clean_up_uploads.rb
@@ -24,7 +24,8 @@ module Jobs
SiteSetting.logo_small_url,
SiteSetting.favicon_url,
SiteSetting.apple_touch_icon_url,
- ].map do |url|
+ *SiteSetting.selectable_avatars.split("\n"),
+ ].flatten.map do |url|
if url.present?
url = url.dup
diff --git a/app/models/user.rb b/app/models/user.rb
index 8cfa1bad3ad..019567b2a86 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -104,6 +104,7 @@ class User < ActiveRecord::Base
after_create :create_user_stat
after_create :create_user_option
after_create :create_user_profile
+ after_create :set_random_avatar
after_create :ensure_in_trust_level_group
after_create :set_default_categories_preferences
@@ -612,8 +613,7 @@ class User < ActiveRecord::Base
end
def self.gravatar_template(email)
- email_hash = self.email_hash(email)
- "//www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
+ "//www.gravatar.com/avatar/#{self.email_hash(email)}.png?s={size}&r=pg&d=identicon"
end
# Don't pass this up to the client - it's meant for server side use
@@ -628,19 +628,19 @@ class User < ActiveRecord::Base
UrlHelper.schemaless UrlHelper.absolute avatar_template
end
+ def self.username_hash(username)
+ username.each_char.reduce(0) do |result, char|
+ [((result << 5) - result) + char.ord].pack('L').unpack('l').first
+ end.abs
+ end
+
def self.default_template(username)
if SiteSetting.default_avatars.present?
- split_avatars = SiteSetting.default_avatars.split("\n")
- if split_avatars.present?
- hash = username.each_char.reduce(0) do |result, char|
- [((result << 5) - result) + char.ord].pack('L').unpack('l').first
- end
-
- split_avatars[hash.abs % split_avatars.size]
- end
- else
- system_avatar_template(username)
+ urls = SiteSetting.default_avatars.split("\n")
+ return urls[username_hash(username) % urls.size] if urls.present?
end
+
+ system_avatar_template(username)
end
def self.avatar_template(username, uploaded_avatar_id)
@@ -1018,6 +1018,18 @@ class User < ActiveRecord::Base
UserProfile.create(user_id: id)
end
+ def set_random_avatar
+ if SiteSetting.selectable_avatars_enabled? && SiteSetting.selectable_avatars.present?
+ urls = SiteSetting.selectable_avatars.split("\n")
+ if urls.present?
+ if upload = Upload.find_by(url: urls.sample)
+ update_column(:uploaded_avatar_id, upload.id)
+ UserAvatar.create(user_id: id, custom_upload_id: upload.id)
+ end
+ end
+ end
+ end
+
def anonymous?
SiteSetting.allow_anonymous_posting &&
trust_level >= 1 &&
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 37121640b2c..c9fd0e40ecf 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3797,6 +3797,14 @@ en:
clear_filter: "Clear"
add_url: "add URL"
add_host: "add host"
+ uploaded_image_list:
+ label: "Edit list"
+ empty: "There are no pictures yet. Please upload one."
+ upload:
+ label: "Upload"
+ title: "Upload image(s)"
+ selectable_avatars:
+ title: "List of avatars users can choose from"
categories:
all_results: 'All'
required: 'Required'
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index c69fd69cc8c..de9dbd06363 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1339,6 +1339,9 @@ en:
external_system_avatars_enabled: "Use external system avatars service."
external_system_avatars_url: "URL of the external system avatars service. Allowed substitutions are {username} {first_letter} {color} {size}"
+ selectable_avatars_enabled: "Force users to choose an avatar from the list."
+ selectable_avatars: "List of avatars users can choose from."
+
default_opengraph_image_url: "URL of the default opengraph image."
twitter_summary_large_image_url: "URL of the default Twitter summary card image (should be at least 280px in width, and at least 150px in height)."
diff --git a/config/routes.rb b/config/routes.rb
index 3793b7baed5..79536cbf4f8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,6 +54,7 @@ Discourse::Application.routes.draw do
get "site/basic-info" => 'site#basic_info'
get "site/statistics" => 'site#statistics'
+ get "site/selectable-avatars" => "site#selectable_avatars"
get "srv/status" => "forums#status"
@@ -405,6 +406,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/preferences/second-factor-backup" => "users#preferences", constraints: { username: RouteFormat.username }
delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
+ put "#{root_path}/:username/preferences/avatar/select" => "users#select_avatar", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username }
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 68f64d09f7f..e808918656f 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -959,6 +959,12 @@ files:
client: true
regex: '^((https?:)?\/)?\/.+[^\/]'
shadowed_by_global: true
+ selectable_avatars_enabled:
+ default: false
+ client: true
+ selectable_avatars:
+ default: ''
+ type: uploaded_image_list
allow_all_attachments_for_group_messages: false
png_to_jpg_quality:
default: 95
diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb
index 3e7c0ec3f13..caf34f62002 100644
--- a/lib/site_settings/type_supervisor.rb
+++ b/lib/site_settings/type_supervisor.rb
@@ -29,7 +29,8 @@ class SiteSettings::TypeSupervisor
regex: 13,
email: 14,
username: 15,
- category: 16
+ category: 16,
+ uploaded_image_list: 17,
)
end
diff --git a/spec/components/site_settings/type_supervisor_spec.rb b/spec/components/site_settings/type_supervisor_spec.rb
index 3e4fbae1cd9..e404213508b 100644
--- a/spec/components/site_settings/type_supervisor_spec.rb
+++ b/spec/components/site_settings/type_supervisor_spec.rb
@@ -73,6 +73,12 @@ describe SiteSettings::TypeSupervisor do
it "'username' should be at 15th position" do
expect(SiteSettings::TypeSupervisor.types[:username]).to eq(15)
end
+ it "'category' should be at 16th position" do
+ expect(SiteSettings::TypeSupervisor.types[:category]).to eq(16)
+ end
+ it "'uploaded_image_list' should be at 17th position" do
+ expect(SiteSettings::TypeSupervisor.types[:uploaded_image_list]).to eq(17)
+ end
end
end
diff --git a/spec/jobs/clean_up_uploads_spec.rb b/spec/jobs/clean_up_uploads_spec.rb
index b0424058211..476e6d8e3cd 100644
--- a/spec/jobs/clean_up_uploads_spec.rb
+++ b/spec/jobs/clean_up_uploads_spec.rb
@@ -46,12 +46,27 @@ describe Jobs::CleanUpUploads do
it "does not clean up uploads in site settings" do
logo_upload = fabricate_upload
+ logo_small_upload = fabricate_upload
+ favicon_upload = fabricate_upload
+ apple_touch_icon_upload = fabricate_upload
+ avatar1_upload = fabricate_upload
+ avatar2_upload = fabricate_upload
+
SiteSetting.logo_url = logo_upload.url
+ SiteSetting.logo_small_url = logo_small_upload.url
+ SiteSetting.favicon_url = favicon_upload.url
+ SiteSetting.apple_touch_icon_url = apple_touch_icon_upload.url
+ SiteSetting.selectable_avatars = [avatar1_upload.url, avatar2_upload.url].join("\n")
Jobs::CleanUpUploads.new.execute(nil)
expect(Upload.exists?(id: @upload.id)).to eq(false)
expect(Upload.exists?(id: logo_upload.id)).to eq(true)
+ expect(Upload.exists?(id: logo_small_upload.id)).to eq(true)
+ expect(Upload.exists?(id: favicon_upload.id)).to eq(true)
+ expect(Upload.exists?(id: apple_touch_icon_upload.id)).to eq(true)
+ expect(Upload.exists?(id: avatar1_upload.id)).to eq(true)
+ expect(Upload.exists?(id: avatar2_upload.id)).to eq(true)
end
it "does not clean up uploads in site settings when they use the CDN" do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index e91c3e86c8c..a8ed543876a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1771,4 +1771,18 @@ describe User do
end
end
+ describe "set_random_avatar" do
+ it "sets a random avatar when selectable avatars is enabled" do
+ avatar1 = Fabricate(:upload)
+ avatar2 = Fabricate(:upload)
+ SiteSetting.selectable_avatars_enabled = true
+ SiteSetting.selectable_avatars = [avatar1.url, avatar2.url].join("\n")
+
+ user = Fabricate(:user)
+ expect(user.uploaded_avatar_id).not_to be(nil)
+ expect([avatar1.id, avatar2.id]).to include(user.uploaded_avatar_id)
+ expect(user.user_avatar.custom_upload_id).to eq(user.uploaded_avatar_id)
+ end
+ end
+
end
diff --git a/spec/requests/site_controller_spec.rb b/spec/requests/site_controller_spec.rb
index b084dec7b19..433c685ae9b 100644
--- a/spec/requests/site_controller_spec.rb
+++ b/spec/requests/site_controller_spec.rb
@@ -56,4 +56,30 @@ describe SiteController do
expect(response).to redirect_to '/'
end
end
+
+ describe '.selectable_avatars' do
+ before do
+ SiteSetting.selectable_avatars = "https://www.discourse.org\nhttps://meta.discourse.org"
+ end
+
+ it 'returns empty array when selectable avatars is disabled' do
+ SiteSetting.selectable_avatars_enabled = false
+
+ get "/site/selectable-avatars.json"
+ json = JSON.parse(response.body)
+
+ expect(response.status).to eq(200)
+ expect(json).to eq([])
+ end
+
+ it 'returns an array when selectable avatars is enabled' do
+ SiteSetting.selectable_avatars_enabled = true
+
+ get "/site/selectable-avatars.json"
+ json = JSON.parse(response.body)
+
+ expect(response.status).to eq(200)
+ expect(json).to contain_exactly("https://www.discourse.org", "https://meta.discourse.org")
+ end
+ end
end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index 3fd4f18a9f5..9b213c5aaba 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -1785,6 +1785,61 @@ describe UsersController do
end
end
+ describe '#select_avatar' do
+ it 'raises an error when not logged in' do
+ put "/u/asdf/preferences/avatar/select.json", params: { url: "https://meta.discourse.org" }
+ expect(response.status).to eq(403)
+ end
+
+ context 'while logged in' do
+
+ let!(:user) { sign_in(Fabricate(:user)) }
+ let(:avatar1) { Fabricate(:upload) }
+ let(:avatar2) { Fabricate(:upload) }
+ let(:url) { "https://www.discourse.org" }
+
+ it 'raises an error when url is blank' do
+ put "/u/#{user.username}/preferences/avatar/select.json", params: { url: "" }
+ expect(response.status).to eq(422)
+ end
+
+ it 'raises an error when selectable avatars is disabled' do
+ put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
+ expect(response.status).to eq(422)
+ end
+
+ context 'selectable avatars is enabled' do
+
+ before { SiteSetting.selectable_avatars_enabled = true }
+
+ it 'raises an error when selectable avatars is empty' do
+ put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
+ expect(response.status).to eq(422)
+ end
+
+ context 'selectable avatars is properly setup' do
+
+ before do
+ SiteSetting.selectable_avatars = [avatar1.url, avatar2.url].join("\n")
+ end
+
+ it 'raises an error when url is not in selectable avatars list' do
+ put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
+ expect(response.status).to eq(422)
+ end
+
+ it 'can successfully select an avatar' do
+ put "/u/#{user.username}/preferences/avatar/select.json", params: { url: avatar1.url }
+
+ expect(response.status).to eq(200)
+ expect(user.reload.uploaded_avatar_id).to eq(avatar1.id)
+ expect(user.user_avatar.reload.custom_upload_id).to eq(avatar1.id)
+ end
+ end
+ end
+ end
+ end
+
describe '#destroy_user_image' do
it 'raises an error when not logged in' do
diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6
index b70b13a061f..bf0f1ea68c7 100644
--- a/test/javascripts/acceptance/preferences-test.js.es6
+++ b/test/javascripts/acceptance/preferences-test.js.es6
@@ -163,11 +163,41 @@ QUnit.test("second factor backup", assert => {
});
});
+QUnit.test("default avatar selector", assert => {
+ visit("/u/eviltrout/preferences");
+
+ click(".pref-avatar .btn");
+ andThen(() => {
+ assert.ok(exists(".avatar-choice", "opens the avatar selection modal"));
+ });
+});
+
+acceptance("Avatar selector when selectable avatars is enabled", {
+ loggedIn: true,
+ settings: { selectable_avatars_enabled: true },
+ beforeEach() {
+ // prettier-ignore
+ server.get("/site/selectable-avatars.json", () => { //eslint-disable-line
+ return [200, { "Content-Type": "application/json" }, [
+ "https://www.discourse.org",
+ "https://meta.discourse.org",
+ ]];
+ });
+ }
+});
+
+QUnit.test("selectable avatars", assert => {
+ visit("/u/eviltrout/preferences");
+
+ click(".pref-avatar .btn");
+ andThen(() => {
+ assert.ok(exists(".selectable-avatars", "opens the avatar selection modal"));
+ });
+});
+
acceptance("User Preferences when badges are disabled", {
loggedIn: true,
- settings: {
- enable_badges: false
- }
+ settings: { enable_badges: false }
});
QUnit.test("visit my preferences", assert => {