diff --git a/app/assets/javascripts/discourse/components/bbcode.js b/app/assets/javascripts/discourse/components/bbcode.js
index 50cd2fdbd65..27c8372fa42 100644
--- a/app/assets/javascripts/discourse/components/bbcode.js
+++ b/app/assets/javascripts/discourse/components/bbcode.js
@@ -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
diff --git a/app/assets/javascripts/discourse/components/utilities.js b/app/assets/javascripts/discourse/components/utilities.js
index 454e0c61834..bf2b0f0ec32 100644
--- a/app/assets/javascripts/discourse/components/utilities.js
+++ b/app/assets/javascripts/discourse/components/utilities.js
@@ -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'));
   }
 
 };
diff --git a/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js b/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js
new file mode 100644
index 00000000000..41180495972
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js
@@ -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") });
+  }
+});
diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js
index e806482412a..b941f29144b 100644
--- a/app/assets/javascripts/discourse/helpers/application_helpers.js
+++ b/app/assets/javascripts/discourse/helpers/application_helpers.js
@@ -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.
diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js
index 72cbff882f3..dbe43748da7 100644
--- a/app/assets/javascripts/discourse/models/composer.js
+++ b/app/assets/javascripts/discourse/models/composer.js
@@ -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;
       }
     }
 
diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js
index 4cf9bf28979..d1b84266173 100644
--- a/app/assets/javascripts/discourse/models/user.js
+++ b/app/assets/javascripts/discourse/models/user.js
@@ -26,25 +26,6 @@ Discourse.User = Discourse.Model.extend({
   **/
   staff: Em.computed.or('admin', 'moderator'),
 
-  /**
-    Large version of this user's avatar.
-
-    @property avatarLarge
-    @type {String}
-  **/
-  avatarLarge: function() {
-    return Discourse.Utilities.avatarUrl(this.get('username'), 'large', this.get('avatar_template'));
-  }.property('username'),
-
-  /**
-    Small version of this user's avatar.
-
-    @property avatarSmall
-    @type {String}
-  **/
-  avatarSmall: function() {
-    return Discourse.Utilities.avatarUrl(this.get('username'), 'small', this.get('avatar_template'));
-  }.property('username'),
 
   searchContext: function() {
     return ({ type: 'user', id: this.get('username_lower'), user: this });
diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js
index c60b937844c..5eadf489a7a 100644
--- a/app/assets/javascripts/discourse/routes/application_routes.js
+++ b/app/assets/javascripts/discourse/routes/application_routes.js
@@ -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' });
diff --git a/app/assets/javascripts/discourse/routes/preferences_routes.js b/app/assets/javascripts/discourse/routes/preferences_routes.js
index 6e6e14160ee..55427f4b9ff 100644
--- a/app/assets/javascripts/discourse/routes/preferences_routes.js
+++ b/app/assets/javascripts/discourse/routes/preferences_routes.js
@@ -116,4 +116,33 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({
   setupController: function(controller, user) {
     controller.setProperties({ model: user, newUsername: user.get('username') });
   }
-});
\ No newline at end of file
+});
+
+
+/**
+  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 });
+  }
+});
diff --git a/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars
index a5d0be4e201..8c1e7d6f8b6 100644
--- a/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars
@@ -2,7 +2,7 @@
   <div class='topic-meta-data span2'>
     <div class='contents'>
       <div>
-        <a href='/users/{{unbound username}}'>{{avatar this imageSize="small"}}</a>
+        <a href='/users/{{unbound username}}'>{{avatar this imageSize="medium"}}</a>
       </div>
       <h5 {{bindAttr class="staff new_user"}}><a href='{{unbound usernameUrl}}'>{{breakUp username}}</a></h5>
     </div>
diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars
index d1784efaa39..7349a442a93 100644
--- a/app/assets/javascripts/discourse/templates/header.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/header.js.handlebars
@@ -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}}
diff --git a/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars b/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars
new file mode 100644
index 00000000000..e72fb175e27
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars
@@ -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>
diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
index 16211759515..e2aece57396 100644
--- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars
@@ -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>
 
diff --git a/app/assets/javascripts/discourse/templates/user/user.js.handlebars b/app/assets/javascripts/discourse/templates/user/user.js.handlebars
index 680c6c9a8ea..bfa14de9446 100644
--- a/app/assets/javascripts/discourse/templates/user/user.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/user/user.js.handlebars
@@ -29,7 +29,7 @@
           {{/if}}
         </ul>
         <div class='avatar-wrapper'>
-          {{boundAvatar model imageSize="120"}}
+          {{boundAvatar model imageSize="huge"}}
         </div>
       </div>
     </div>
diff --git a/app/assets/javascripts/discourse/views/actions_history_view.js b/app/assets/javascripts/discourse/views/actions_history_view.js
index c112ffaf779..ece98f66ede 100644
--- a/app/assets/javascripts/discourse/views/actions_history_view.js
+++ b/app/assets/javascripts/discourse/views/actions_history_view.js
@@ -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')
             });
diff --git a/app/assets/javascripts/discourse/views/composer_view.js b/app/assets/javascripts/discourse/views/composer_view.js
index ed365503a46..57fada42edb 100644
--- a/app/assets/javascripts/discourse/views/composer_view.js
+++ b/app/assets/javascripts/discourse/views/composer_view.js
@@ -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);
   },
 
diff --git a/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js b/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js
new file mode 100644
index 00000000000..4eb5d6eff6c
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js
@@ -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')
+
+});
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index a6eccb61dd6..c2acad8b738 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -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
 
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 4cb28ec4e28..23769012658 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -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
diff --git a/app/models/blocked_email.rb b/app/models/blocked_email.rb
index b9a3b0ac475..1aa85ec4345 100644
--- a/app/models/blocked_email.rb
+++ b/app/models/blocked_email.rb
@@ -33,3 +33,22 @@ class BlockedEmail < ActiveRecord::Base
   end
 
 end
+
+# == Schema Information
+#
+# Table name: blocked_emails
+#
+#  id            :integer          not null, primary key
+#  email         :string(255)      not null
+#  action_type   :integer          not null
+#  match_count   :integer          default(0), not null
+#  last_match_at :datetime
+#  created_at    :datetime         not null
+#  updated_at    :datetime         not null
+#
+# Indexes
+#
+#  index_blocked_emails_on_email          (email) UNIQUE
+#  index_blocked_emails_on_last_match_at  (last_match_at)
+#
+
diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb
index 728fbd96720..5bbdc3acae7 100644
--- a/app/models/category_featured_topic.rb
+++ b/app/models/category_featured_topic.rb
@@ -44,6 +44,7 @@ end
 #  created_at  :datetime         not null
 #  updated_at  :datetime         not null
 #  rank        :integer          default(0), not null
+#  id          :integer          not null, primary key
 #
 # Indexes
 #
diff --git a/app/models/incoming_link.rb b/app/models/incoming_link.rb
index 42b6f6a650a..0ca8d4acc87 100644
--- a/app/models/incoming_link.rb
+++ b/app/models/incoming_link.rb
@@ -106,6 +106,8 @@ end
 #
 # Indexes
 #
-#  incoming_index  (topic_id,post_number)
+#  incoming_index                                  (topic_id,post_number)
+#  index_incoming_links_on_created_at_and_domain   (created_at,domain)
+#  index_incoming_links_on_created_at_and_user_id  (created_at,user_id)
 #
 
diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb
index aee807d1e88..681059c8c2c 100644
--- a/app/models/optimized_image.rb
+++ b/app/models/optimized_image.rb
@@ -43,7 +43,7 @@ class OptimizedImage < ActiveRecord::Base
 
   def destroy
     OptimizedImage.transaction do
-      Discourse.store.remove_file(url)
+      Discourse.store.remove_optimized_image(self)
       super
     end
   end
@@ -60,6 +60,7 @@ end
 #  width     :integer          not null
 #  height    :integer          not null
 #  upload_id :integer          not null
+#  url       :string(255)      not null
 #
 # Indexes
 #
diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb
index 0187be422c5..3405372ee23 100644
--- a/app/models/site_setting.rb
+++ b/app/models/site_setting.rb
@@ -242,6 +242,7 @@ class SiteSetting < ActiveRecord::Base
 
   setting(:username_change_period, 3) # days
 
+  client_setting(:allow_uploaded_avatars, false)
 
   def self.generate_api_key!
     self.api_key = SecureRandom.hex(32)
diff --git a/app/models/staff_action_log.rb b/app/models/staff_action_log.rb
index 5d9e2628b85..b3312a1bda5 100644
--- a/app/models/staff_action_log.rb
+++ b/app/models/staff_action_log.rb
@@ -37,5 +37,14 @@ end
 #  details        :text
 #  created_at     :datetime         not null
 #  updated_at     :datetime         not null
+#  context        :string(255)
+#  ip_address     :string(255)
+#  email          :string(255)
+#
+# Indexes
+#
+#  index_staff_action_logs_on_action_and_id          (action,id)
+#  index_staff_action_logs_on_staff_user_id_and_id   (staff_user_id,id)
+#  index_staff_action_logs_on_target_user_id_and_id  (target_user_id,id)
 #
 
diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb
index 185bede5350..f5e2efda96e 100644
--- a/app/models/topic_user.rb
+++ b/app/models/topic_user.rb
@@ -259,6 +259,7 @@ end
 #  total_msecs_viewed       :integer          default(0), not null
 #  cleared_pinned_at        :datetime
 #  unstarred_at             :datetime
+#  id                       :integer          not null, primary key
 #
 # Indexes
 #
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 2b1c35f9699..a7fcafecb1c 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index b350637af16..da795d0ab3f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
 #
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index 69079b4f369..59c28c7f46f 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -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
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 48e1cef6001..09f4d9bec89 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -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
 
diff --git a/config/initializers/06-mini_profiler.rb b/config/initializers/06-mini_profiler.rb
index 3858000afb0..a7a5dbec5d9 100644
--- a/config/initializers/06-mini_profiler.rb
+++ b/config/initializers/06-mini_profiler.rb
@@ -20,7 +20,6 @@ if defined?(Rack::MiniProfiler)
     (env['PATH_INFO'] !~ /topics\/timings/) &&
     (env['PATH_INFO'] !~ /assets/) &&
     (env['PATH_INFO'] !~ /qunit/) &&
-    (env['PATH_INFO'] !~ /users\/.*\/avatar/) &&
     (env['PATH_INFO'] !~ /srv\/status/) &&
     (env['PATH_INFO'] !~ /commits-widget/)
   end
diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml
index 29d798d602b..57cc658473f 100644
--- a/config/locales/client.cs.yml
+++ b/config/locales/client.cs.yml
@@ -327,7 +327,8 @@ cs:
         title: "Poslední IP adresa"
       avatar:
         title: "Avatar"
-        instructions: "Používáme službu <a href='https://gravatar.com' target='_blank'>Gravatar</a> pro zobrazení avataru podle vaší emailové adresy"
+        instructions:
+          gravatar: "Používáme službu <a href='https://gravatar.com' target='_blank'>Gravatar</a> pro zobrazení avataru podle vaší emailové adresy"
       title:
         title: "Nadpis"
 
diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml
index 6cc05652c57..3ac6a6d0d34 100644
--- a/config/locales/client.da.yml
+++ b/config/locales/client.da.yml
@@ -187,7 +187,8 @@ da:
         title: "Sidste IP-adresse"
       avatar:
         title: "Brugerbillede"
-        instructions: "Vi bruger <a href='https://gravatar.com' target='_blank'>Gravatar</a> for brugerbilleder baseret på e-mail-adresse"
+        instructions:
+          gravatar: "Vi bruger <a href='https://gravatar.com' target='_blank'>Gravatar</a> for brugerbilleder baseret på e-mail-adresse"
 
       filters:
         all: "Alle"
diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml
index ce3a559b9ed..a3c8e9f2308 100644
--- a/config/locales/client.de.yml
+++ b/config/locales/client.de.yml
@@ -308,7 +308,8 @@ de:
         title: "Letzte IP-Adresse"
       avatar:
         title: "Avatar"
-        instructions: "Wir nutzen <a href='https://gravatar.com' target='_blank'>Gravatar</a> zur Darstellung von Avataren basierend auf deiner Mailadresse:"
+        instructions:
+          gravatar: "Wir nutzen <a href='https://gravatar.com' target='_blank'>Gravatar</a> zur Darstellung von Avataren basierend auf deiner Mailadresse:"
 
       title:
         title: "Title"
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index a2cdd55d589..09400690e52 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -132,6 +132,10 @@ en:
     saving: "Saving..."
     saved: "Saved!"
 
+    upload: "Upload"
+    uploading: "Uploading..."
+    uploaded: "Uploaded!"
+
     choose_topic:
       none_found: "No topics found."
       title:
@@ -211,6 +215,15 @@ en:
         error: "There was an error changing your email. Perhaps that address is already in use?"
         success: "We've sent an email to that address. Please follow the confirmation instructions."
 
+      change_avatar:
+        title: "Change your avatar"
+        upload_instructions: "Or you could upload an image"
+        upload: "Upload a picture"
+        uploading: "Uploading the picture..."
+        gravatar: "Gravatar"
+        gravatar_title: "Change your avatar on Gravatar's website"
+        uploaded_avatar: "Uploaded picture"
+
       email:
         title: "Email"
         instructions: "Your email will never be shown to the public."
@@ -304,7 +317,9 @@ en:
         title: "Last IP Address"
       avatar:
         title: "Avatar"
-        instructions: "We use <a href='https://gravatar.com' target='_blank'>Gravatar</a> for avatars based on your email"
+        instructions:
+          gravatar: "We use <a href='//gravatar.com/emails' target='_blank'>Gravatar</a> for avatars based on your email:"
+          uploaded_avatar: "We use the avatar you uploaded."
       title:
         title: "Title"
 
diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml
index eaf62f1e3b2..fbdefd05915 100644
--- a/config/locales/client.es.yml
+++ b/config/locales/client.es.yml
@@ -263,7 +263,8 @@ es:
         title: "Última Dirección IP"
       avatar:
         title: "Avatar"
-        instructions: "Usamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para obtener tu avatar basado en tu dirección de email."
+        instructions:
+          gravatar: "Usamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para obtener tu avatar basado en tu dirección de email."
 
       filters:
         all: "Todos"
diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml
index bc6dca4877f..d69dd9feb00 100644
--- a/config/locales/client.fr.yml
+++ b/config/locales/client.fr.yml
@@ -138,6 +138,10 @@ fr:
     saving: "Sauvegarde en cours..."
     saved: "Sauvegardé !"
 
+    save: "Envoyer"
+    saving: "Envois en cours..."
+    saved: "Envoyé !"
+
     choose_topic:
       none_found: "Aucune discussion trouvée."
       title:
@@ -215,6 +219,14 @@ fr:
         error: "Il y a eu une erreur lors du changement d'email. Cette adresse est peut-être déjà utilisée ?"
         success: "Nous vous avons envoyé un mail à cette adresse. Merci de suivre les instructions."
 
+      change_avatar:
+        title: "Changez votre avatar"
+        upload_instructions: "Ou vous pourriez envoyer une image"
+        upload: "Envoyer une image"
+        uploading: "Image en cours d'envois..."
+        gravatar: "Gravatar"
+        uploaded_avatar: "Image envoyée"
+
       email:
         title: "Email"
         instructions: "Votre adresse email ne sera jamais comuniquée."
@@ -309,8 +321,10 @@ fr:
         title: "Dernières adresses IP"
       avatar:
         title: "Avatar"
-        instructions: "Nous utilisons <a href='https://gravatar.com' target='_blank'>Gravatar</a> pour associer votre avatar avec votre adresse email."
-
+        instructions:
+          gravatar: "Nous utilisons <a href='https://gravatar.com' target='_blank'>Gravatar</a> pour associer votre avatar avec votre adresse email :"
+          uploaded_avatar: "Nous utilisons l'avatar que vous avez envoyé."
+          upload: "Ou vous pouvez envoyer une image"
       filters:
         all: "Tout"
 
diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml
index 6d1c38133ce..f4f86754ff0 100644
--- a/config/locales/client.id.yml
+++ b/config/locales/client.id.yml
@@ -181,7 +181,8 @@ id:
         title: "Last IP Address"
       avatar:
         title: "Avatar"
-        instructions: "We use <a href='https://gravatar.com' target='_blank'>Gravatar</a> for avatars based on your email"
+        instructions:
+          gravatar: "We use <a href='https://gravatar.com' target='_blank'>Gravatar</a> for avatars based on your email"
 
       filters:
         all: "All"
diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml
index 3a8ddc7ec91..5d92f69520d 100644
--- a/config/locales/client.it.yml
+++ b/config/locales/client.it.yml
@@ -247,7 +247,8 @@ it:
         title: "Ultimo indirizzo IP"
       avatar:
         title: "Avatar"
-        instructions: "Usiamo <a href='https://gravatar.com' target='_blank'>Gravatar</a> per gli avatar basandoci sulla tua email"
+        instructions:
+          gravatar: "Usiamo <a href='https://gravatar.com' target='_blank'>Gravatar</a> per gli avatar basandoci sulla tua email"
 
       filters:
         all: "Tutti"
diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml
index 6012a097733..db347b288e4 100644
--- a/config/locales/client.ko.yml
+++ b/config/locales/client.ko.yml
@@ -213,7 +213,8 @@ ko:
         title: "마지막 IP 주소"
       avatar:
         title: "아바타"
-        instructions: "포럼에서는 당신의 Email을 기바능로 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 아바타 서비스를 기본적으로 사용합니다"
+        instructions:
+          gravatar: "포럼에서는 당신의 Email을 기바능로 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 아바타 서비스를 기본적으로 사용합니다"
 
       filters:
         all: "All"
diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml
index 1125b1bf6bf..64f5dc2486a 100644
--- a/config/locales/client.nb_NO.yml
+++ b/config/locales/client.nb_NO.yml
@@ -247,7 +247,8 @@ nb_NO:
         title: "Siste IP Addresse"
       avatar:
         title: "Profilbilde"
-        instructions: "Vi bruker <a href='https://gravatar.com' target='_blank'>Gravatar</a> basert på din email for profilbilder."
+        instructions:
+          gravatar: "Vi bruker <a href='https://gravatar.com' target='_blank'>Gravatar</a> basert på din email for profilbilder."
 
       filters:
         all: "Alle"
diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml
index 024e0738543..735c595b06c 100644
--- a/config/locales/client.nl.yml
+++ b/config/locales/client.nl.yml
@@ -305,7 +305,8 @@ nl:
         title: Laatste IP-adres
       avatar:
         title: Profielfoto
-        instructions: "Wij gebruiken <a href='https://gravatar.com' target='_blank'>Gravatar</a> voor profielfoto's die aan je e-mailadres gekoppeld zijn"
+        instructions:
+          gravatar: "Wij gebruiken <a href='https://gravatar.com' target='_blank'>Gravatar</a> voor profielfoto's die aan je e-mailadres gekoppeld zijn"
       title:
         title: Titel
 
diff --git a/config/locales/client.pseudo.yml b/config/locales/client.pseudo.yml
index 308b86597e3..592f108916b 100644
--- a/config/locales/client.pseudo.yml
+++ b/config/locales/client.pseudo.yml
@@ -275,7 +275,8 @@ pseudo:
         title: '[[ Łášť ÍР Áďďřéšš ]]'
       avatar:
         title: '[[ Áνáťář ]]'
-        instructions: '[[ Ŵé ůšé <á ĥřéƒ=''ĥťťƿš://ǧřáνáťář.čóɱ'' ťářǧéť=''_ƀłáɳǩ''>Ǧřáνáťář</á>
+        instructions:
+          gravatar: '[[ Ŵé ůšé <á ĥřéƒ=''ĥťťƿš://ǧřáνáťář.čóɱ'' ťářǧéť=''_ƀłáɳǩ''>Ǧřáνáťář</á>
           ƒóř áνáťářš ƀášéď óɳ ýóůř éɱáíł ]]'
       title:
         title: '[[ Ťíťłé ]]'
diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml
index 95facfd68b7..69e302cb095 100644
--- a/config/locales/client.pt.yml
+++ b/config/locales/client.pt.yml
@@ -181,7 +181,7 @@ pt:
         title: "Último endereço IP"
       avatar:
         title: "Avatar"
-        instructions: "Nós utilizamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para os avatares baseados no teu email"
+        instructions_gravatar: "Nós utilizamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para os avatares baseados no teu email"
 
       filters:
         all: "Todos"
diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml
index 61da002ef3b..86167d5bb9a 100644
--- a/config/locales/client.pt_BR.yml
+++ b/config/locales/client.pt_BR.yml
@@ -268,7 +268,8 @@ pt_BR:
         title: "Último endereço IP"
       avatar:
         title: "Avatar"
-        instructions: "Nós utilizamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para os avatares baseados no seu email"
+        instructions:
+          gravatar: "Nós utilizamos <a href='https://gravatar.com' target='_blank'>Gravatar</a> para os avatares baseados no seu email"
       title:
         title: "Título"
       filters:
diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml
index d0c5f6c34f0..5d35e73020e 100644
--- a/config/locales/client.ru.yml
+++ b/config/locales/client.ru.yml
@@ -328,7 +328,8 @@ ru:
         title: Последний IP адрес
       avatar:
         title: Аватар
-        instructions: "Сервис <a href='https://ru.gravatar.com/' target='_blank'>Gravatar</a> позволяет создать аватар для вашего адреса электронной почты"
+        instructions:
+          gravatar: "Сервис <a href='https://ru.gravatar.com/' target='_blank'>Gravatar</a> позволяет создать аватар для вашего адреса электронной почты"
       title:
         title: Заголовок
       filters:
diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml
index 84e2c6ca0c0..f1b67562c0e 100644
--- a/config/locales/client.sv.yml
+++ b/config/locales/client.sv.yml
@@ -191,7 +191,8 @@ sv:
         title: "Senaste IP-adress"
       avatar:
         title: "Profilbild"
-        instructions: "Vi använder <a href='https://gravatar.com' target='_blank'>Gravatar</a> för profilbilder baserat på din e-post"
+        instructions:
+          gravatar: "Vi använder <a href='https://gravatar.com' target='_blank'>Gravatar</a> för profilbilder baserat på din e-post"
 
       filters:
         all: "Alla"
diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml
index 6055b09c345..69612c170d4 100644
--- a/config/locales/client.zh_CN.yml
+++ b/config/locales/client.zh_CN.yml
@@ -311,7 +311,8 @@ zh_CN:
         title: "最后使用的IP地址"
       avatar:
         title: "头像"
-        instructions: "我们目前使用 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 来基于你的邮箱生成头像"
+        instructions:
+          gravatar: "我们目前使用 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 来基于你的邮箱生成头像"
       title:
         title: "头衔"
 
diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml
index 5f049901ea3..7fe7cb1aee6 100644
--- a/config/locales/client.zh_TW.yml
+++ b/config/locales/client.zh_TW.yml
@@ -196,7 +196,7 @@ zh_TW:
         success: "(電子郵件已發送)"
         in_progress: "(正在發送電子郵件)"
         error: "(錯誤)"
-      
+
       change_about:
         title: "更改關於我"
 
@@ -307,7 +307,8 @@ zh_TW:
         title: "最後使用的IP地址"
       avatar:
         title: "頭像"
-        instructions: "我們目前使用 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 來基於你的郵箱生成頭像"
+        instructions:
+          gravatar: "我們目前使用 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 來基於你的郵箱生成頭像"
 
       filters:
         all: "全部"
@@ -492,7 +493,7 @@ zh_TW:
       placeholder: "在此輸入你的搜索條件"
       no_results: "沒有找到結果。"
       searching: "搜索中……"
-      
+
       prefer:
         user: "搜索會優先列出@{{username}}的結果"
         category: "搜索會優先列出{{category}}的結果"
@@ -729,7 +730,7 @@ zh_TW:
         upload_not_authorized: "抱歉, 你上傳的文件並不允許 (authorized extension: {{authorized_extensions}})."
         image_upload_not_allowed_for_new_user: "抱歉, 新用戶不能上傳圖片。"
         attachment_upload_not_allowed_for_new_user: "抱歉, 新用戶不能上傳附件。"
-      
+
       abandon: "你確定要丟棄你的帖子嗎?"
 
       archetypes:
@@ -969,7 +970,7 @@ zh_TW:
         help: "在 {{categoryName}} 分類中熱門的主題"
 
     browser_update: '抱歉, <a href="http://www.iteriter.com/faq/#browser">你的瀏覽器版本太低,推薦使用Google Chrome</a>. 請 <a href="http://www.google.com/chrome/">升級你的浏覽器</a>。'
-    
+
     permission_types:
       full: "創建 / 回復 / 觀看"
       create_post: "回復 / 觀看"
@@ -1043,7 +1044,7 @@ zh_TW:
         error: "出錯了"
         view_message: "查看消息"
         no_results: "沒有任何投訴"
-      
+
       summary:
           action_type_3:
             one: "離題"
@@ -1114,7 +1115,7 @@ zh_TW:
         text: "文字"
         last_seen_user: "最後看見用戶:"
         reply_key: "回複金鑰"
-        
+
         logs:
         title: "記錄"
         action: "行動"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 190192c58cc..6662beb4165 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -665,6 +665,8 @@ en:
     delete_all_posts_max: "The maximum number of posts that can be deleted at once with the Delete All Posts button. If a user has more than this many posts, the posts cannot all be deleted at once and the user can't be deleted."
     username_change_period: "The number of days after registration that accounts can change their username."
 
+    allow_uploaded_avatars: "Allow support for uploaded avatars"
+
   notification_types:
     mentioned: "%{display_username} mentioned you in %{link}"
     liked: "%{display_username} liked your post in %{link}"
diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml
index e9ceb932008..cbb51c49c2d 100644
--- a/config/locales/server.fr.yml
+++ b/config/locales/server.fr.yml
@@ -613,6 +613,8 @@ fr:
 
     minimum_topics_similar: "Combien de topics ont besoin d'exister dans la base de données avant que des topics similaires soit présentés."
 
+    allow_uploaded_avatars: "Permet aux utilisateurs d'uploader leur propre avatar"
+
   notification_types:
     mentioned: "%{display_username} vous a mentionné dans %{link}"
     liked: "%{display_username} a aimé votre message dans %{link}"
diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf
index d89fd351254..240ca45a209 100644
--- a/config/nginx.sample.conf
+++ b/config/nginx.sample.conf
@@ -30,12 +30,6 @@ server {
     #  }
     #}
 
-    location ~ ^/t\/[0-9]+\/[0-9]+\/avatar {
-      expires 1d;
-      add_header Cache-Control public;
-      add_header ETag "";
-    }
-
     location ~ ^/(assets|uploads)/ {
       expires 1y;
       add_header Cache-Control public;
diff --git a/config/routes.rb b/config/routes.rb
index 406e2e6a6ae..53d1a66864a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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+/}
diff --git a/db/migrate/20130809211409_add_avatar_to_users.rb b/db/migrate/20130809211409_add_avatar_to_users.rb
new file mode 100644
index 00000000000..4d570aeee7d
--- /dev/null
+++ b/db/migrate/20130809211409_add_avatar_to_users.rb
@@ -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
diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb
index 01bdf3516e4..2b8ea166cef 100644
--- a/lib/file_store/local_store.rb
+++ b/lib/file_store/local_store.rb
@@ -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
diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb
index e864591d86d..02f9a29c7a2 100644
--- a/lib/file_store/s3_store.rb
+++ b/lib/file_store/s3_store.rb
@@ -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)
diff --git a/lib/jobs/generate_avatars.rb b/lib/jobs/generate_avatars.rb
new file mode 100644
index 00000000000..9be32b3cb7c
--- /dev/null
+++ b/lib/jobs/generate_avatars.rb
@@ -0,0 +1,42 @@
+require "image_sorcery"
+
+module Jobs
+
+  class GenerateAvatars < Jobs::Base
+
+    def execute(args)
+      upload = Upload.where(id: args[:upload_id]).first
+      return unless upload.present?
+
+      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
+
+      [120, 45, 32, 25, 20].each do |s|
+        # handle retina too
+        [s, s * 2].each do |size|
+          # create a temp file with the same extension as the original
+          temp_file = Tempfile.new(["discourse-avatar", File.extname(original_path)])
+          temp_path = temp_file.path
+          #
+          Discourse.store.store_avatar(temp_file, upload, size) if ImageSorcery.new(original_path).convert(temp_path, gravity: "center", thumbnail: "#{size}x#{size}^", extent: "#{size}x#{size}")
+          # close && remove temp file
+          temp_file.close!
+        end
+      end
+
+      # make sure we remove the cached copy from external stores
+      external_copy.close! if Discourse.store.external?
+
+      user = User.where(id: upload.user_id).first
+      user.uploaded_avatar_template = Discourse.store.absolute_avatar_template(upload)
+      user.save!
+
+    end
+
+  end
+
+end
diff --git a/lib/oneboxer/discourse_local_onebox.rb b/lib/oneboxer/discourse_local_onebox.rb
index c339c162645..3d500acb926 100644
--- a/lib/oneboxer/discourse_local_onebox.rb
+++ b/lib/oneboxer/discourse_local_onebox.rb
@@ -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
diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb
index 9d20a022abe..b1329ee7f6c 100644
--- a/lib/pretty_text.rb
+++ b/lib/pretty_text.rb
@@ -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
diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb
index bd9d1f57787..b0339b8883f 100644
--- a/spec/components/cooked_post_processor_spec.rb
+++ b/spec/components/cooked_post_processor_spec.rb
@@ -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
diff --git a/spec/components/file_store/local_store_spec.rb b/spec/components/file_store/local_store_spec.rb
index aca9e4b48e9..684a9a273b5 100644
--- a/spec/components/file_store/local_store_spec.rb
+++ b/spec/components/file_store/local_store_spec.rb
@@ -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
diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb
index 8b38a4d9160..fc43173f5db 100644
--- a/spec/components/file_store/s3_store_spec.rb
+++ b/spec/components/file_store/s3_store_spec.rb
@@ -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
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index 10440313e1c..41c8ff3a91c 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -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
 
diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb
index 3ce512cb7eb..6fe8c978e9c 100644
--- a/spec/components/pretty_text_spec.rb
+++ b/spec/components/pretty_text_spec.rb
@@ -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>"
diff --git a/spec/models/optimized_image_spec.rb b/spec/models/optimized_image_spec.rb
index b2d2db798d2..743c3bb0fcf 100644
--- a/spec/models/optimized_image_spec.rb
+++ b/spec/models/optimized_image_spec.rb
@@ -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
diff --git a/test/javascripts/components/bbcode_test.js b/test/javascripts/components/bbcode_test.js
index 5941ed812d3..a80a2bfec20 100644
--- a/test/javascripts/components/bbcode_test.js
+++ b/test/javascripts/components/bbcode_test.js
@@ -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);
 };
diff --git a/test/javascripts/components/utilities_test.js b/test/javascripts/components/utilities_test.js
index ec4ac8cfbf2..a0dae30b8ea 100644
--- a/test/javascripts/components/utilities_test.js
+++ b/test/javascripts/components/utilities_test.js
@@ -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");
 });