Merge pull request #2036 from ArmedGuy/profile-backgrounds

FEATURE: Profile Backgrounds
This commit is contained in:
Régis Hanol 2014-03-05 16:00:49 +01:00
commit bce4130d39
17 changed files with 292 additions and 39 deletions

View file

@ -61,6 +61,19 @@ 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')) {
@ -317,6 +330,22 @@ Discourse.User = Discourse.Model.extend({
});
},
/*
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.

View file

@ -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();
}
}
});

View file

@ -74,6 +74,26 @@
</div>
</div>
{{#if Discourse.SiteSettings.allow_profile_backgrounds}}
<div class="control-group">
<label class="control-label">{{i18n user.change_profile_background.title}}</label>
<div class="controls">
<input type="file" id="profile-background-input" accept="image/*" style="display:none" />
<div id="profile-background-preview" class="input-xxlarge" {{bind-attr style="profileBackground"}}>
<div id="profile-background-controls">
<button {{action showProfileBackgroundFileSelector}} class="btn pad-left no-text"><i class="fa fa-picture-o"></i></button>
{{#if profileBackground}}
<button {{action clearProfileBackground}} class="btn btn-danger pad-left no-text"><i class="fa fa-trash-o"></i></button>
{{/if}}
{{#if view.uploading}}
<span class="btn">{{i18n upload_selector.uploading}} {{view.uploadProgress}}%</span>
{{/if}}
</div>
</div>
</div>
</div>
{{/if}}
{{#if allowUserLocale}}
<div class="control-group">
<label class="control-label">{{i18n user.locale.title}}</label>

View file

@ -40,7 +40,7 @@
</section>
<section class='user-main'>
<section {{bind-attr class="collapsedInfo :about"}}>
<section {{bind-attr class="collapsedInfo :about"}} {{bind-attr style="profileBackground"}}>
<div class='details'>
<div class='primary'>

View file

@ -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

View file

@ -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");
}
});

View file

@ -41,6 +41,19 @@
}
}
#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;
margin-top: 5px;
@ -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;

View file

@ -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;

View file

@ -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
@ -299,6 +299,14 @@ class UsersController < ApplicationController
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)
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
upload_avatar_for(user, avatar)
rescue Discourse::InvalidParameters
render status: 422, text: I18n.t("upload.images.unknown_image_type")
@ -341,6 +358,16 @@ 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
@ -381,6 +408,16 @@ class UsersController < ApplicationController
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(

View file

@ -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

View file

@ -8,6 +8,7 @@ class UserSerializer < BasicUserSerializer
:bio_cooked,
:created_at,
:website,
:profile_background,
:can_edit,
:can_edit_username,
:can_edit_email,

View file

@ -301,6 +301,9 @@ en:
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"
instructions: "Your email will never be shown to the public."

View file

@ -821,6 +821,8 @@ 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."

View file

@ -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}

View file

@ -287,6 +287,9 @@ files:
default: ''
enum: 'S3RegionSiteSetting'
s3_upload_bucket: ''
allow_profile_backgrounds:
client: true
default: true
allow_uploaded_avatars:
client: true
default: true

View file

@ -0,0 +1,5 @@
class AddProfileBackgroundToUser < ActiveRecord::Migration
def change
add_column :users, :profile_background, :string, limit: 255
end
end

View file

@ -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 'is successful' do
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 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)
@ -1164,26 +1190,48 @@ describe UsersController do
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 'is successful' do
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 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)
@ -1191,10 +1239,11 @@ describe UsersController do
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
@ -1234,6 +1283,32 @@ describe UsersController do
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
lambda { xhr :delete, :destroy, username: 'nobody' }.should raise_error(Discourse::NotLoggedIn)