custom avatar support

This commit is contained in:
Régis Hanol 2013-08-13 22:08:29 +02:00
parent e5e3164ea1
commit c867b67a0b
34 changed files with 576 additions and 201 deletions

View file

@ -252,12 +252,22 @@ Discourse.BBCode = {
// remove leading <br>s
var content = matches[2].trim();
var avatarImg;
if (opts.lookupAvatarByPostNumber) {
// client-side, we can retrieve the avatar from the post
var postNumber = parseInt(_.find(params, { 'key' : 'post' }).value, 10);
avatarImg = opts.lookupAvatarByPostNumber(postNumber);
} else if (opts.lookupAvatar) {
// server-side, we need to lookup the avatar from the username
avatarImg = opts.lookupAvatar(username);
}
// Arguments for formatting
args = {
username: I18n.t('user.said',{username: username}),
username: I18n.t('user.said', {username: username}),
params: params,
quote: content,
avatarImg: opts.lookupAvatar ? opts.lookupAvatar(username) : void 0
avatarImg: avatarImg
};
// Name of the template

View file

@ -16,6 +16,7 @@ Discourse.Utilities = {
case 'small': return 25;
case 'medium': return 32;
case 'large': return 45;
case 'huge': return 120;
}
return size;
},
@ -50,18 +51,20 @@ Discourse.Utilities = {
return result + "style=\"background-color: #" + color + "; color: #" + textColor + ";\">" + name + "</a>";
},
avatarUrl: function(username, size, template) {
if (!username) return "";
var rawSize = (Discourse.Utilities.translateSize(size) * (window.devicePixelRatio || 1)).toFixed();
avatarUrl: function(template, size) {
if (!template) { return ""; }
var rawSize = Discourse.Utilities.getRawSize(Discourse.Utilities.translateSize(size));
return template.replace(/\{size\}/g, rawSize);
},
if (username.match(/[^A-Za-z0-9_]/)) { return ""; }
if (template) return template.replace(/\{size\}/g, rawSize);
return Discourse.getURL("/users/") + username.toLowerCase() + "/avatar/" + rawSize + "?__ws=" + encodeURIComponent(Discourse.BaseUrl || "");
getRawSize: function(size) {
var pixelRatio = window.devicePixelRatio || 1;
return pixelRatio >= 1.5 ? size * 2 : size;
},
avatarImg: function(options) {
var size = Discourse.Utilities.translateSize(options.size);
var url = Discourse.Utilities.avatarUrl(options.username, options.size, options.avatarTemplate);
var url = Discourse.Utilities.avatarUrl(options.avatarTemplate, size);
// We won't render an invalid url
if (!url || url.length === 0) { return ""; }
@ -71,8 +74,8 @@ Discourse.Utilities = {
return "<img width='" + size + "' height='" + size + "' src='" + url + "' class='" + classes + "'" + title + ">";
},
tinyAvatar: function(username) {
return Discourse.Utilities.avatarImg({ username: username, size: 'tiny' });
tinyAvatar: function(avatarTemplate) {
return Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny' });
},
postUrl: function(slug, topicId, postNumber) {
@ -266,6 +269,28 @@ Discourse.Utilities = {
authorizedExtensions: function() {
return Discourse.SiteSettings.authorized_extensions.replace(/\|/g, ", ");
},
displayErrorForUpload: function(data) {
// deal with meaningful errors first
if (data.jqXHR) {
switch (data.jqXHR.status) {
// cancel from the user
case 0: return;
// entity too large, usually returned from the web server
case 413:
var maxSizeKB = Discourse.SiteSettings.max_image_size_kb;
bootbox.alert(I18n.t('post.errors.image_too_large', { max_size_kb: maxSizeKB }));
return;
// the error message is provided by the server
case 415: // media type not authorized
case 422: // there has been an error on the server (mostly due to FastImage)
bootbox.alert(data.jqXHR.responseText);
return;
}
}
// otherwise, display a generic error message
bootbox.alert(I18n.t('post.errors.upload'));
}
};

View file

@ -0,0 +1,84 @@
/**
This controller supports actions related to updating one's avatar
@class PreferencesAvatarController
@extends Discourse.ObjectController
@namespace Discourse
@module Discourse
**/
Discourse.PreferencesAvatarController = Discourse.ObjectController.extend({
uploading: false,
uploadProgress: 0,
uploadDisabled: Em.computed.or("uploading"),
useGravatar: Em.computed.not("use_uploaded_avatar"),
useUploadedAvatar: Em.computed.alias("use_uploaded_avatar"),
toggleUseUploadedAvatar: function(toggle) {
if (this.get("use_uploaded_avatar") !== toggle) {
var controller = this;
this.set("use_uploaded_avatar", toggle);
Discourse.ajax("/users/" + this.get("username") + "/preferences/avatar/toggle", { type: 'PUT', data: { use_uploaded_avatar: toggle }})
.then(function(result) { controller.set("avatar_template", result.avatar_template); });
}
},
uploadButtonText: function() {
return this.get("uploading") ? I18n.t("user.change_avatar.uploading") : I18n.t("user.change_avatar.upload");
}.property("uploading"),
uploadAvatar: function() {
var controller = this;
var $upload = $("#avatar-input");
// do nothing if no file is selected
if (Em.isEmpty($upload.val())) { return; }
this.set("uploading", true);
// define the upload endpoint
$upload.fileupload({
url: Discourse.getURL("/users/" + this.get("username") + "/preferences/avatar"),
dataType: "json",
timeout: 20000
});
// when there is a progression for the upload
$upload.on("fileuploadprogressall", function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
controller.set("uploadProgress", progress);
});
// when the upload is successful
$upload.on("fileuploaddone", function (e, data) {
// set some properties
controller.setProperties({
has_uploaded_avatar: true,
use_uploaded_avatar: true,
avatar_template: data.result.url,
uploaded_avatar_template: data.result.url
});
});
// when there has been an error with the upload
$upload.on("fileuploadfail", function (e, data) {
Discourse.Utilities.displayErrorForUpload(data);
});
// when the upload is done
$upload.on("fileuploadalways", function (e, data) {
// prevent automatic upload when selecting a file
$upload.fileupload("destroy");
$upload.off();
// clear file input
$upload.val("");
// indicate upload is done
controller.setProperties({
uploading: false,
uploadProgress: 0
});
});
// *actually* launch the upload
$("#avatar-input").fileupload("add", { fileInput: $("#avatar-input") });
}
});

View file

@ -116,16 +116,22 @@ Handlebars.registerHelper('lower', function(property, options) {
@for Handlebars
**/
Handlebars.registerHelper('avatar', function(user, options) {
if (typeof user === 'string') {
user = Ember.Handlebars.get(this, user, options);
}
if( user ) {
if (user) {
var username = Em.get(user, 'username');
if (!username) username = Em.get(user, options.hash.usernamePath);
var avatarTemplate = Ember.get(user, 'avatar_template');
var avatarTemplate;
var template = options.hash.template;
if (template && template !== 'avatar_template') {
avatarTemplate = Em.get(user, template);
if (!avatarTemplate) avatarTemplate = Em.get(user, 'user.' + template);
}
if (!avatarTemplate) avatarTemplate = Em.get(user, 'avatar_template');
if (!avatarTemplate) avatarTemplate = Em.get(user, 'user.avatar_template');
var title;
@ -147,7 +153,6 @@ Handlebars.registerHelper('avatar', function(user, options) {
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
extraClasses: Em.get(user, 'extras') || options.hash.extraClasses,
username: username,
title: title || username,
avatarTemplate: avatarTemplate
}));
@ -158,18 +163,32 @@ Handlebars.registerHelper('avatar', function(user, options) {
/**
Bound avatar helper.
Will rerender whenever the "avatar_template" changes.
@method boundAvatar
@for Handlebars
**/
Ember.Handlebars.registerBoundHelper('boundAvatar', function(user, options) {
var username = Em.get(user, 'username');
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
username: username,
avatarTemplate: Ember.get(user, 'avatar_template')
avatarTemplate: Em.get(user, 'avatar_template')
}));
});
}, 'avatar_template');
/**
Bound avatar helper.
Will rerender whenever the "uploaded_avatar_template" changes.
Only available for the current user.
@method boundUploadedAvatar
@for Handlebars
**/
Ember.Handlebars.registerBoundHelper('boundUploadedAvatar', function(user, options) {
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
avatarTemplate: Em.get(user, 'uploaded_avatar_template')
}));
}, 'uploaded_avatar_template');
/**
Nicely format a date without a binding since the date doesn't need to change.

View file

@ -70,14 +70,14 @@ Discourse.Composer = Discourse.Model.extend({
if (post) {
postDescription = I18n.t('post.' + this.get('action'), {
link: postLink,
replyAvatar: Discourse.Utilities.tinyAvatar(post.get('username')),
replyAvatar: Discourse.Utilities.tinyAvatar(post.get('avatar_template')),
username: this.get('post.username')
});
var replyUsername = post.get('reply_to_user.username');
if (replyUsername && this.get('action') === EDIT) {
postDescription += " " + I18n.t("post.in_reply_to") + " " +
Discourse.Utilities.tinyAvatar(replyUsername) + " " + replyUsername;
var replyAvatarTemplate = post.get('reply_to_user.avatar_template');
if (replyUsername && replyAvatarTemplate && this.get('action') === EDIT) {
postDescription += " " + I18n.t("post.in_reply_to") + " " + Discourse.Utilities.tinyAvatar(replyAvatarTemplate) + " " + replyUsername;
}
}

View file

@ -57,6 +57,7 @@ Discourse.Route.buildRoutes(function() {
this.route('username', { path: '/username' });
this.route('email', { path: '/email' });
this.route('about', { path: '/about-me' });
this.route('avatar', { path: '/avatar' });
});
this.route('invited', { path: 'invited' });

View file

@ -116,4 +116,33 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({
setupController: function(controller, user) {
controller.setProperties({ model: user, newUsername: user.get('username') });
}
});
});
/**
The route for updating a user's avatar
@class PreferencesAvatarRoute
@extends Discourse.RestrictedUserRoute
@namespace Discourse
@module Discourse
**/
Discourse.PreferencesAvatarRoute = Discourse.RestrictedUserRoute.extend({
model: function() {
return this.modelFor('user');
},
renderTemplate: function() {
return this.render({ into: 'user', outlet: 'userOutlet' });
},
// A bit odd, but if we leave to /preferences we need to re-render that outlet
exit: function() {
this._super();
this.render('preferences', { into: 'user', outlet: 'userOutlet', controller: 'preferences' });
},
setupController: function(controller, user) {
controller.setProperties({ model: user });
}
});

View file

@ -30,7 +30,7 @@
{{#unless showExtraInfo}}
<div class='current-username'>
{{#if currentUser}}
<span class='username'><a {{bindAttr href="currentUser.path"}}>{{currentUser.name}}</a></span>
<span class='username'><a {{bindAttr href="currentUser.path"}}>{{currentUser.name}}</a></span>
{{else}}
<button {{action showLogin}} class='btn btn-primary btn-small'>{{i18n log_in}}</button>
{{/if}}
@ -85,7 +85,7 @@
</li>
<li class='current-user'>
{{#if currentUser}}
{{#linkTo 'userActivity.index' currentUser titleKey="current_user" class="icon"}}{{avatar currentUser imageSize="medium" }}{{/linkTo}}
{{#linkTo 'userActivity.index' currentUser titleKey="current_user" class="icon"}}{{boundAvatar currentUser imageSize="medium" }}{{/linkTo}}
{{else}}
<div class="icon not-logged-in-avatar" {{action showLogin}}><i class='icon-user'></i></div>
{{/if}}

View file

@ -0,0 +1,39 @@
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
<h3>{{i18n user.change_avatar.title}}</h3>
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n user.avatar.title}}</label>
<div class="controls">
<label class="radio">
<input type="radio" name="avatar" value="gravatar" {{action toggleUseUploadedAvatar false}}> {{avatar this imageSize="large" template="gravatar_template"}} {{i18n user.change_avatar.gravatar}} <a href="//gravatar.com/emails/" target="_blank" class="btn pad-left" title="{{i18n user.change_avatar.gravatar_title}}">{{i18n user.change}}</a>
</label>
{{#if has_uploaded_avatar}}
<label class="radio">
<input type="radio" name="avatar" value="uploaded_avatar" {{action toggleUseUploadedAvatar true}}> {{boundUploadedAvatar this imageSize="large"}} {{i18n user.change_avatar.uploaded_avatar}}
</label>
{{/if}}
</div>
</div>
<div class="control-group">
<div class="instructions">{{i18n user.change_avatar.upload_instructions}}</div>
<div class="controls">
<div>
<input type="file" id="avatar-input" accept="image/*">
</div>
<button {{action uploadAvatar}} {{bindAttr disabled="uploadDisabled"}} class="btn btn-primary">
<span class="add-upload"><i class="icon-picture"></i><i class="icon-plus"></i></span>
{{uploadButtonText}}
</button>
{{#if uploading}}
<span>{{i18n upload_selector.uploading}} {{uploadProgress}}%</span>
{{/if}}
</div>
</div>
</form>

View file

@ -47,7 +47,17 @@
{{avatar model imageSize="large"}}
</div>
<div class='instructions'>
{{{i18n user.avatar.instructions}}} {{email}}
{{#if Discourse.SiteSettings.allow_uploaded_avatars}}
{{#if use_uploaded_avatar}}
{{{i18n user.avatar.instructions.uploaded_avatar}}}
{{else}}
{{{i18n user.avatar.instructions.gravatar}}} {{email}}
{{/if}}
{{#linkTo "preferences.avatar" class="btn pad-left"}}{{i18n user.change}}{{/linkTo}}
{{else}}
{{{i18n user.avatar.instructions.gravatar}}} {{email}}
<a href="//gravatar.com/emails/" target="_blank" title="{{i18n user.change_avatar.gravatar_title}}" class="btn pad-left">{{i18n user.change}}</a>
{{/if}}
</div>
</div>

View file

@ -29,7 +29,7 @@
{{/if}}
</ul>
<div class='avatar-wrapper'>
{{boundAvatar model imageSize="120"}}
{{boundAvatar model imageSize="huge"}}
</div>
</div>
</div>

View file

@ -38,7 +38,6 @@ Discourse.ActionsHistoryView = Discourse.View.extend({
}
iconsHtml += Discourse.Utilities.avatarImg({
size: 'small',
username: u.get('username'),
avatarTemplate: u.get('avatar_template'),
title: u.get('username')
});

View file

@ -191,8 +191,11 @@ Discourse.ComposerView = Discourse.View.extend({
});
this.editor = editor = Discourse.Markdown.createEditor({
lookupAvatar: function(username) {
return Discourse.Utilities.avatarImg({ username: username, size: 'tiny' });
lookupAvatarByPostNumber: function(postNumber) {
var quotedPost = composerView.get('controller.controllers.topic.postStream.posts').findProperty("post_number", postNumber);
if (quotedPost) {
return Discourse.Utilities.tinyAvatar(quotedPost.get("avatar_template"));
}
}
});
@ -295,27 +298,8 @@ Discourse.ComposerView = Discourse.View.extend({
$uploadTarget.on('fileuploadfail', function (e, data) {
// hide upload status
composerView.set('isUploading', false);
// deal with meaningful errors first
if (data.jqXHR) {
switch (data.jqXHR.status) {
// 0 == cancel from the user
case 0: return;
// 413 == entity too large, usually returned from the web server
case 413:
var type = Discourse.Utilities.isAnImage(data.files[0].name) ? "image" : "attachment";
var maxSizeKB = Discourse.SiteSettings['max_' + type + '_size_kb'];
bootbox.alert(I18n.t('post.errors.' + type + '_too_large', { max_size_kb: maxSizeKB }));
return;
// 415 == media type not authorized
case 415:
// 422 == there has been an error on the server (mostly due to FastImage)
case 422:
bootbox.alert(data.jqXHR.responseText);
return;
}
}
// otherwise, display a generic error message
bootbox.alert(I18n.t('post.errors.upload'));
// display an error message
Discourse.Utilities.displayErrorForUpload(data);
});
// I hate to use Em.run.later, but I don't think there's a way of waiting for a CSS transition
@ -323,11 +307,7 @@ Discourse.ComposerView = Discourse.View.extend({
return Em.run.later(jQuery, (function() {
var replyTitle = $('#reply-title');
composerView.resize();
if (replyTitle.length) {
return replyTitle.putCursorAtEnd();
} else {
return $wmdInput.putCursorAtEnd();
}
return replyTitle.length ? replyTitle.putCursorAtEnd() : $wmdInput.putCursorAtEnd();
}), 300);
},

View file

@ -0,0 +1,21 @@
/**
This view handles rendering of a user's avatar uploader
@class PreferencesAvatarView
@extends Discourse.View
@namespace Discourse
@module Discourse
**/
Discourse.PreferencesAvatarView = Discourse.View.extend({
templateName: "user/avatar",
classNames: ["user-preferences"],
selectedChanged: function() {
var view = this;
Em.run.next(function() {
var value = view.get("controller.use_uploaded_avatar") ? "uploaded_avatar" : "gravatar";
view.$('input:radio[name="avatar"]').val([value]);
});
}.observes('controller.use_uploaded_avatar')
});

View file

@ -3,7 +3,6 @@ require_dependency 'promotion'
class TopicsController < ApplicationController
# Avatar is an image request, not XHR
before_filter :ensure_logged_in, only: [:timings,
:destroy_timings,
:update,
@ -22,8 +21,7 @@ class TopicsController < ApplicationController
before_filter :consider_user_for_promotion, only: :show
skip_before_filter :check_xhr, only: [:avatar, :show, :feed]
caches_action :avatar, cache_path: Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" }
skip_before_filter :check_xhr, only: [:show, :feed]
def show

View file

@ -3,8 +3,7 @@ require_dependency 'user_name_suggester'
class UsersController < ApplicationController
skip_before_filter :check_xhr, only: [:show, :password_reset, :update, :activate_account, :avatar, :authorize_email, :user_preferences_redirect]
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]
before_filter :ensure_logged_in, only: [:username, :update, :change_email, :user_preferences_redirect]
@ -220,25 +219,6 @@ class UsersController < ApplicationController
render json: {value: honeypot_value, challenge: challenge_value}
end
# all avatars are funneled through here
def avatar
# TEMP to catch all missing spots
# raise ActiveRecord::RecordNotFound
user = User.select(:email).where(username_lower: params[:username].downcase).first
if user.present?
# for now we only support gravatar in square (redirect cached for a day),
# later we can use x-sendfile and/or a cdn to serve local
size = determine_avatar_size(params[:size])
url = user.avatar_template.gsub("{size}", size.to_s)
expires_in 1.day
redirect_to url
else
raise ActiveRecord::RecordNotFound
end
end
def password_reset
expires_now()
@ -336,6 +316,46 @@ class UsersController < ApplicationController
methods: :avatar_template) }
end
def avatar
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
file = params[:file] || params[:files].first
# check the file size (note: this might also be done in the web server)
filesize = File.size(file.tempfile)
max_size_kb = SiteSetting.max_image_size_kb * 1024
return render status: 413, text: I18n.t("upload.images.too_large", max_size_kb: max_size_kb) if filesize > max_size_kb
upload = Upload.create_for(user.id, file, filesize)
user.uploaded_avatar = upload
user.use_uploaded_avatar = true
user.save!
Jobs.enqueue(:generate_avatars, upload_id: upload.id)
render json: { url: upload.url }
rescue FastImage::ImageFetchFailure
render status: 422, text: I18n.t("upload.images.fetch_failure")
rescue FastImage::UnknownImageType
render status: 422, text: I18n.t("upload.images.unknown_image_type")
rescue FastImage::SizeNotFound
render status: 422, text: I18n.t("upload.images.size_not_found")
end
def toggle_avatar
params.require(:use_uploaded_avatar)
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
user.use_uploaded_avatar = params[:use_uploaded_avatar]
user.save!
render json: { avatar_template: user.avatar_template }
end
private
def honeypot_value
@ -405,12 +425,4 @@ class UsersController < ApplicationController
auth[:github_user_id] && auth[:github_screen_name] &&
GithubUserInfo.find_by_github_user_id(auth[:github_user_id]).nil?
end
def determine_avatar_size(size)
size = size.to_i
size = 64 if size == 0
size = 10 if size < 10
size = 128 if size > 128
size
end
end

View file

@ -31,11 +31,15 @@ class Upload < ActiveRecord::Base
def destroy
Upload.transaction do
Discourse.store.remove_file(url)
Discourse.store.remove_upload(self)
super
end
end
def extension
File.extname(original_filename)
end
def self.create_for(user_id, file, filesize)
# compute the sha
sha1 = Digest::SHA1.file(file.tempfile).hexdigest

View file

@ -28,6 +28,7 @@ class User < ActiveRecord::Base
has_many :user_visits
has_many :invites
has_many :topic_links
has_many :uploads
has_one :facebook_user_info, dependent: :destroy
has_one :twitter_user_info, dependent: :destroy
@ -41,6 +42,8 @@ class User < ActiveRecord::Base
has_one :user_search_data
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
validates_presence_of :username
validate :username_validator
validates :email, presence: true, uniqueness: true
@ -295,24 +298,38 @@ class User < ActiveRecord::Base
end
def self.avatar_template(email)
user = User.select([:email, :use_uploaded_avatar, :uploaded_avatar_template, :uploaded_avatar_id])
.where(email: email.downcase)
.first
if user.present?
if SiteSetting.allow_uploaded_avatars? && user.use_uploaded_avatar
# the avatars might take a while to generate
# so return the url of the original image in the meantime
user.uploaded_avatar_template.present? ? user.uploaded_avatar_template : user.uploaded_avatar.url
else
User.gravatar_template(email)
end
end
end
def self.gravatar_template(email)
email_hash = self.email_hash(email)
# robohash was possibly causing caching issues
# robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png")
"https://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
"//www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
end
# Don't pass this up to the client - it's meant for server side use
# The only spot this is now used is for self oneboxes in open graph data
# This is used in
# - self oneboxes in open graph data
# - emails
def small_avatar_url
"https://www.gravatar.com/avatar/#{email_hash}.png?s=60&r=pg&d=identicon"
template = User.avatar_template(email)
template.gsub(/\{size\}/, "60")
end
# return null for local avatars, a template for gravatar
def avatar_template
User.avatar_template(email)
end
# Updates the denormalized view counts for all users
def self.update_view_counts
# Update denormalized topics_entered
@ -506,6 +523,9 @@ class User < ActiveRecord::Base
end
end
def has_uploaded_avatar
uploaded_avatar.present?
end
protected
@ -529,7 +549,6 @@ class User < ActiveRecord::Base
end
end
def create_email_token
email_tokens.create(email: email)
end
@ -571,13 +590,13 @@ class User < ActiveRecord::Base
end
end
def send_approval_email
Jobs.enqueue(:user_email,
type: :signup_after_approval,
user_id: id,
email_token: email_tokens.first.token
)
end
def send_approval_email
Jobs.enqueue(:user_email,
type: :signup_after_approval,
user_id: id,
email_token: email_tokens.first.token
)
end
private
@ -647,6 +666,9 @@ end
# blocked :boolean default(FALSE)
# dynamic_favicon :boolean default(FALSE), not null
# title :string(255)
# use_uploaded_avatar :boolean default(FALSE)
# uploaded_avatar_template :string(255)
# uploaded_avatar_id :integer
#
# Indexes
#

View file

@ -14,7 +14,11 @@ class CurrentUserSerializer < BasicUserSerializer
:external_links_in_new_tab,
:dynamic_favicon,
:trust_level,
:can_edit
:can_edit,
:use_uploaded_avatar,
:has_uploaded_avatar,
:gravatar_template,
:uploaded_avatar_template
def include_site_flagged_posts_count?
object.staff?
@ -36,4 +40,8 @@ class CurrentUserSerializer < BasicUserSerializer
true
end
def gravatar_template
User.gravatar_template(object.email)
end
end

View file

@ -111,7 +111,8 @@ class PostSerializer < BasicPostSerializer
def reply_to_user
{
username: object.reply_to_user.username,
name: object.reply_to_user.name
name: object.reply_to_user.name,
avatar_template: object.reply_to_user.avatar_template
}
end

View file

@ -135,7 +135,9 @@ Discourse::Application.routes.draw do
get 'users/:username/preferences/about-me' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'users/:username/preferences/username' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT}
put 'users/:username/preferences/username' => 'users#username', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'users/:username/avatar(/:size)' => 'users#avatar', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'users/:username/preferences/avatar' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT}
put 'users/:username/preferences/avatar/toggle' => 'users#toggle_avatar', constraints: {username: USERNAME_ROUTE_FORMAT}
post 'users/:username/preferences/avatar' => 'users#avatar', 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}
@ -143,7 +145,6 @@ Discourse::Application.routes.draw do
resources :uploads
get 'posts/by_number/:topic_id/:post_number' => 'posts#by_number'
get 'posts/:id/reply-history' => 'posts#reply_history'
resources :posts do
@ -211,9 +212,6 @@ Discourse::Application.routes.draw do
get 'topics/similar_to'
get 'topics/created-by/:username' => 'list#topics_by', as: 'topics_by', constraints: {username: USERNAME_ROUTE_FORMAT}
# Legacy route for old avatars
get 'threads/:topic_id/:post_number/avatar' => 'topics#avatar', constraints: {topic_id: /\d+/, post_number: /\d+/}
# Topic routes
get 't/:slug/:topic_id/wordpress' => 'topics#wordpress', constraints: {topic_id: /\d+/}
get 't/:slug/:topic_id/moderator-liked' => 'topics#moderator_liked', constraints: {topic_id: /\d+/}

View file

@ -0,0 +1,7 @@
class AddAvatarToUsers < ActiveRecord::Migration
def change
add_column :users, :use_uploaded_avatar, :boolean, default: false
add_column :users, :uploaded_avatar_template, :string
add_column :users, :uploaded_avatar_id, :integer
end
end

View file

@ -1,41 +1,31 @@
class LocalStore
def store_upload(file, upload)
unique_sha1 = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0,16]
extension = File.extname(file.original_filename)
clean_name = "#{unique_sha1}#{extension}"
path = "#{relative_base_url}/#{upload.id}/#{clean_name}"
# copy the file to the right location
copy_file(file, "#{public_dir}#{path}")
# url
Discourse.base_uri + path
path = get_path_for_upload(file, upload)
store_file(file, path)
end
def store_optimized_image(file, optimized_image)
# 1234567890ABCDEF_100x200.jpg
filename = [
optimized_image.sha1[6..16],
"_#{optimized_image.width}x#{optimized_image.height}",
optimized_image.extension,
].join
# <rails>/public/uploads/site/_optimized/123/456/<filename>
path = File.join(
relative_base_url,
"_optimized",
optimized_image.sha1[0..2],
optimized_image.sha1[3..5],
filename
)
# copy the file to the right location
copy_file(file, "#{public_dir}#{path}")
# url
Discourse.base_uri + path
path = get_path_for_optimized_image(file, optimized_image)
store_file(file, path)
end
def remove_file(url)
File.delete("#{public_dir}#{url}") if has_been_uploaded?(url)
rescue Errno::ENOENT
# don't care if the file isn't there
def store_avatar(file, upload, size)
path = get_path_for_avatar(file, upload, size)
store_file(file, path)
end
def remove_upload(upload)
remove_file(upload.url)
end
def remove_optimized_image(optimized_image)
remove_file(optimized_image.url)
end
def remove_avatars(upload)
return unless upload.url =~ /avatars/
remove_directory(File.dirname(upload.url))
end
def has_been_uploaded?(url)
@ -63,8 +53,63 @@ class LocalStore
"#{public_dir}#{upload.url}"
end
def absolute_avatar_template(upload)
avatar_template(upload, absolute_base_url)
end
private
def get_path_for_upload(file, upload)
unique_sha1 = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0..15]
extension = File.extname(file.original_filename)
clean_name = "#{unique_sha1}#{extension}"
# path
"#{relative_base_url}/#{upload.id}/#{clean_name}"
end
def get_path_for_optimized_image(file, optimized_image)
# 1234567890ABCDEF_100x200.jpg
filename = [
optimized_image.sha1[6..15],
"_#{optimized_image.width}x#{optimized_image.height}",
optimized_image.extension,
].join
# /uploads/<site>/_optimized/<1A3>/<B5C>/<filename>
File.join(
relative_base_url,
"_optimized",
optimized_image.sha1[0..2],
optimized_image.sha1[3..5],
filename
)
end
def get_path_for_avatar(file, upload, size)
relative_avatar_template(upload).gsub(/\{size\}/, size.to_s)
end
def relative_avatar_template(upload)
avatar_template(upload, relative_base_url)
end
def avatar_template(upload, base_url)
File.join(
base_url,
"avatars",
upload.sha1[0..2],
upload.sha1[3..5],
upload.sha1[6..15],
"{size}#{upload.extension}"
)
end
def store_file(file, path)
# copy the file to the right location
copy_file(file, "#{public_dir}#{path}")
# url
Discourse.base_uri + path
end
def copy_file(file, path)
FileUtils.mkdir_p Pathname.new(path).dirname
# move the file to the right location
@ -74,6 +119,17 @@ class LocalStore
end
end
def remove_file(url)
File.delete("#{public_dir}#{url}") if has_been_uploaded?(url)
rescue Errno::ENOENT
# don't care if the file isn't there
end
def remove_directory(path)
directory = "#{public_dir}/#{path}"
FileUtils.rm_rf(directory)
end
def is_relative?(url)
url.start_with?(relative_base_url)
end

View file

@ -4,34 +4,60 @@ require 'open-uri'
class S3Store
def store_upload(file, upload)
extension = File.extname(file.original_filename)
remote_filename = "#{upload.id}#{upload.sha1}#{extension}"
# <id><sha1><extension>
path = "#{upload.id}#{upload.sha1}#{upload.extension}"
# if this fails, it will throw an exception
upload(file.tempfile, remote_filename, file.content_type)
upload(file.tempfile, path, file.content_type)
# returns the url of the uploaded file
"#{absolute_base_url}/#{remote_filename}"
"#{absolute_base_url}/#{path}"
end
def store_optimized_image(file, optimized_image)
extension = File.extname(file.path)
remote_filename = [
# <id><sha1>_<width>x<height><extension>
path = [
optimized_image.id,
optimized_image.sha1,
"_#{optimized_image.width}x#{optimized_image.height}",
extension
optimized_image.extension
].join
# if this fails, it will throw an exception
upload(file, remote_filename)
upload(file, path)
# returns the url of the uploaded file
"#{absolute_base_url}/#{remote_filename}"
"#{absolute_base_url}/#{path}"
end
def store_avatar(file, upload, size)
# /avatars/<sha1>/200.jpg
path = File.join(
"avatars",
upload.sha1,
"#{size}#{upload.extension}"
)
# if this fails, it will throw an exception
upload(file, path)
# returns the url of the avatar
"#{absolute_base_url}/#{path}"
end
def remove_upload(upload)
remove_file(upload.url)
end
def remove_optimized_image(optimized_image)
remove_file(optimized_image.url)
end
def remove_avatars(upload)
end
def remove_file(url)
check_missing_site_settings
return unless has_been_uploaded?(url)
name = File.basename(url)
remove(name)

View file

@ -23,7 +23,7 @@ module Oneboxer
return @url unless Guardian.new.can_see?(user)
args.merge! avatar: PrettyText.avatar_img(user.username, 'tiny'), username: user.username
args.merge! avatar: PrettyText.avatar_img(user.avatar_template, 'tiny'), username: user.username
args[:bio] = user.bio_cooked if user.bio_cooked.present?
@template = 'user'
@ -58,7 +58,7 @@ module Oneboxer
posters = topic.posters_summary.map do |p|
{username: p[:user][:username],
avatar: PrettyText.avatar_img(p[:user][:username], 'tiny'),
avatar: PrettyText.avatar_img(p[:user][:avatar_template], 'tiny'),
description: p[:description],
extras: p[:extras]}
end

View file

@ -64,7 +64,7 @@ module PrettyText
return "" unless username
user = User.where(username_lower: username.downcase).first
if user
if user.present?
user.avatar_template
end
end
@ -139,7 +139,7 @@ module PrettyText
v8['opts'] = opts || {}
v8['raw'] = text
v8.eval('opts["mentionLookup"] = function(u){return helpers.is_username_valid(u);}')
v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({username: p, size: "tiny", avatarTemplate: helpers.avatar_template(p)});}')
v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({size: "tiny", avatarTemplate: helpers.avatar_template(p)});}')
baked = v8.eval('Discourse.Markdown.markdownConverter(opts).makeHtml(raw)')
end
@ -149,15 +149,15 @@ module PrettyText
end
# leaving this here, cause it invokes v8, don't want to implement twice
def self.avatar_img(username, size)
def self.avatar_img(avatar_template, size)
r = nil
@mutex.synchronize do
v8['username'] = username
v8['avatarTemplate'] = avatar_template
v8['size'] = size
v8.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};")
v8.eval("Discourse.CDN = '#{Rails.configuration.action_controller.asset_host}';")
v8.eval("Discourse.BaseUrl = '#{RailsMultisite::ConnectionManagement.current_hostname}';")
r = v8.eval("Discourse.Utilities.avatarImg({ username: username, size: size });")
r = v8.eval("Discourse.Utilities.avatarImg({ avatarTemplate: avatarTemplate, size: size });")
end
r
end

View file

@ -135,7 +135,7 @@ describe CookedPostProcessor do
it "generates overlay information" do
cpp.post_process_images
cpp.html.should match_html '<div><a href="http://test.localhost/uploads/default/1/1234567890123456.jpg" class="lightbox"><img src="http://test.localhost/uploads/default/_optimized/da3/9a3/ee5e6b4b0d3_100x200.jpg" width="690" height="1380"><div class="meta">
cpp.html.should match_html '<div><a href="http://test.localhost/uploads/default/1/1234567890123456.jpg" class="lightbox"><img src="http://test.localhost/uploads/default/_optimized/da3/9a3/ee5e6b4b0d_100x200.jpg" width="690" height="1380"><div class="meta">
<span class="filename">uploaded.jpg</span><span class="informations">1000x2000 1.21 KB</span><span class="expand"></span>
</div></a></div>'
cpp.should be_dirty

View file

@ -35,25 +35,38 @@ describe LocalStore do
it "returns a relative url" do
store.expects(:copy_file)
store.store_optimized_image({}, optimized_image).should == "/uploads/default/_optimized/86f/7e4/37faa5a7fce_100x200.png"
store.store_optimized_image({}, optimized_image).should == "/uploads/default/_optimized/86f/7e4/37faa5a7fc_100x200.png"
end
end
describe "remove_file" do
describe "remove_upload" do
it "does not delete any file" do
it "does not delete non uploaded" do
File.expects(:delete).never
store.remove_file("/path/to/file")
upload = Upload.new
upload.stubs(:url).returns("/path/to/file")
store.remove_upload(upload)
end
it "deletes the file locally" do
File.expects(:delete)
store.remove_file("/uploads/default/42/253dc8edf9d4ada1.png")
upload = Upload.new
upload.stubs(:url).returns("/uploads/default/42/253dc8edf9d4ada1.png")
store.remove_upload(upload)
end
end
describe "remove_optimized_image" do
end
describe "remove_avatar" do
end
describe "has_been_uploaded?" do
it "identifies local or relatives urls" do

View file

@ -40,6 +40,7 @@ describe S3Store do
it "returns a relative url" do
upload.stubs(:id).returns(42)
upload.stubs(:extension).returns(".png")
store.store_upload(uploaded_file, upload).should == "//s3_upload_bucket.s3.amazonaws.com/42e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98.png"
end
@ -54,20 +55,32 @@ describe S3Store do
end
describe "remove_file" do
describe "remove_upload" do
it "does not delete any file" do
it "does not delete non uploaded file" do
store.expects(:remove).never
store.remove_file("//other_bucket.s3.amazonaws.com/42.png")
upload = Upload.new
upload.stubs(:url).returns("//other_bucket.s3.amazonaws.com/42.png")
store.remove_upload(upload)
end
it "deletes the file on s3" do
store.expects(:remove)
store.remove_file("//s3_upload_bucket.s3.amazonaws.com/42.png")
upload = Upload.new
upload.stubs(:url).returns("//s3_upload_bucket.s3.amazonaws.com/42.png")
store.remove_upload(upload)
end
end
describe "remove_optimized_image" do
end
describe "remove_avatar" do
end
describe "has_been_uploaded?" do
it "identifies S3 uploads" do

View file

@ -628,7 +628,7 @@ describe Guardian do
Guardian.new(nil).can_see_flags?(post).should be_false
end
it "allow regular uses to see flags" do
it "allow regular users to see flags" do
Guardian.new(user).can_see_flags?(post).should be_false
end

View file

@ -14,18 +14,27 @@ test
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"][sam][/quote]").should =~ /\[sam\]/
end
it "produces a quote even with new lines in it" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"/users/eviltrout/avatar/40?__ws=http%3A%2F%2Ftest.localhost\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
describe "with avatar" do
it "should produce a quote" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"/users/eviltrout/avatar/40?__ws=http%3A%2F%2Ftest.localhost\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
before(:each) do
eviltrout = User.new
eviltrout.stubs(:avatar_template).returns("http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png")
User.expects(:where).with(username_lower: "eviltrout").returns([eviltrout])
end
it "trims spaces on quote params" do
PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"555\" data-topic=\"666\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"/users/eviltrout/avatar/40?__ws=http%3A%2F%2Ftest.localhost\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
it "produces a quote even with new lines in it" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
it "should produce a quote" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
it "trims spaces on quote params" do
PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"555\" data-topic=\"666\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>"
end
end
it "should handle 3 mentions in a row" do
PrettyText.cook('@hello @hello @hello').should match_html "<p><span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span></p>"

View file

@ -19,7 +19,7 @@ describe OptimizedImage do
oi.extension.should == ".jpg"
oi.width.should == 100
oi.height.should == 200
oi.url.should == "/uploads/default/_optimized/da3/9a3/ee5e6b4b0d3_100x200.jpg"
oi.url.should == "/uploads/default/_optimized/da3/9a3/ee5e6b4b0d_100x200.jpg"
end
end

View file

@ -2,7 +2,6 @@
module("Discourse.BBCode");
var format = function(input, expected, text) {
var cooked = Discourse.Markdown.cook(input, {lookupAvatar: false});
equal(cooked, "<p>" + expected + "</p>", text);
};

View file

@ -110,33 +110,25 @@ test("isAnImage", function() {
});
test("avatarUrl", function() {
blank(Discourse.Utilities.avatarUrl('', 'tiny'), "no avatar url returns blank");
blank(Discourse.Utilities.avatarUrl('this is not a username', 'tiny'), "invalid username returns blank");
equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=", "simple avatar url");
equal(Discourse.Utilities.avatarUrl('eviltrout', 'large'), "/users/eviltrout/avatar/45?__ws=", "different size");
equal(Discourse.Utilities.avatarUrl('EvilTrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=", "lowercases username");
equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny', 'test{size}'), "test20", "replaces the size in a template");
});
test("avatarUrl with a baseUrl", function() {
Discourse.BaseUrl = "http://try.discourse.org";
equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=http%3A%2F%2Ftry.discourse.org", "simple avatar url");
blank(Discourse.Utilities.avatarUrl('', 'tiny'), "no template returns blank");
equal(Discourse.Utilities.avatarUrl('/fake/template/{size}.png', 'tiny'), "/fake/template/20.png", "simple avatar url");
equal(Discourse.Utilities.avatarUrl('/fake/template/{size}.png', 'large'), "/fake/template/45.png", "different size");
});
test("avatarImg", function() {
equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny'}),
"<img width='20' height='20' src='/users/eviltrout/avatar/20?__ws=' class='avatar'>",
var avatarTemplate = "/path/to/avatar/{size}.png";
equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny'}),
"<img width='20' height='20' src='/path/to/avatar/20.png' class='avatar'>",
"it returns the avatar html");
equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny', title: 'evilest trout'}),
"<img width='20' height='20' src='/users/eviltrout/avatar/20?__ws=' class='avatar' title='evilest trout'>",
equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', title: 'evilest trout'}),
"<img width='20' height='20' src='/path/to/avatar/20.png' class='avatar' title='evilest trout'>",
"it adds a title if supplied");
equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny', extraClasses: 'evil fish'}),
"<img width='20' height='20' src='/users/eviltrout/avatar/20?__ws=' class='avatar evil fish'>",
equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', extraClasses: 'evil fish'}),
"<img width='20' height='20' src='/path/to/avatar/20.png' class='avatar evil fish'>",
"it adds extra classes if supplied");
blank(Discourse.Utilities.avatarImg({username: 'weird*username', size: 'tiny'}),
"it doesn't render avatars for invalid usernames");
blank(Discourse.Utilities.avatarImg({avatarTemplate: "", size: 'tiny'}),
"it doesn't render avatars for invalid avatar template");
});