diff --git a/apps/reader/models.py b/apps/reader/models.py
index f9698ed3d..fc4af8fe4 100644
--- a/apps/reader/models.py
+++ b/apps/reader/models.py
@@ -351,7 +351,7 @@ class UserSubscription(models.Model):
for story_id in set(story_ids):
try:
- story = MStory.objects.get(story_feed_id=self.feed_id, story_guid=story_id)
+ story = MStory.get_story(story_feed_id=self.feed_id, story_guid=story_id)
except MStory.DoesNotExist:
# Story has been deleted, probably by feed_fetcher.
continue
diff --git a/apps/rss_feeds/models.py b/apps/rss_feeds/models.py
index aa7a7d423..1cc8d362c 100644
--- a/apps/rss_feeds/models.py
+++ b/apps/rss_feeds/models.py
@@ -1401,7 +1401,7 @@ class MFeedPage(mongo.Document):
class MStory(mongo.Document):
'''A feed item'''
- story_feed_id = mongo.IntField(unique_with='story_guid')
+ story_feed_id = mongo.IntField()
story_date = mongo.DateTimeField()
story_title = mongo.StringField(max_length=1024)
story_content = mongo.StringField()
@@ -1414,6 +1414,7 @@ class MStory(mongo.Document):
story_author_name = mongo.StringField()
story_permalink = mongo.StringField()
story_guid = mongo.StringField()
+ story_hash = mongo.StringField()
story_tags = mongo.ListField(mongo.StringField(max_length=250))
comment_count = mongo.IntField()
comment_user_ids = mongo.ListField(mongo.IntField())
@@ -1432,6 +1433,10 @@ class MStory(mongo.Document):
@property
def guid_hash(self):
return hashlib.sha1(self.story_guid).hexdigest()[:6]
+
+ @property
+ def feed_guid_hash(self):
+ return hashlib.sha1("%s:%s" % (self.story_feed_id, self.story_guid)).hexdigest()[:6]
def save(self, *args, **kwargs):
story_title_max = MStory._fields['story_title'].max_length
@@ -1449,6 +1454,7 @@ class MStory(mongo.Document):
self.story_title = self.story_title[:story_title_max]
if self.story_content_type and len(self.story_content_type) > story_content_type_max:
self.story_content_type = self.story_content_type[:story_content_type_max]
+
super(MStory, self).save(*args, **kwargs)
self.sync_redis()
diff --git a/apps/social/models.py b/apps/social/models.py
index b1f2195ad..8984a538b 100644
--- a/apps/social/models.py
+++ b/apps/social/models.py
@@ -32,6 +32,7 @@ from utils import json_functions as json
from utils.feed_functions import relative_timesince
from utils.story_functions import truncate_chars, strip_tags, linkify, image_size
from utils.scrubber import SelectiveScriptScrubber
+from utils import s3_utils
RECOMMENDATIONS_LIMIT = 5
IGNORE_IMAGE_SOURCES = [
@@ -244,6 +245,8 @@ class MSocialProfile(mongo.Document):
return photo_url + '?type=large'
elif 'twimg' in photo_url:
return photo_url.replace('_normal', '')
+ elif '/avatars/' in photo_url:
+ return photo_url.replace('thumbnail_', 'large_')
return photo_url
@property
@@ -1991,6 +1994,22 @@ class MSocialServices(mongo.Document):
def profile(cls, user_id):
profile = cls.get_user(user_id=user_id)
return profile.to_json()
+
+ def save_uploaded_photo(self, photo):
+ photo_body = photo.read()
+ filename = photo.name
+
+ s3 = s3_utils.S3Store()
+ image_name = s3.save_profile_picture(self.user_id, filename, photo_body)
+ if image_name:
+ self.upload_picture_url = "https://s3.amazonaws.com/%s/avatars/%s/thumbnail_%s" % (
+ settings.S3_AVATARS_BUCKET_NAME,
+ self.user_id,
+ image_name,
+ )
+ self.save()
+
+ return image_name and self.upload_picture_url
def twitter_api(self):
twitter_consumer_key = settings.TWITTER_CONSUMER_KEY
diff --git a/apps/social/urls.py b/apps/social/urls.py
index cf153d3bf..ccc9695e1 100644
--- a/apps/social/urls.py
+++ b/apps/social/urls.py
@@ -10,6 +10,7 @@ urlpatterns = patterns('',
url(r'^profile/?$', views.profile, name='profile'),
url(r'^load_user_profile/?$', views.load_user_profile, name='load-user-profile'),
url(r'^save_user_profile/?$', views.save_user_profile, name='save-user-profile'),
+ url(r'^upload_avatar/?', views.upload_avatar, name='upload-avatar'),
url(r'^save_blurblog_settings/?$', views.save_blurblog_settings, name='save-blurblog-settings'),
url(r'^interactions/?$', views.load_interactions, name='social-interactions'),
url(r'^activities/?$', views.load_activities, name='social-activities'),
diff --git a/apps/social/views.py b/apps/social/views.py
index 03e6756bc..0bca7428c 100644
--- a/apps/social/views.py
+++ b/apps/social/views.py
@@ -870,7 +870,24 @@ def save_user_profile(request):
return dict(code=1, user_profile=profile.to_json(include_follows=True))
-
+
+@ajax_login_required
+@json.json_view
+def upload_avatar(request):
+ photo = request.FILES['photo']
+ profile = MSocialProfile.get_user(request.user.pk)
+ social_services = MSocialServices.objects.get(user_id=request.user.pk)
+ image_url = social_services.save_uploaded_photo(photo)
+ if image_url:
+ profile = social_services.set_photo('upload')
+
+ return {
+ "code": 1 if image_url else -1,
+ "uploaded": image_url,
+ "services": social_services,
+ "user_profile": profile.to_json(include_follows=True),
+ }
+
@ajax_login_required
@json.json_view
def save_blurblog_settings(request):
diff --git a/media/css/reader.css b/media/css/reader.css
index d3e7953a5..e32e03b43 100644
--- a/media/css/reader.css
+++ b/media/css/reader.css
@@ -7197,7 +7197,7 @@ form.opml_import_form input {
.NB-modal-preferences .NB-preferences-scroll {
overflow: auto;
- max-height: 500px;
+ max-height: 600px;
width: 100%;
padding-right: 12px;
}
diff --git a/media/ios/Entitlements.entitlements b/media/ios/Entitlements.entitlements
index fe238e5c4..c468210c7 100644
--- a/media/ios/Entitlements.entitlements
+++ b/media/ios/Entitlements.entitlements
@@ -5,7 +5,7 @@
application-identifier
$(AppIdentifierPrefix)$(CFBundleIdentifier)
get-task-allow
-
+
keychain-access-groups
$(AppIdentifierPrefix)$(CFBundleIdentifier)
diff --git a/media/js/newsblur/reader/reader_account.js b/media/js/newsblur/reader/reader_account.js
index 8d71df33d..9f8727e5d 100644
--- a/media/js/newsblur/reader/reader_account.js
+++ b/media/js/newsblur/reader/reader_account.js
@@ -1,5 +1,6 @@
NEWSBLUR.ReaderAccount = function(options) {
var defaults = {
+ 'width': 700,
'animate_email': false,
'change_password': false,
'onOpen': _.bind(function() {
diff --git a/media/js/newsblur/reader/reader_preferences.js b/media/js/newsblur/reader/reader_preferences.js
index 91449627d..bf056ad9e 100644
--- a/media/js/newsblur/reader/reader_preferences.js
+++ b/media/js/newsblur/reader/reader_preferences.js
@@ -593,7 +593,7 @@ _.extend(NEWSBLUR.ReaderPreferences.prototype, {
},
resize_modal: function() {
- var $scroll = $('.NB-preferences-scroll', this.$modal);
+ var $scroll = $('.NB-tab.NB-active', this.$modal);
var $modal = this.$modal;
var $modal_container = $modal.closest('.simplemodal-container');
diff --git a/media/js/newsblur/reader/reader_profile_editor.js b/media/js/newsblur/reader/reader_profile_editor.js
index 68cf10308..c4b60bbb4 100644
--- a/media/js/newsblur/reader/reader_profile_editor.js
+++ b/media/js/newsblur/reader/reader_profile_editor.js
@@ -24,6 +24,7 @@ _.extend(NEWSBLUR.ReaderProfileEditor.prototype, {
this.fetch_user_profile();
this.$modal.bind('click', $.rescope(this.handle_click, this));
+ this.$modal.bind('change', $.rescope(this.handle_change, this));
this.handle_profile_counts();
this.delegate_change();
},
@@ -217,7 +218,9 @@ _.extend(NEWSBLUR.ReaderProfileEditor.prototype, {
make_profile_photo_chooser: function() {
var $profiles = $('.NB-friends-profilephoto', this.$modal).empty();
- _.each(['nothing', 'twitter', 'facebook', 'gravatar'], _.bind(function(service) {
+ $profiles.append($.make('div', { className: "NB-photo-upload-error NB-error" }));
+
+ _.each(['nothing', 'upload', 'twitter', 'facebook', 'gravatar'], _.bind(function(service) {
var $profile = $.make('div', { className: 'NB-friends-profile-photo-group NB-friends-photo-'+service }, [
$.make('div', { className: 'NB-friends-photo-title' }, [
$.make('input', { type: 'radio', name: 'profile_photo_service', value: service, id: 'NB-profile-photo-service-'+service }),
@@ -233,8 +236,10 @@ _.extend(NEWSBLUR.ReaderProfileEditor.prototype, {
])
]),
(service == 'upload' && $.make('div', { className: 'NB-photo-link' }, [
- $.make('a', { href: '#', className: 'NB-photo-upload-link NB-splash-link' }, 'Upload picture'),
- $.make('input', { type: 'file', name: 'photo' })
+ $.make('form', { method: 'post', enctype: 'multipart/form-data', encoding: 'multipart/form-data' }, [
+ $.make('a', { href: '#', className: 'NB-photo-upload-link NB-splash-link' }, 'upload picture'),
+ $.make('input', { type: 'file', name: 'photo', id: "NB-photo-upload-file", className: 'NB-photo-upload-file' })
+ ])
])),
(service == 'gravatar' && $.make('div', { className: 'NB-gravatar-link' }, [
$.make('a', { href: 'http://www.gravatar.com', className: 'NB-splash-link', target: '_blank' }, 'gravatar.com')
@@ -463,6 +468,15 @@ _.extend(NEWSBLUR.ReaderProfileEditor.prototype, {
});
},
+ handle_change: function(elem, e) {
+ var self = this;
+ $.targetIs(e, { tagSelector: '.NB-photo-upload-file' }, function($t, $p) {
+ e.preventDefault();
+
+ self.handle_photo_upload();
+ });
+ },
+
handle_cancel: function() {
var $cancel = $('.NB-modal-cancel', this.$modal);
@@ -492,6 +506,65 @@ _.extend(NEWSBLUR.ReaderProfileEditor.prototype, {
});
},
+
+ handle_photo_upload: function() {
+ var self = this;
+ var $loading = $('.NB-modal-loading', this.$modal);
+ var $error = $('.NB-photo-upload-error', this.$modal);
+ var $file = $('.NB-photo-upload-file', this.$modal);
+
+ $error.slideUp(300);
+ $loading.addClass('NB-active');
+
+ var params = {
+ url: NEWSBLUR.URLs['upload-avatar'],
+ type: 'POST',
+ dataType: 'json',
+ success: _.bind(function(data, status) {
+ if (data.code < 0) {
+ this.error_uploading_photo();
+ } else {
+ $loading.removeClass('NB-active');
+ console.log(["success uploading", data, status, this]);
+ NEWSBLUR.assets.user_profile.set(data.user_profile);
+ this.services = data.services;
+ this.make_profile_section();
+ this.make_profile_photo_chooser();
+ }
+ }, this),
+ error: _.bind(this.error_uploading_photo, this),
+ cache: false,
+ contentType: false,
+ processData: false
+ };
+ if (window.FormData) {
+ var formData = new FormData($file.closest('form')[0]);
+ params['data'] = formData;
+
+ $.ajax(params);
+ } else {
+ // IE9 has no FormData
+ params['secureuri'] = false;
+ params['fileElementId'] = 'NB-photo-upload-file';
+ params['dataType'] = 'json';
+
+ $.ajaxFileUpload(params);
+ }
+
+ $file.replaceWith($file.clone());
+
+ return false;
+ },
+
+ error_uploading_photo: function() {
+ var $loading = $('.NB-modal-loading', this.$modal);
+ var $error = $('.NB-photo-upload-error', this.$modal);
+
+ $loading.removeClass('NB-active');
+ $error.text("There was a problem uploading your photo.");
+ $error.slideDown(300);
+ },
+
delegate_change: function() {
$('.NB-tab-profile', this.$modal).delegate('input[type=radio],input[type=checkbox],select', 'change', _.bind(this.enable_save_profile, this));
$('.NB-tab-profile', this.$modal).delegate('input[type=text]', 'keydown', _.bind(this.enable_save_profile, this));
diff --git a/settings.py b/settings.py
index ca2342863..a0d5a1b64 100644
--- a/settings.py
+++ b/settings.py
@@ -452,6 +452,7 @@ PROXY_S3_PAGES = True
S3_BACKUP_BUCKET = 'newsblur_backups'
S3_PAGES_BUCKET_NAME = 'pages.newsblur.com'
S3_ICONS_BUCKET_NAME = 'icons.newsblur.com'
+S3_AVATARS_BUCKET_NAME = 'avatars.newsblur.com'
# ==================
# = Configurations =
diff --git a/templates/base.html b/templates/base.html
index 922f47174..d0798260d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -72,6 +72,7 @@
};
NEWSBLUR.URLs = {
'google-reader-authorize' : "{% url google-reader-authorize %}",
+ 'upload-avatar' : "{% url upload-avatar %}",
'opml-upload' : "{% url opml-upload %}",
'opml-export' : "{% url opml-export %}",
'domain' : "{% current_domain %}",
diff --git a/utils/image_functions.py b/utils/image_functions.py
new file mode 100644
index 000000000..c267004e7
--- /dev/null
+++ b/utils/image_functions.py
@@ -0,0 +1,70 @@
+"""Operations for images through the PIL."""
+
+import Image
+import ImageOps as PILOps
+from ExifTags import TAGS
+from StringIO import StringIO
+
+PROFILE_PICTURE_SIZES = {
+ 'fullsize': (256, 256),
+ 'thumbnail': (64, 64)
+}
+
+class ImageOps:
+ """Module that holds all image operations. Since there's no state,
+ everything is a classmethod."""
+
+ @classmethod
+ def resize_image(cls, image_body, size, fit_to_size=False):
+ """Takes a raw image (in image_body) and resizes it to fit given
+ dimensions. Returns a file-like object in the form of a StringIO.
+ This must happen in this function because PIL is transforming the
+ original as it works."""
+
+ image_file = StringIO(image_body)
+ try:
+ image = Image.open(image_file)
+ except IOError:
+ # Invalid image file
+ return False
+
+ # Get the image format early, as we lose it after perform a `thumbnail` or `fit`.
+ format = image.format
+
+ # Check for rotation
+ image = cls.adjust_image_orientation(image)
+
+ if not fit_to_size:
+ image.thumbnail(PROFILE_PICTURE_SIZES[size], Image.ANTIALIAS)
+ else:
+ image = PILOps.fit(image, PROFILE_PICTURE_SIZES[size],
+ method=Image.ANTIALIAS,
+ centering=(0.5, 0.5))
+
+ output = StringIO()
+ if format.lower() == 'jpg':
+ format = 'jpeg'
+ image.save(output, format=format, quality=95)
+
+ return output
+
+ @classmethod
+ def adjust_image_orientation(cls, image):
+ """Since the iPhone will store an image on its side but with EXIF
+ data stating that it should be rotated, we need to find that
+ EXIF data and correctly rotate the image before storage."""
+
+ if hasattr(image, '_getexif'):
+ exif = image._getexif()
+ if exif:
+ for tag, value in exif.items():
+ decoded = TAGS.get(tag, tag)
+ if decoded == 'Orientation':
+ if value == 6:
+ image = image.rotate(-90)
+ if value == 8:
+ image = image.rotate(90)
+ if value == 3:
+ image = image.rotate(180)
+ break
+ return image
\ No newline at end of file
diff --git a/utils/s3_utils.py b/utils/s3_utils.py
index a9da2b447..7ae490e9e 100644
--- a/utils/s3_utils.py
+++ b/utils/s3_utils.py
@@ -1,7 +1,10 @@
-from boto.s3.connection import S3Connection
-from boto.s3.key import Key
import os
import sys
+import time
+import mimetypes
+from boto.s3.connection import S3Connection
+from boto.s3.key import Key
+from utils.image_functions import ImageOps
if '/home/sclay/newsblur' not in ' '.join(sys.path):
sys.path.append("/home/sclay/newsblur")
@@ -60,3 +63,62 @@ if __name__ == '__main__':
delete_all_backups()
else:
print 'Usage: %s ' % (sys.argv[0])
+
+
+class S3Store:
+
+ def __init__(self, bucket_name=settings.S3_AVATARS_BUCKET_NAME):
+ self.s3 = S3Connection(ACCESS_KEY, SECRET)
+ self.bucket = self.create_bucket(bucket_name)
+
+ def create_bucket(self, bucket_name):
+ return self.s3.create_bucket(bucket_name)
+
+ def save_profile_picture(self, user_id, filename, image_body):
+ mimetype, extension = self._extract_mimetype(filename)
+ if not mimetype or not extension:
+ return
+
+ image_name = 'profile_%s.%s' % (int(time.time()), extension)
+
+ image = ImageOps.resize_image(image_body, 'fullsize', fit_to_size=False)
+ if image:
+ key = 'avatars/%s/large_%s' % (user_id, image_name)
+ self._save_object(key, image, mimetype=mimetype)
+
+ image = ImageOps.resize_image(image_body, 'thumbnail', fit_to_size=True)
+ if image:
+ key = 'avatars/%s/thumbnail_%s' % (user_id, image_name)
+ self._save_object(key, image, mimetype=mimetype)
+
+ return image and image_name
+
+ def _extract_mimetype(self, filename):
+ mimetype = mimetypes.guess_type(filename)[0]
+ extension = None
+
+ if mimetype == 'image/jpeg':
+ extension = 'jpg'
+ elif mimetype == 'image/png':
+ extension = 'png'
+ elif mimetype == 'image/gif':
+ extension = 'gif'
+
+ return mimetype, extension
+
+ def _make_key(self):
+ return Key(bucket=self.bucket)
+
+ def _save_object(self, key, file_object, mimetype=None):
+ k = self._make_key()
+ k.key = key
+ file_object.seek(0)
+
+ if mimetype:
+ k.set_contents_from_file(file_object, headers={
+ 'Content-Type': mimetype,
+ })
+ else:
+ k.set_contents_from_file(file_object)
+ k.set_acl('public-read')
+