Work in progress, keeping avatars locally

This introduces a new model to store the avatars and 3 uploads per user (gravatar, system and custom)

user can then pick which they want.
This commit is contained in:
Sam 2014-05-22 17:37:02 +10:00 committed by Sam Saffron
parent 4ccf07be8c
commit 6c1c8be794
42 changed files with 626 additions and 319 deletions

View File

@ -10,12 +10,14 @@
export default Discourse.Controller.extend(Discourse.ModalFunctionality, {
actions: {
useUploadedAvatar: function() { this.set("use_uploaded_avatar", true); },
useGravatar: function() { this.set("use_uploaded_avatar", false); }
},
avatarTemplate: function() {
return this.get("use_uploaded_avatar") ? this.get("uploaded_avatar_template") : this.get("gravatar_template");
}.property("use_uploaded_avatar", "uploaded_avatar_template", "gravatar_template")
useUploadedAvatar: function() {
this.set("selected", "uploaded");
},
useGravatar: function() {
this.set("selected", "gravatar");
},
useSystem: function() {
this.set("selected", "system");
}
}
});

View File

@ -183,16 +183,6 @@ Handlebars.registerHelper('avatar', function(user, options) {
var username = Em.get(user, 'username');
if (!username) username = Em.get(user, options.hash.usernamePath);
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;
if (!options.hash.ignoreTitle) {
// first try to get a title
@ -209,6 +199,10 @@ Handlebars.registerHelper('avatar', function(user, options) {
}
}
// this is simply done to ensure we cache images correctly
var uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id') || "_1";
var avatarTemplate = Discourse.User.avatarTemplate(username,uploadedAvatarId);
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
extraClasses: Em.get(user, 'extras') || options.hash.extraClasses,
@ -228,11 +222,19 @@ Handlebars.registerHelper('avatar', function(user, options) {
@for Handlebars
**/
Ember.Handlebars.registerBoundHelper('boundAvatar', function(user, options) {
var username = Em.get(user, 'username');
console.log(options.hash);
var uploadId = (options.hash.uploadId && Em.get(user, options.hash.uploadId)) || Em.get(user, 'uploaded_avatar_id');
var avatarTemplate = Discourse.User.avatarTemplate(username,uploadId);
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
avatarTemplate: Em.get(user, options.hash.template || 'avatar_template')
avatarTemplate: avatarTemplate
}));
}, 'avatar_template', 'uploaded_avatar_template', 'gravatar_template');
}, 'uploadId', 'username', 'uploaded_avatar_id');
/**
Nicely format a date without binding or returning HTML

View File

@ -326,6 +326,10 @@ Discourse.User = Discourse.Model.extend({
});
},
avatarTemplate: function(){
return Discourse.User.avatarTemplate(this.get('username'),this.get('uploaded_avatar_id'));
}.property('uploaded_avatar_id', 'username'),
/*
Change avatar selection
@ -413,6 +417,11 @@ Discourse.User = Discourse.Model.extend({
});
Discourse.User.reopenClass(Discourse.Singleton, {
avatarTemplate: function(username, uploadedAvatarId){
return Discourse.getURL("/avatar/" + username.toLowerCase() + "/{size}/" + uploadedAvatarId + ".png");
},
/**
Find a `Discourse.User` for a given username.

View File

@ -184,7 +184,7 @@ Discourse.UserAction = Discourse.Model.extend({
switchToActing: function() {
this.setProperties({
username: this.get('acting_username'),
avatar_template: this.get('acting_avatar_template'),
uploaded_avatar_id: this.get('acting_uploaded_avatar_id'),
name: this.get('actingDisplayName')
});
}

View File

@ -22,8 +22,11 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({
// all the properties needed for displaying the avatar selector modal
this.controllerFor('avatar-selector').setProperties(this.modelFor('user').getProperties(
'username', 'email',
'has_uploaded_avatar', 'use_uploaded_avatar',
'gravatar_template', 'uploaded_avatar_template'));
'system_avatar_upload_id',
'gravatr_avatar_upload_id',
'custom_avatar_upload_id'
)
);
},
saveAvatarSelection: function() {

View File

@ -1,15 +1,19 @@
<div class="modal-body">
<div>
<div>
<input type="radio" id="avatar" name="avatar" value="gravatar" {{action useGravatar}}>
<label class="radio" for="avatar">{{avatar controller imageSize="large" template="gravatar_template"}} {{{i18n user.change_avatar.gravatar}}} {{email}}</label>
<a href="//gravatar.com/emails" target="_blank" title="{{i18n user.change_avatar.gravatar_title}}" class="btn no-text"><i class="fa fa-pencil"></i></a>
<input type="radio" id="system-avatar" name="avatar" value="system" {{action useSystem}}>
<label class="radio" for="system-avatar">{{boundAvatar controller imageSize="large" uploadId="system_avatar_upload_id"}} {{{i18n user.change_avatar.letter_based}}}</label>
</div>
<div>
<input type="radio" id="gravatar" name="avatar" value="gravatar" {{action useGravatar}}>
<label class="radio" for="gravatar">{{boundAvatar controller imageSize="large" uploadId="gravatar_avatar_upload_id"}} {{{i18n user.change_avatar.gravatar}}} {{email}}</label>
<a href="#" {{action refreshGravatar}} title="{{i18n user.change_avatar.refresh_gravatar_title}}" class="btn no-text"><i class="fa fa-refresh"></i></a>
</div>
<div>
<input type="radio" id="uploaded_avatar" name="avatar" value="uploaded_avatar" {{action useUploadedAvatar}}>
<label class="radio" for="uploaded_avatar">
{{#if has_uploaded_avatar}}
{{boundAvatar controller imageSize="large" template="uploaded_avatar_template"}} {{i18n user.change_avatar.uploaded_avatar}}
{{#if custom_avatar_upload_id}}
{{boundAvatar controller imageSize="large" uploadId="custom_avatar_upload_id"}} {{i18n user.change_avatar.uploaded_avatar}}
{{else}}
{{i18n user.change_avatar.uploaded_avatar_empty}}
{{/if}}

View File

@ -105,7 +105,7 @@ Discourse.AvatarSelectorView = Discourse.ModalBodyView.extend({
}.observes('controller.use_uploaded_avatar'),
uploadButtonText: function() {
return this.get("uploading") ? I18n.t("uploading") : I18n.t("upload");
return this.get("uploading") ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
}.property("uploading")
});

View File

@ -182,3 +182,11 @@ animation: modal .25s;
}
}
}
.upload-options {
margin-left: 20px;
margin-top: 20px;
}
.uploaded-avatar {
margin-top: 20px;
}

View File

@ -0,0 +1,46 @@
require_dependency 'letter_avatar'
class AvatarController < ApplicationController
skip_before_filter :check_xhr, :verify_authenticity_token
def show
username = params[:username].to_s
raise Discourse::NotFound unless user = User.find_by(username_lower: username.downcase)
size = params[:size].to_i
if size > 1000 || size < 1
raise Discourse::NotFound
end
image = nil
version = params[:version].to_i
raise Discourse::NotFound unless version > 0 && user_avatar = user.user_avatar
upload = version if user_avatar.contains_upload?(version)
upload ||= user.uploaded_avatar if user.uploaded_avatar_id == version
if user.uploaded_avatar && !upload
return redirect_to "/avatar/#{user.username_lower}/#{size}/#{user.uploaded_avatar_id}.png"
elsif upload
# TODO broken with S3 (should retrun a permanent redirect)
original = Discourse.store.path_for(user.uploaded_avatar)
if File.exists?(original)
optimized = OptimizedImage.create_for(
user.uploaded_avatar,
size,
size,
allow_animation: SiteSetting.allow_animated_avatars
)
image = Discourse.store.path_for(optimized)
end
end
if image
expires_in 1.year, public: true
send_file image, disposition: nil
else
raise Discourse::NotFound
end
end
end

View File

@ -422,8 +422,6 @@ class UsersController < ApplicationController
def upload_avatar_for(user, upload)
user.upload_avatar(upload)
Jobs.enqueue(:generate_avatars, user_id: user.id, upload_id: upload.id)
render json: { url: upload.url, width: upload.width, height: upload.height }
end

View File

@ -0,0 +1,10 @@
module Jobs
class CreateMissingAvatars < Jobs::Base
def execute(args)
User.find_each do |u|
u.refresh_avatar
u.save
end
end
end
end

View File

@ -1,67 +0,0 @@
require "image_sorcery"
module Jobs
class GenerateAvatars < Jobs::Base
def execute(args)
raise Discourse::ImageMagickMissing.new unless system("command -v convert >/dev/null;")
upload_id, user_id = args[:upload_id], args[:user_id]
raise Discourse::InvalidParameters.new(:upload_id) if upload_id.blank?
raise Discourse::InvalidParameters.new(:user_id) if user_id.blank?
upload = Upload.find_by(id: upload_id)
user = User.find_by(id: user_id)
return if upload.nil? || user.nil?
external_copy = Discourse.store.download(upload) if Discourse.store.external?
original_path = if Discourse.store.external?
external_copy.path
else
Discourse.store.path_for(upload)
end
source = original_path
# extract the first frame when it's a gif
source << "[0]" unless SiteSetting.allow_animated_avatars
image = ImageSorcery.new(source)
extension = File.extname(original_path)
[120, 45, 32, 25, 20].each do |s|
# handle retina too
[s, s * 2].each do |size|
begin
# create a temp file with the same extension as the original
temp_file = Tempfile.new(["discourse-avatar", extension])
# create a transparent centered square thumbnail
if image.convert(temp_file.path,
gravity: "center",
thumbnail: "#{size}x#{size}^",
extent: "#{size}x#{size}",
background: "transparent")
if Discourse.store.store_avatar(temp_file, upload, size).blank?
Rails.logger.error("Failed to store avatar #{size} for #{upload.url} from #{source}")
end
else
Rails.logger.error("Failed to create avatar #{size} for #{upload.url} from #{source}")
end
ensure
# close && remove temp file
temp_file && temp_file.close!
end
end
end
# make sure we remove the cached copy from external stores
external_copy.close! if Discourse.store.external?
# attach the avatar to the user
user.uploaded_avatar_template = Discourse.store.avatar_template(upload)
user.save!
end
end
end

View File

@ -1,31 +0,0 @@
require_dependency 'avatar_detector'
module Jobs
class DetectAvatars < Jobs::Scheduled
every 8.hours
def execute(args)
return unless SiteSetting.detect_custom_avatars?
# Find a random sampling of users of trust level 1 or higher who don't have a custom avatar.
user_stats = UserStat.where('user_stats.has_custom_avatar = false AND users.trust_level > 0')
.references(:user)
.includes(:user)
.order("random()")
.limit(SiteSetting.max_daily_gravatar_crawls)
if user_stats.present?
user_stats.each do |us|
us.update_column(:has_custom_avatar, true) if AvatarDetector.new(us.user).has_custom_avatar?
UserHistory.create!(
action: UserHistory.actions[:checked_for_custom_avatar],
target_user_id: us.user_id
)
end
end
end
end
end

View File

@ -33,6 +33,7 @@ class User < ActiveRecord::Base
has_many :topic_links, dependent: :destroy
has_many :uploads
has_one :user_avatar, dependent: :destroy
has_one :facebook_user_info, dependent: :destroy
has_one :twitter_user_info, dependent: :destroy
has_one :github_user_info, dependent: :destroy
@ -49,7 +50,7 @@ class User < ActiveRecord::Base
has_one :user_search_data, dependent: :destroy
has_one :api_key, dependent: :destroy
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
belongs_to :uploaded_avatar, class_name: 'Upload'
delegate :last_sent_email_address, :to => :email_logs
@ -72,6 +73,7 @@ class User < ActiveRecord::Base
after_create :create_email_token
after_create :create_user_stat
after_save :refresh_avatar
before_destroy do
# These tables don't have primary keys, so destroying them with activerecord is tricky:
@ -336,25 +338,27 @@ class User < ActiveRecord::Base
"//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
# This is used in
# - self oneboxes in open graph data
# - emails
def small_avatar_url
template = avatar_template
schemaless template.gsub("{size}", "45")
avatar_template_url.gsub("{size}", "45")
end
# the avatars might take a while to generate
# so return the url of the original image in the meantime
def uploaded_avatar_path
return unless SiteSetting.allow_uploaded_avatars? && use_uploaded_avatar
avatar_template = uploaded_avatar_template.present? ? uploaded_avatar_template : uploaded_avatar.try(:url)
def avatar_template_url
schemaless absolute avatar_template
end
def self.avatar_template(username,uploaded_avatar_id)
id = uploaded_avatar_id || -1
username ||= ""
"/avatar/#{username.downcase}/{size}/#{id}.png"
end
def avatar_template
uploaded_avatar_path || User.gravatar_template(id != -1 ? email : "team@discourse.org")
self.class.avatar_template(username,uploaded_avatar_id)
end
# The following count methods are somewhat slow - definitely don't use them in a loop.
@ -616,6 +620,20 @@ class User < ActiveRecord::Base
Jobs.enqueue_in(delay / 2, :update_top_redirection, user_id: self.id, redirected_at: Time.zone.now)
end
def refresh_avatar
avatar = user_avatar || UserAvatar.create!(user_id: id)
if SiteSetting.automatically_download_gravatars?
avatar.update_gravatar! unless avatar.last_gravatar_download_attempt
end
if SiteSetting.enable_system_avatars?
avatar.update_system_avatar! if !avatar.system_upload_id || username_changed?
end
self.uploaded_avatar_id = (avatar.gravatar_upload_id || avatar.system_upload_id) unless uploaded_avatar_id
end
protected
def cook
@ -778,7 +796,6 @@ end
# primary_group_id :integer
# locale :string(10)
# profile_background :string(255)
# email_hash :string(255)
# registration_ip_address :inet
# last_redirected_to_top_at :datetime
#

View File

@ -109,9 +109,9 @@ SELECT
coalesce(p.post_number, 1) post_number,
p.reply_to_post_number,
pu.email, pu.username, pu.name, pu.id user_id,
pu.use_uploaded_avatar, pu.uploaded_avatar_template, pu.uploaded_avatar_id,
pu.uploaded_avatar_id,
u.email acting_email, u.username acting_username, u.name acting_name, u.id acting_user_id,
u.use_uploaded_avatar acting_use_uploaded_avatar, u.uploaded_avatar_template acting_uploaded_avatar_template, u.uploaded_avatar_id acting_uploaded_avatar_id,
u.uploaded_avatar_id acting_uploaded_avatar_id,
coalesce(p.cooked, p2.cooked) cooked,
CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted,
p.hidden,

53
app/models/user_avatar.rb Normal file
View File

@ -0,0 +1,53 @@
require_dependency 'letter_avatar'
class UserAvatar < ActiveRecord::Base
MAX_SIZE = 240
belongs_to :user
belongs_to :system_upload, class_name: 'Upload', dependent: :destroy
belongs_to :gravatar_upload, class_name: 'Upload', dependent: :destroy
belongs_to :custom_upload, class_name: 'Upload', dependent: :destroy
def contains_upload?(id)
system_upload_id == id || gravatar_upload_id == id || custom_upload_id == id
end
# updates the letter based avatar
def update_system_avatar!
system_upload.destroy! if system_upload
file = File.open(LetterAvatar.generate(user.username, MAX_SIZE), "r")
self.system_upload = Upload.create_for(user_id, file, "avatar.png", file.size)
save!
end
def update_gravatar!
self.last_gravatar_download_attempt = Time.new
gravatar_url = "http://www.gravatar.com/avatar/#{user.email_hash}.png?s=500&d=404"
tempfile = FileHelper.download(gravatar_url, 1.megabyte, "gravatar")
upload = Upload.create_for(user.id, tempfile, 'gravatar.png', File.size(tempfile.path))
gravatar_upload.destroy! if gravatar_upload
self.gravatar_upload = upload
save!
rescue OpenURI::HTTPError
save!
ensure
tempfile.unlink if tempfile
end
end
# == Schema Information
#
# Table name: user_avatars
#
# id :integer not null, primary key
# user_id :integer not null
# system_upload_id :integer
# custom_upload_id :integer
# gravatar_upload_id :integer
# last_gravatar_download_attempt :datetime
# created_at :datetime
# updated_at :datetime
#

View File

@ -85,7 +85,6 @@ end
# Table name: user_stats
#
# user_id :integer not null, primary key
# has_custom_avatar :boolean default(FALSE), not null
# topics_entered :integer default(0), not null
# time_read :integer default(0), not null
# days_visited :integer default(0), not null

View File

@ -4,6 +4,7 @@ class BasicPostSerializer < ApplicationSerializer
:name,
:username,
:avatar_template,
:uploaded_avatar_id,
:created_at,
:cooked
@ -19,6 +20,10 @@ class BasicPostSerializer < ApplicationSerializer
object.user.try(:avatar_template)
end
def uploaded_avatar_id
object.user.try(:uploaded_avatar_id)
end
def cooked
if object.hidden && !scope.is_staff?
if scope.current_user && object.user_id == scope.current_user.id

View File

@ -1,7 +1,26 @@
class BasicUserSerializer < ApplicationSerializer
attributes :id, :username, :avatar_template
attributes :id, :username, :uploaded_avatar_id, :avatar_template
def include_name?
SiteSetting.enable_names?
end
# so weird we send a hash in here sometimes and an object others
def include_uploaded_avatar_id?
SiteSetting.allow_uploaded_avatars? &&
(Hash === object ? user[:uploaded_avatar_id] : object.uploaded_avatar_id)
end
def avatar_template
if Hash === object
User.avatar_template(user[:username], user[:uploaded_avatar_id])
else
object.avatar_template
end
end
def user
object[:user] || object
end
end

View File

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

View File

@ -10,13 +10,15 @@ class TopicPostCountSerializer < BasicUserSerializer
object[:user].username
end
def avatar_template
object[:user].avatar_template
end
def post_count
object[:post_count]
end
def uploaded_avatar_id
object[:user].uploaded_avatar_id
end
def include_uploaded_avatar_id?
SiteSetting.allow_uploaded_avatars? && object[:user].use_uploaded_avatar
end
end

View File

@ -23,30 +23,20 @@ class UserActionSerializer < ApplicationSerializer
:hidden,
:moderator_action,
:edit_reason,
:category_id
:category_id,
:uploaded_avatar_id,
:acting_uploaded_avatar_id
def excerpt
PrettyText.excerpt(object.cooked, 300) if object.cooked
end
def avatar_template
avatar_for(
object.user_id,
object.email,
object.use_uploaded_avatar,
object.uploaded_avatar_template,
object.uploaded_avatar_id
)
User.avatar_template(object.username, object.uploaded_avatar_id)
end
def acting_avatar_template
avatar_for(
object.acting_user_id,
object.acting_email,
object.acting_use_uploaded_avatar,
object.acting_uploaded_avatar_template,
object.acting_uploaded_avatar_id
)
User.avatar_template(object.acting_username, object.acting_uploaded_avatar_id)
end
def include_name?

View File

@ -63,16 +63,27 @@ class UserSerializer < BasicUserSerializer
:external_links_in_new_tab,
:dynamic_favicon,
:enable_quoting,
:use_uploaded_avatar,
:has_uploaded_avatar,
:gravatar_template,
:uploaded_avatar_template,
:muted_category_ids,
:tracked_category_ids,
:watched_category_ids,
:private_messages_stats,
:disable_jump_reply
:disable_jump_reply,
:system_avatar_upload_id,
:gravatar_avatar_upload_id,
:custom_avatar_upload_id,
:uploaded_avatar_id
def system_avatar_upload_id
object.user_avatar.try(:system_upload_id)
end
def gravatar_avatar_upload_id
object.user_avatar.try(:gravatar_upload_id)
end
def custom_avatar_upload_id
object.user_avatar.try(:custom_upload_id)
end
def auto_track_topics_after_msecs
object.auto_track_topics_after_msecs || SiteSetting.auto_track_topics_after
@ -106,10 +117,6 @@ class UserSerializer < BasicUserSerializer
UserAction.stats(object.id, scope)
end
def gravatar_template
User.gravatar_template(object.email)
end
def include_suspended?
object.suspended?
end

View File

@ -22,6 +22,7 @@ if defined?(Rack::MiniProfiler)
(path !~ /^\/message-bus/) &&
(path !~ /topics\/timings/) &&
(path !~ /assets/) &&
(path !~ /\/avatar\//) &&
(path !~ /qunit/) &&
(path !~ /srv\/status/) &&
(path !~ /commits-widget/) &&

View File

@ -307,10 +307,12 @@ en:
change_avatar:
title: "Change your avatar"
gravatar: "<a href='//gravatar.com/emails' target='_blank'>Gravatar</a>, based on"
gravatar_title: "Change your avatar on Gravatar's website"
refresh_gravatar_title: "Refresh your Gravatar"
letter_based: "System assigned avatar"
uploaded_avatar: "Custom picture"
uploaded_avatar_empty: "Add a custom picture"
upload_title: "Upload your picture"
upload_picture: "Upload Picture"
image_is_not_a_square: "Warning: we've cropped your image; it is not square."
change_profile_background:

View File

@ -863,6 +863,7 @@ en:
allow_uploaded_avatars: "Allow users to upload their custom avatars"
allow_animated_avatars: "Allow users to use animated gif for avatars. WARNING: it is highly recommended to run the avatars:regenerate rake task after changing that setting."
automatically_download_gravatars: "Download gravatars for users upon account creation or email change"
digest_topics: "The maximum amount of topics to display in an email digest"
digest_min_excerpt_length: "How many characters we're aiming for for each post in the email digest"
default_digest_email_frequency: "How often users receive digest emails by default. They can change this setting in their preferences."

View File

@ -366,6 +366,8 @@ Discourse::Application.routes.draw do
post "draft" => "draft#update"
delete "draft" => "draft#destroy"
get "avatar/:username/:size/:version.png" => "avatar#show", format: false
get "cdn_asset/:site/*path" => "static#cdn_asset", format: false
get "robots.txt" => "robots_txt#index"

View File

@ -329,14 +329,13 @@ files:
allow_profile_backgrounds:
client: true
default: true
automatically_download_gravatars: true
allow_uploaded_avatars:
client: true
default: true
allow_animated_avatars:
client: true
default: false
detect_custom_avatars: true
max_daily_gravatar_crawls: 500
trust:
default_trust_level: 0
@ -547,7 +546,11 @@ uncategorized:
default: ''
enable_cdn_js_debugging: true
show_create_topics_notice:
client: true
default: true
enable_system_avatars:
hidden: true
default: true

View File

@ -23,3 +23,8 @@ User.seed do |u|
u.email_private_messages = false
u.trust_level = TrustLevel.levels[:elder]
end
# download avatars for existing users
if UserAvatar.count < User.count
Jobs.enqueue(:create_missing_avatars)
end

View File

@ -0,0 +1,5 @@
class RemoveHasCustomAvatarFromUserStats < ActiveRecord::Migration
def change
remove_column :user_stats, :has_custom_avatar
end
end

View File

@ -0,0 +1,12 @@
class AddUserAvatars < ActiveRecord::Migration
def change
create_table :user_avatars do |t|
t.integer :user_id, null: false
t.integer :system_upload_id
t.integer :custom_upload_id
t.integer :gravatar_upload_id
t.datetime :last_gravatar_download_attempt
t.timestamps
end
end
end

View File

@ -1,32 +0,0 @@
require_dependency 'user'
require 'net/http'
class AvatarDetector
def initialize(user)
raise "Tried to detect an avatar on a non-user instance" unless user && user.is_a?(User)
@user = user
end
def has_custom_avatar?
return true if @user.uploaded_avatar_path
has_custom_gravatar?
end
# Check whether the user has a gravatar by performing a HTTP HEAD request to
# Gravatar using the `d=404` parameter.
def has_custom_gravatar?
result = Net::HTTP.start('www.gravatar.com') do |http|
http.open_timeout = 2
http.read_timeout = 2
http.head("/avatar/#{User.email_hash(@user.email)}?d=404")
end
return result.code.to_i == 200
rescue
# If the HTTP request fails, assume no gravatar
false
end
end

View File

@ -15,8 +15,6 @@ class AvatarLookup
@lookup_columns ||= [:id,
:email,
:username,
:use_uploaded_avatar,
:uploaded_avatar_template,
:uploaded_avatar_id]
end

View File

@ -47,7 +47,7 @@ class ComposerMessagesFinder
return unless @user.has_trust_level?(:basic)
# We don't notify users who have avatars or who have been notified already.
return if @user.user_stat.has_custom_avatar? || UserHistory.exists_for_user?(@user, :notified_about_avatar)
return if @user.uploaded_avatar_id || UserHistory.exists_for_user?(@user, :notified_about_avatar)
# Finally, we don't check users whose avatars haven't been examined
return unless UserHistory.exists_for_user?(@user, :checked_for_custom_avatar)

303
lib/letter_avatar.rb Normal file
View File

@ -0,0 +1,303 @@
class LetterAvatar
class<<self
FULLSIZE = 240
def cache_path
'tmp/letter_avatars'
end
def generate(username, size)
size = FULLSIZE if size > FULLSIZE
filename = cached_path(username, size)
if File.exists?(filename)
return filename
end
fullsize = fullsize_path(username)
if !File.exists?(fullsize)
generate_fullsize(username)
end
resize(fullsize, filename, size)
end
def cached_path(username, size)
dir = "#{cache_path}/#{username}"
FileUtils.mkdir_p(dir)
"#{dir}/#{size}.png"
end
def fullsize_path(username)
cached_path(username, FULLSIZE)
end
def resize(from, to, size)
`convert #{from} -resize #{size}x#{size} #{to}`
to
end
def generate_fullsize(username)
filename = fullsize_path(username)
color = colors[Digest::MD5.hexdigest(username)[0...15].to_i(16) % 216]
stroke = darken(color, 0.8)
instructions = %W{
-size 240x240
xc:#{to_rgb(color)}
-pointsize 240
-fill white
-gravity Center
-font 'Helvetica-Bold'
-stroke #{to_rgb(stroke)}
-strokewidth 2
-annotate -5+25 '#{username[0].upcase}'
'#{filename}'
}
`convert #{instructions.join(" ")}`
filename
end
def darken(color,pct)
color.map do |n|
(n.to_f * pct).to_i
end
end
def to_rgb(color)
r,g,b = color
"'rgb(#{r},#{g},#{b})'"
end
# palette of 216 optimally disctinct colors
# cf. http://tools.medialab.sciences-po.fr/iwanthue/index.php
# parameters used:
# - H: 0 - 360
# - C: 0 - 2
# - L: 0.75 - 1.5
def colors
[[198,125,40],
[61,155,243],
[74,243,75],
[238,89,166],
[52,240,224],
[177,156,155],
[247,242,53],
[111,154,78],
[237,179,245],
[237,101,95],
[89,239,155],
[242,233,158],
[163,212,245],
[65,152,142],
[165,135,246],
[181,166,38],
[187,229,206],
[77,164,25],
[179,246,101],
[234,93,37],
[225,155,115],
[142,140,188],
[223,120,140],
[249,174,27],
[244,117,225],
[137,141,102],
[75,191,146],
[188,239,142],
[164,199,145],
[173,120,149],
[59,195,89],
[222,198,220],
[68,145,187],
[236,204,179],
[159,195,72],
[188,121,189],
[166,160,85],
[181,233,37],
[236,177,85],
[121,147,160],
[234,218,110],
[241,157,191],
[62,200,234],
[133,243,34],
[88,149,110],
[226,235,237],
[183,119,118],
[192,247,192],
[113,196,122],
[197,115,70],
[80,175,187],
[103,231,238],
[240,72,133],
[228,149,241],
[180,188,159],
[172,132,85],
[221,236,102],
[236,194,58],
[217,176,109],
[88,244,199],
[186,157,239],
[113,230,96],
[206,115,165],
[244,178,163],
[230,139,26],
[241,125,89],
[83,160,66],
[107,190,166],
[197,161,210],
[198,203,245],
[238,117,19],
[228,119,116],
[131,156,41],
[145,178,168],
[139,170,220],
[233,95,125],
[87,178,230],
[157,200,119],
[237,140,76],
[229,185,186],
[144,206,212],
[236,209,158],
[185,189,79],
[34,208,66],
[84,238,129],
[133,140,134],
[219,229,175],
[168,179,25],
[140,145,240],
[151,241,125],
[67,162,107],
[200,156,21],
[169,173,189],
[226,116,189],
[133,231,191],
[194,161,63],
[241,77,99],
[241,217,53],
[123,204,105],
[210,201,119],
[229,108,155],
[240,91,72],
[187,115,210],
[240,163,100],
[178,217,57],
[179,135,116],
[204,211,24],
[186,135,57],
[223,176,135],
[204,148,151],
[116,223,50],
[95,195,46],
[123,160,236],
[181,172,131],
[142,220,202],
[240,140,112],
[172,145,164],
[228,124,45],
[222,234,142],
[42,205,125],
[192,233,116],
[119,170,114],
[158,138,26],
[73,190,183],
[185,229,243],
[227,107,55],
[196,205,202],
[132,143,60],
[233,192,237],
[62,150,220],
[205,201,141],
[106,140,190],
[161,131,205],
[226,226,75],
[198,139,81],
[115,171,32],
[101,181,67],
[149,137,119],
[222,245,86],
[183,130,175],
[168,125,133],
[124,142,87],
[236,156,171],
[232,194,91],
[219,200,69],
[144,219,34],
[219,95,187],
[145,154,217],
[165,185,100],
[127,238,163],
[224,178,198],
[119,153,120],
[124,212,92],
[172,161,105],
[231,155,135],
[157,132,101],
[122,185,146],
[53,166,51],
[70,163,90],
[150,190,213],
[234,235,219],
[166,152,185],
[159,194,159],
[222,242,47],
[202,176,161],
[95,140,229],
[156,246,80],
[93,170,203],
[159,142,54],
[185,237,230],
[94,150,149],
[187,206,136],
[157,224,166],
[235,158,208],
[109,232,216],
[141,201,87],
[208,124,118],
[142,125,214],
[226,234,123],
[72,219,41],
[234,102,111],
[168,142,79],
[188,135,35],
[214,233,195],
[148,173,116],
[223,112,95],
[228,128,236],
[206,114,54],
[195,119,88],
[235,140,94],
[235,202,125],
[233,155,153],
[214,214,238],
[246,200,35],
[151,125,171],
[132,145,172],
[131,142,118],
[199,126,150],
[61,162,123],
[58,176,151],
[215,141,69],
[225,154,220],
[224,244,174],
[233,161,64],
[130,221,137],
[81,191,129],
[169,162,140],
[174,177,222],
[236,174,47],
[196,240,159],
[69,222,172],
[71,232,93],
[118,211,238],
[157,224,83],
[218,105,73],
[165,241,221]]
end
end
end

View File

@ -1,12 +0,0 @@
desc "re-generate avatars"
task "avatars:regenerate" => :environment do
RailsMultisite::ConnectionManagement.each_connection do |db|
puts "Generating avatars for: #{db}"
next unless SiteSetting.allow_uploaded_avatars
User.where("uploaded_avatar_id IS NOT NULL").find_each do |u|
Jobs.enqueue(:generate_avatars, upload_id: u.uploaded_avatar_id, user_id: u.id)
putc "."
end
end
puts "\ndone."
end

View File

@ -1,67 +0,0 @@
# encoding: utf-8
require 'spec_helper'
require_dependency 'avatar_detector'
describe AvatarDetector do
describe "construction" do
it "raises an error without a user" do
-> { AvatarDetector.new(nil) }.should raise_error
end
it "raises an error on a non-user object" do
-> { AvatarDetector.new(Array.new) }.should raise_error
end
end
describe "has_custom_avatar?" do
describe "with a user" do
let(:user) { User.new(use_uploaded_avatar: true) }
let(:avatar_detector) { AvatarDetector.new(user) }
describe "when the user doesn't have an uploaded_avatar_path" do
before do
user.stubs(:uploaded_avatar_path)
end
it "returns true if they have a custom gravatar" do
avatar_detector.expects(:has_custom_gravatar?).returns(true)
avatar_detector.has_custom_avatar?.should be_true
end
it "returns false if they don't have a custom gravatar" do
avatar_detector.expects(:has_custom_gravatar?).returns(false)
avatar_detector.has_custom_avatar?.should be_false
end
end
context "when the user doesn't have an uploaded_avatar_path" do
let(:user) { User.new(use_uploaded_avatar: true) }
let(:avatar_detector) { AvatarDetector.new(user) }
describe "when the user has an uploaded avatar" do
before do
user.expects(:uploaded_avatar_path).returns("/some/uploaded/file.png")
end
it "returns true" do
avatar_detector.has_custom_avatar?.should be_true
end
it "doesn't call has_custom_gravatar" do
avatar_detector.expects(:has_custom_gravatar?).never
avatar_detector.has_custom_avatar?
end
end
end
end
end
end

View File

@ -112,7 +112,7 @@ describe ComposerMessagesFinder do
end
it "doesn't return notifications for users who have custom avatars" do
user.user_stat.has_custom_avatar = true
user.uploaded_avatar_id = 1
finder.check_avatar_notification.should be_blank
end

View File

@ -1136,7 +1136,6 @@ describe UsersController do
upload = Fabricate(:upload)
Upload.expects(:create_for).returns(upload)
# enqueues the user_image generator job
Jobs.expects(:enqueue).with(:generate_avatars, { user_id: user.id, upload_id: upload.id })
xhr :post, :upload_user_image, username: user.username, file: user_image, user_image_type: "avatar"
user.reload
# erase the previous template
@ -1194,7 +1193,6 @@ describe UsersController do
upload = Fabricate(:upload)
Upload.expects(:create_for).returns(upload)
# 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: user_image_url, user_image_type: "avatar"
user.reload
# erase the previous template

View File

@ -0,0 +1,21 @@
require 'spec_helper'
describe UserAvatar do
let(:avatar){
user = Fabricate(:user)
user.create_user_avatar!
}
it 'can generate a system avatar' do
avatar.update_system_avatar!
avatar.system_upload.should_not be_nil
end
it 'can update gravatars' do
temp = Tempfile.new('test')
FileHelper.expects(:download).returns(temp)
avatar.update_gravatar!
temp.unlink
avatar.gravatar_upload.should_not be_nil
end
end

View File

@ -984,47 +984,30 @@ describe User do
describe ".small_avatar_url" do
let(:user) { build(:user, use_uploaded_avatar: true, uploaded_avatar_template: "/uploaded/avatar/template/{size}.png") }
let(:user) { build(:user, username: 'Sam') }
it "returns a 45-pixel-wide avatar" do
user.small_avatar_url.should == "//test.localhost/uploaded/avatar/template/45.png"
user.small_avatar_url.should == "//test.localhost/avatar/sam/45/-1.png"
end
end
describe ".uploaded_avatar_path" do
describe ".avatar_template_url" do
let(:user) { build(:user, use_uploaded_avatar: true, uploaded_avatar_template: "/uploaded/avatar/template/{size}.png") }
let(:user) { build(:user, uploaded_avatar_id: 99, username: 'Sam') }
it "returns nothing when uploaded avatars are not allowed" do
SiteSetting.expects(:allow_uploaded_avatars).returns(false)
user.uploaded_avatar_path.should be_nil
it "returns default when uploaded avatars are not allowed" do
SiteSetting.allow_uploaded_avatars = false
user.avatar_template_url.should == "//test.localhost/avatar/sam/{size}/-1.png"
end
it "returns a schemaless avatar template" do
user.uploaded_avatar_path.should == "//test.localhost/uploaded/avatar/template/{size}.png"
it "returns a schemaless avatar template with correct id" do
user.avatar_template_url.should == "//test.localhost/avatar/sam/{size}/99.png"
end
it "returns a schemaless cdn-based avatar template" do
Rails.configuration.action_controller.stubs(:asset_host).returns("http://my.cdn.com")
user.uploaded_avatar_path.should == "//my.cdn.com/uploaded/avatar/template/{size}.png"
end
end
describe ".avatar_template" do
let(:user) { build(:user, email: "em@il.com") }
it "returns the uploaded_avatar_path by default" do
user.expects(:uploaded_avatar_path).returns("//discourse.org/uploaded/avatar.png")
user.avatar_template.should == "//discourse.org/uploaded/avatar.png"
end
it "returns the gravatar when no avatar has been uploaded" do
user.expects(:uploaded_avatar_path)
User.expects(:gravatar_template).with(user.email).returns("//gravatar.com/avatar.png")
user.avatar_template.should == "//gravatar.com/avatar.png"
user.avatar_template_url.should == "//my.cdn.com/avatar/sam/{size}/99.png"
end
end

View File

@ -33,6 +33,9 @@ Spork.prefork do
# let's not run seed_fu every test
SeedFu.quiet = true if SeedFu.respond_to? :quiet
SiteSetting.enable_system_avatars = false
SiteSetting.automatically_download_gravatars = false
SeedFu.seed
RSpec.configure do |config|
@ -79,6 +82,10 @@ Spork.prefork do
SiteSetting.remove_override!(setting.name)
end
# very expensive IO operations
SiteSetting.enable_system_avatars = false
SiteSetting.automatically_download_gravatars = false
end
class TestCurrentUserProvider < Auth::DefaultCurrentUserProvider