diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 56df2846517..8fc12997dbc 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -60,7 +60,20 @@ Discourse.User = Discourse.Model.extend({ return this.get('website').split("/")[2]; }.property('website'), + + /** + This user's profile background(in CSS). + @property websiteName + @type {String} + **/ + profileBackground: function() { + var background = this.get('profile_background'); + if(Em.isEmpty(background) || !Discourse.SiteSettings.allow_profile_backgrounds) { return; } + + return 'background-image: url(' + background + ')'; + }.property('profile_background'), + statusIcon: function() { var desc; if(this.get('admin')) { @@ -316,6 +329,22 @@ Discourse.User = Discourse.Model.extend({ data: { use_uploaded_avatar: useUploadedAvatar } }); }, + + /* + Clear profile background + + @method clearProfileBackground + @returns {Promise} the result of the clear profile background request + */ + clearProfileBackground: function() { + var user = this; + return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/profile_background/clear", { + type: 'PUT', + data: { } + }).then(function() { + user.set('profile_background', null); + }); + }, /** Determines whether the current user is allowed to upload a file. diff --git a/app/assets/javascripts/discourse/routes/preferences_routes.js b/app/assets/javascripts/discourse/routes/preferences_routes.js index 9f4382134b4..8ec049e9d31 100644 --- a/app/assets/javascripts/discourse/routes/preferences_routes.js +++ b/app/assets/javascripts/discourse/routes/preferences_routes.js @@ -43,6 +43,13 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({ )); user.set('avatar_template', avatarSelector.get('avatarTemplate')); avatarSelector.send('closeModal'); + }, + + showProfileBackgroundFileSelector: function() { + $("#profile-background-input").click(); + }, + clearProfileBackground: function() { + this.modelFor('user').clearProfileBackground(); } } }); diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars index b9b2f886366..9343cdc8ea0 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -73,6 +73,26 @@ {{/if}} + + {{#if Discourse.SiteSettings.allow_profile_backgrounds}} +
+ +
+ +
+
+ + {{#if profileBackground}} + + {{/if}} + {{#if view.uploading}} + {{i18n upload_selector.uploading}} {{view.uploadProgress}}% + {{/if}} +
+
+
+
+ {{/if}} {{#if allowUserLocale}}
diff --git a/app/assets/javascripts/discourse/templates/user/user.js.handlebars b/app/assets/javascripts/discourse/templates/user/user.js.handlebars index 8a7accc4c61..0bcf23cc89a 100644 --- a/app/assets/javascripts/discourse/templates/user/user.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/user.js.handlebars @@ -40,7 +40,7 @@
-
+
diff --git a/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js index 54f8e8eb3a9..94d73a8ab93 100644 --- a/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js +++ b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js @@ -33,9 +33,10 @@ Discourse.AvatarSelectorView = Discourse.ModalBodyView.extend({ // define the upload endpoint $upload.fileupload({ - url: Discourse.getURL("/users/" + this.get("controller.username") + "/preferences/avatar"), + url: Discourse.getURL("/users/" + this.get("controller.username") + "/preferences/user_image"), dataType: "json", - fileInput: $upload + fileInput: $upload, + formData: { user_image_type: "avatar" } }); // when a file has been selected diff --git a/app/assets/javascripts/discourse/views/user/preferences_view.js b/app/assets/javascripts/discourse/views/user/preferences_view.js index 1652af24c06..96d9062f324 100644 --- a/app/assets/javascripts/discourse/views/user/preferences_view.js +++ b/app/assets/javascripts/discourse/views/user/preferences_view.js @@ -8,7 +8,48 @@ **/ Discourse.PreferencesView = Discourse.View.extend({ templateName: 'user/preferences', - classNames: ['user-preferences'] + classNames: ['user-preferences'], + + uploading: false, + uploadProgress: 0, + + didInsertElement: function() { + var self = this; + var $upload = $("#profile-background-input"); + + this._super(); + + $upload.fileupload({ + url: Discourse.getURL("/users/" + this.get('controller.model.username') + "/preferences/user_image"), + dataType: "json", + fileInput: $upload, + formData: { user_image_type: "profile_background" } + }); + + $upload.on("fileuploadadd", function() { + self.set("uploading", true); + }); + $upload.on("fileuploadprogressall", function(e, data) { + var progress = parseInt(data.loaded / data.total * 100, 10); + self.set("uploadProgress", progress); + }); + $upload.on("fileuploaddone", function(e, data) { + if(data.result.url) { + self.set("controller.model.profile_background", data.result.url); + } else { + bootbox.alert(I18n.t('post.errors.upload')); + } + }); + $upload.on("fileuploadfail", function(e, data) { + Discourse.Utilities.displayErrorForUpload(data); + }); + $upload.on("fileuploadalways", function() { + self.setProperties({ uploading: false, uploadProgress: 0}); + }); + }, + willDestroyElement: function() { + $("#profile-background-input").fileupload("destroy"); + } }); diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 4cee7f51cd9..7d55f3aa7d1 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -40,6 +40,19 @@ width: 440px; } } + + #profile-background-preview { + height: 270px; + + background-position: center center; + background-size: cover; + + background-color: $secondary_background_color; + } + + #profile-background-controls { + padding: 10px; + } .static { color: $primary_text_color; @@ -174,6 +187,9 @@ .about { background-color: $secondary_background_color; + background-size: cover; + background-position: center center; + margin-bottom: 10px; overflow: hidden; color: $secondary_text_color; diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index cf7fbc601a5..d3da6e2eb12 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -64,6 +64,13 @@ width: 100%; padding: 5px 8px; } + + #profile-background-preview { + height: 150px; + background-position: center center; + background-size: cover; + background-color: $secondary_background_color; + } } #about-me { @@ -120,6 +127,8 @@ .about { background-color: $secondary_background_color; + background-position: center center; + background-size: cover; margin-bottom: 10px; overflow: hidden; color: $primary_text_color; diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 05b23328e9f..7b42a4ece66 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,7 +7,7 @@ class UsersController < ApplicationController skip_before_filter :authorize_mini_profiler, only: [:avatar] skip_before_filter :check_xhr, only: [:show, :password_reset, :update, :activate_account, :authorize_email, :user_preferences_redirect, :avatar] - before_filter :ensure_logged_in, only: [:username, :update, :change_email, :user_preferences_redirect, :upload_avatar, :toggle_avatar, :destroy] + before_filter :ensure_logged_in, only: [:username, :update, :change_email, :user_preferences_redirect, :upload_user_image, :toggle_avatar, :clear_profile_background, :destroy] before_filter :respond_to_suspicious_request, only: [:create] # we need to allow account creation with bad CSRF tokens, if people are caching, the CSRF token on the @@ -297,8 +297,16 @@ class UsersController < ApplicationController size = 128 if size > 128 size end - + def upload_avatar + params[:user_image_type] = "avatar" + + upload_user_image + + end + + def upload_user_image + params.require(:user_image_type) user = fetch_user_from_params guardian.ensure_can_edit!(user) @@ -308,17 +316,26 @@ class UsersController < ApplicationController # TODO: Does not protect from huge uploads # https://github.com/discourse/discourse/pull/1512 # check the file size (note: this might also be done in the web server) - avatar = build_avatar_from(file) - avatar_policy = AvatarUploadPolicy.new(avatar) + img = build_user_image_from(file) + upload_policy = AvatarUploadPolicy.new(img) - if avatar_policy.too_big? + if upload_policy.too_big? return render status: 413, text: I18n.t("upload.images.too_large", - max_size_kb: avatar_policy.max_size_kb) + max_size_kb: upload_policy.max_size_kb) end - raise FastImage::UnknownImageType unless SiteSetting.authorized_image?(avatar.file) - - upload_avatar_for(user, avatar) + raise FastImage::UnknownImageType unless SiteSetting.authorized_image?(img.file) + + upload_type = params[:user_image_type] + + if upload_type == "avatar" + upload_avatar_for(user, img) + elsif upload_type == "profile_background" + upload_profile_background_for(user, img) + else + render status: 422, text: "" + end + rescue Discourse::InvalidParameters render status: 422, text: I18n.t("upload.images.unknown_image_type") @@ -340,7 +357,17 @@ class UsersController < ApplicationController render nothing: true end - + + def clear_profile_background + user = fetch_user_from_params + guardian.ensure_can_edit!(user) + + user.profile_background = "" + user.save! + + render nothing: true + end + def destroy @user = fetch_user_from_params guardian.ensure_can_delete_user!(@user) @@ -364,7 +391,7 @@ class UsersController < ApplicationController challenge end - def build_avatar_from(file) + def build_user_image_from(file) source = if file.is_a?(String) is_api? ? :url : (raise FastImage::UnknownImageType) else @@ -380,7 +407,17 @@ class UsersController < ApplicationController Jobs.enqueue(:generate_avatars, user_id: user.id, upload_id: upload.id) render json: { url: upload.url, width: upload.width, height: upload.height } end - + + def upload_profile_background_for(user, background) + upload = Upload.create_for(user.id, background.file, background.filesize) + user.profile_background = upload.url + user.save! + + # TODO: maybe add a resize job here + + render json: { url: upload.url, width: upload.width, height: upload.height } + end + def respond_to_suspicious_request if suspicious?(params) render( diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index 219492fa932..503662b0418 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -8,11 +8,13 @@ module Jobs uploads_used_in_posts = PostUpload.uniq.pluck(:upload_id) uploads_used_as_avatars = User.uniq.where('uploaded_avatar_id IS NOT NULL').pluck(:uploaded_avatar_id) - + uploads_used_as_profile_backgrounds = User.uniq.where("profile_background IS NOT NULL AND profile_background != ''").pluck(:profile_background) + grace_period = [SiteSetting.clean_orphan_uploads_grace_period_hours, 1].max Upload.where("created_at < ?", grace_period.hour.ago) .where("id NOT IN (?)", uploads_used_in_posts + uploads_used_as_avatars) + .where("url NOT IN (?)", uploads_used_as_profile_backgrounds) .find_each do |upload| upload.destroy end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 4b55a2fd9e4..1d47e991685 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -8,6 +8,7 @@ class UserSerializer < BasicUserSerializer :bio_cooked, :created_at, :website, + :profile_background, :can_edit, :can_edit_username, :can_edit_email, diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 14dc7e1300b..76f5166ec45 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -297,6 +297,9 @@ en: uploaded_avatar_empty: "Add a custom picture" upload_title: "Upload your picture" image_is_not_a_square: "Warning: we've cropped your image; it is not square." + + change_profile_background: + title: "Change Profile Background" email: title: "Email" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fe50842f556..949b19a626e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -809,7 +809,9 @@ en: detect_custom_avatars: "Whether or not to check that users have uploaded custom avatars" max_daily_gravatar_crawls: "The maximum amount of times Discourse will check gravatar for custom avatars in a day" - + + allow_profile_backgrounds: "Allows users to upload profile backgrounds" + sequential_replies_threshold: "The amount of posts a user has to make in a row in a topic before being notified" enable_mobile_theme: "Mobile devices use a mobile-friendly theme, with the ability to switch to the full site. Disable this if you want to use a custom stylesheet that is fully responsive." diff --git a/config/routes.rb b/config/routes.rb index d42072a6e37..bdcec4a48f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -181,7 +181,9 @@ Discourse::Application.routes.draw do put "users/:username/preferences/username" => "users#username", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/avatar(/:size)" => "users#avatar", constraints: {username: USERNAME_ROUTE_FORMAT} # LEGACY ROUTE post "users/:username/preferences/avatar" => "users#upload_avatar", constraints: {username: USERNAME_ROUTE_FORMAT} + post "users/:username/preferences/user_image" => "users#upload_user_image", constraints: {username: USERNAME_ROUTE_FORMAT} put "users/:username/preferences/avatar/toggle" => "users#toggle_avatar", constraints: {username: USERNAME_ROUTE_FORMAT} + put "users/:username/preferences/profile_background/clear" => "users#clear_profile_background", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/invited" => "users#invited", constraints: {username: USERNAME_ROUTE_FORMAT} post "users/:username/send_activation_email" => "users#send_activation_email", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/activity" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} diff --git a/config/site_settings.yml b/config/site_settings.yml index ef00962fb4a..c2abbb74053 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -270,6 +270,9 @@ files: default: '' enum: 'S3RegionSiteSetting' s3_upload_bucket: '' + allow_profile_backgrounds: + client: true + default: true allow_uploaded_avatars: client: true default: true diff --git a/db/migrate/20140224232712_add_profile_background_to_user.rb b/db/migrate/20140224232712_add_profile_background_to_user.rb new file mode 100644 index 00000000000..edf46f5932f --- /dev/null +++ b/db/migrate/20140224232712_add_profile_background_to_user.rb @@ -0,0 +1,5 @@ +class AddProfileBackgroundToUser < ActiveRecord::Migration + def change + add_column :users, :profile_background, :string, limit: 255 + end +end \ No newline at end of file diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 2751ed39995..09a3589d555 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -1092,55 +1092,65 @@ describe UsersController do end end - describe '.upload_avatar' do + describe '.upload_user_image' do it 'raises an error when not logged in' do - lambda { xhr :put, :upload_avatar, username: 'asdf' }.should raise_error(Discourse::NotLoggedIn) + lambda { xhr :put, :upload_user_image, username: 'asdf' }.should raise_error(Discourse::NotLoggedIn) end + context 'while logged in' do let!(:user) { log_in } - let(:avatar) do + let(:user_image) do ActionDispatch::Http::UploadedFile.new({ filename: 'logo.png', tempfile: File.new("#{Rails.root}/spec/fixtures/images/logo.png") }) end + + it 'raises an error without a user_image_type param' do + lambda { xhr :put, :upload_user_image, username: user.username }.should raise_error(ActionController::ParameterMissing) + end describe "with uploaded file" do - - it 'raises an error when you don\'t have permission to upload an avatar' do + + it 'raises an error when you don\'t have permission to upload an user image' do Guardian.any_instance.expects(:can_edit?).with(user).returns(false) - xhr :post, :upload_avatar, username: user.username + xhr :post, :upload_user_image, username: user.username, user_image_type: "avatar" response.should be_forbidden end it 'rejects large images' do AvatarUploadPolicy.any_instance.stubs(:too_big?).returns(true) - xhr :post, :upload_avatar, username: user.username, file: avatar + xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "avatar" response.status.should eq 413 end it 'rejects unauthorized images' do SiteSetting.stubs(:authorized_image?).returns(false) - xhr :post, :upload_avatar, username: user.username, file: avatar + xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "avatar" + response.status.should eq 422 + end + + it 'rejects requests with unknown user_image_type' do + xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "asdf" response.status.should eq 422 end - it 'is successful' do + it 'is successful for avatars' do upload = Fabricate(:upload) Upload.expects(:create_for).returns(upload) - # enqueues the avatar generator job + # enqueues the user_image generator job Jobs.expects(:enqueue).with(:generate_avatars, { user_id: user.id, upload_id: upload.id }) - xhr :post, :upload_avatar, username: user.username, file: avatar + xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "avatar" user.reload # erase the previous template user.uploaded_avatar_template.should == nil # link to the right upload user.uploaded_avatar.id.should == upload.id - # automatically set "use_uploaded_avatar" + # automatically set "use_uploaded_user_image" user.use_uploaded_avatar.should == true # returns the url, width and height of the uploaded image json = JSON.parse(response.body) @@ -1148,10 +1158,26 @@ describe UsersController do json['width'].should == 100 json['height'].should == 200 end + + it 'is successful for profile backgrounds' do + upload = Fabricate(:upload) + Upload.expects(:create_for).returns(upload) + xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "profile_background" + user.reload + + user.profile_background.should == "/uploads/default/1/1234567890123456.jpg" + + # returns the url, width and height of the uploaded image + json = JSON.parse(response.body) + json['url'].should == "/uploads/default/1/1234567890123456.jpg" + json['width'].should == 100 + json['height'].should == 200 + end + end describe "with url" do - let(:avatar_url) { "http://cdn.discourse.org/assets/logo.png" } + let(:user_image_url) { "http://cdn.discourse.org/assets/logo.png" } before :each do UsersController.any_instance.stubs(:is_api?).returns(true) @@ -1161,40 +1187,63 @@ describe UsersController do before :each do UriAdapter.any_instance.stubs(:open).returns StringIO.new(fixture_file("images/logo.png")) end - + it 'rejects large images' do AvatarUploadPolicy.any_instance.stubs(:too_big?).returns(true) - xhr :post, :upload_avatar, username: user.username, file: avatar_url + xhr :post, :upload_user_image, username: user.username, file: user_image_url, user_image_type: "profile_background" response.status.should eq 413 end it 'rejects unauthorized images' do SiteSetting.stubs(:authorized_image?).returns(false) - xhr :post, :upload_avatar, username: user.username, file: avatar_url + xhr :post, :upload_user_image, username: user.username, file: user_image_url, user_image_type: "profile_background" + response.status.should eq 422 + end + + it 'rejects requests with unknown user_image_type' do + xhr :post, :upload_user_image, username: user.username, file: user_image_url, user_image_type: "asdf" response.status.should eq 422 end - it 'is successful' do + it 'is successful for avatars' do upload = Fabricate(:upload) Upload.expects(:create_for).returns(upload) - # enqueues the avatar generator job + # enqueues the user_image generator job Jobs.expects(:enqueue).with(:generate_avatars, { user_id: user.id, upload_id: upload.id }) - xhr :post, :upload_avatar, username: user.username, file: avatar_url + xhr :post, :upload_avatar, username: user.username, file: user_image_url, user_image_type: "avatar" user.reload + # erase the previous template user.uploaded_avatar_template.should == nil + # link to the right upload user.uploaded_avatar.id.should == upload.id + # automatically set "use_uploaded_user_image" user.use_uploaded_avatar.should == true - # returns the url, width and height of the uploaded image json = JSON.parse(response.body) json['url'].should == "/uploads/default/1/1234567890123456.jpg" json['width'].should == 100 json['height'].should == 200 end + + it 'is successful for profile backgrounds' do + upload = Fabricate(:upload) + Upload.expects(:create_for).returns(upload) + xhr :post, :upload_user_image, username: user.username, file: user_image_url, user_image_type: "profile_background" + user.reload + + user.profile_background.should == "/uploads/default/1/1234567890123456.jpg" + + # returns the url, width and height of the uploaded image + json = JSON.parse(response.body) + json['url'].should == "/uploads/default/1/1234567890123456.jpg" + json['width'].should == 100 + json['height'].should == 200 + end + end it "should handle malformed urls" do - xhr :post, :upload_avatar, username: user.username, file: "foobar" + xhr :post, :upload_user_image, username: user.username, file: "foobar", user_image_type: "profile_background" response.status.should eq 422 end @@ -1233,6 +1282,32 @@ describe UsersController do end end + + describe '.clear_profile_background' do + + it 'raises an error when not logged in' do + lambda { xhr :put, :clear_profile_background, username: 'asdf' }.should raise_error(Discourse::NotLoggedIn) + end + + context 'while logged in' do + + let!(:user) { log_in } + + it 'raises an error when you don\'t have permission to clear the profile background' do + Guardian.any_instance.expects(:can_edit?).with(user).returns(false) + xhr :put, :clear_profile_background, username: user.username + response.should be_forbidden + end + + it 'it successful' do + xhr :put, :clear_profile_background, username: user.username + user.reload.profile_background.should == "" + response.should be_success + end + + end + + end describe '.destroy' do it 'raises an error when not logged in' do