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') +