From 47474ac936ffb1d179161c8a3cac5d20e6005659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Fri, 24 May 2019 17:36:06 +0200 Subject: [PATCH] Replaced Gravatar with self-hosted avatars Avatars are now obtained from Blender ID. They are downloaded from Blender ID and stored in the users' home project storage. Avatars can be synced via Celery and triggered from a webhook. The avatar can be obtained from the current user object in Python, or via pillar.api.users.avatar.url(user_dict). Avatars can be shown in the web frontend by: - an explicit image (like before but with a non-Gravatar URL) - a Vue.js component `user-avatar` - a Vue.js component `current-user-avatar` The latter is the most efficient for the current user, as it uses user info that's already injected into the webpage (so requires no extra queries). --- pillar/__init__.py | 2 + pillar/api/activities.py | 4 +- pillar/api/blender_id.py | 10 ++ pillar/api/eve_settings.py | 19 +++ pillar/api/file_storage/__init__.py | 73 ++++++-- pillar/api/nodes/activities.py | 6 +- pillar/api/nodes/comments.py | 7 +- pillar/api/organizations/__init__.py | 2 +- pillar/api/projects/routes.py | 10 +- pillar/api/users/avatar.py | 159 ++++++++++++++++++ pillar/api/users/hooks.py | 5 +- pillar/api/utils/__init__.py | 11 ++ pillar/auth/__init__.py | 52 +++++- pillar/celery/avatar.py | 29 ++++ pillar/config.py | 2 + pillar/web/organizations/routes.py | 5 +- pillar/web/projects/routes.py | 13 +- pillar/web/settings/routes.py | 22 ++- .../static/assets/img/default_user_avatar.png | Bin 0 -> 496 bytes pillar/web/utils/__init__.py | 11 +- .../js/es6/common/utils/currentuser.js | 17 +- src/scripts/js/es6/common/utils/init.js | 4 +- .../js/es6/common/vuecomponents/init.js | 1 + .../es6/common/vuecomponents/user/Avatar.js | 2 +- .../vuecomponents/user/CurrentUserAvatar.js | 23 +++ .../js/es6/individual/avatar/AvatarSync.js | 39 +++++ src/scripts/js/es6/individual/avatar/init.js | 1 + src/styles/_user.sass | 6 + src/templates/menus/user_base.pug | 6 +- src/templates/organizations/view_embed.pug | 2 +- src/templates/projects/sharing.pug | 2 +- src/templates/users/settings/profile.pug | 60 ++++--- tests/test_api/test_auth.py | 4 +- 33 files changed, 516 insertions(+), 93 deletions(-) create mode 100644 pillar/api/users/avatar.py create mode 100644 pillar/celery/avatar.py create mode 100644 pillar/web/static/assets/img/default_user_avatar.png create mode 100644 src/scripts/js/es6/common/vuecomponents/user/CurrentUserAvatar.js create mode 100644 src/scripts/js/es6/individual/avatar/AvatarSync.js create mode 100644 src/scripts/js/es6/individual/avatar/init.js diff --git a/pillar/__init__.py b/pillar/__init__.py index 3c8492b4..4b09e52d 100644 --- a/pillar/__init__.py +++ b/pillar/__init__.py @@ -492,6 +492,7 @@ class PillarServer(BlinkerCompatibleEve): # Pillar-defined Celery task modules: celery_task_modules = [ + 'pillar.celery.avatar', 'pillar.celery.badges', 'pillar.celery.email_tasks', 'pillar.celery.file_link_tasks', @@ -810,6 +811,7 @@ class PillarServer(BlinkerCompatibleEve): url = self.config['URLS'][resource] path = '%s/%s' % (self.api_prefix, url) + with self.__fake_request_url_rule('POST', path): return post_internal(resource, payl=payl, skip_validation=skip_validation)[:4] diff --git a/pillar/api/activities.py b/pillar/api/activities.py index d81bafb6..9177a46d 100644 --- a/pillar/api/activities.py +++ b/pillar/api/activities.py @@ -1,7 +1,7 @@ import logging from flask import request, current_app -from pillar.api.utils import gravatar +import pillar.api.users.avatar from pillar.auth import current_user log = logging.getLogger(__name__) @@ -68,7 +68,7 @@ def notification_parse(notification): if actor: parsed_actor = { 'username': actor['username'], - 'avatar': gravatar(actor['email'])} + 'avatar': pillar.api.users.avatar.url(actor)} else: parsed_actor = None diff --git a/pillar/api/blender_id.py b/pillar/api/blender_id.py index 8e4a1548..8cdfa69f 100644 --- a/pillar/api/blender_id.py +++ b/pillar/api/blender_id.py @@ -280,6 +280,16 @@ def fetch_blenderid_user() -> dict: return payload +def avatar_url(blenderid_user_id: str) -> str: + """Return the URL to the user's avatar on Blender ID. + + This avatar should be downloaded, and not served from the Blender ID URL. + """ + bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], + f'api/user/{blenderid_user_id}/avatar') + return bid_url + + def setup_app(app, url_prefix): app.register_api_blueprint(blender_id, url_prefix=url_prefix) diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index 6bf9c242..ba9dea85 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -125,6 +125,25 @@ users_schema = { 'type': 'dict', 'allow_unknown': True, }, + 'avatar': { + 'type': 'dict', + 'schema': { + 'file': { + 'type': 'objectid', + 'data_relation': { + 'resource': 'files', + 'field': '_id', + }, + }, + # For only downloading when things really changed: + 'last_downloaded_url': { + 'type': 'string', + }, + 'last_modified': { + 'type': 'string', + }, + }, + }, # Node-specific information for this user. 'nodes': { diff --git a/pillar/api/file_storage/__init__.py b/pillar/api/file_storage/__init__.py index ad0bcdfa..bcdc3169 100644 --- a/pillar/api/file_storage/__init__.py +++ b/pillar/api/file_storage/__init__.py @@ -821,6 +821,10 @@ def stream_to_storage(project_id: str): local_file = uploaded_file.stream result = upload_and_process(local_file, uploaded_file, project_id) + + # Local processing is done, we can close the local file so it is removed. + local_file.close() + resp = jsonify(result) resp.status_code = result['status_code'] add_access_control_headers(resp) @@ -829,7 +833,9 @@ def stream_to_storage(project_id: str): def upload_and_process(local_file: typing.Union[io.BytesIO, typing.BinaryIO], uploaded_file: werkzeug.datastructures.FileStorage, - project_id: str): + project_id: str, + *, + may_process_file=True) -> dict: # Figure out the file size, as we need to pass this in explicitly to GCloud. # Otherwise it always uses os.fstat(file_obj.fileno()).st_size, which isn't # supported by a BytesIO object (even though it does have a fileno @@ -856,18 +862,15 @@ def upload_and_process(local_file: typing.Union[io.BytesIO, typing.BinaryIO], 'size=%i as "queued_for_processing"', file_id, internal_fname, file_size) update_file_doc(file_id, - status='queued_for_processing', + status='queued_for_processing' if may_process_file else 'complete', file_path=internal_fname, length=blob.size, content_type=uploaded_file.mimetype) - log.debug('Processing uploaded file id=%s, fname=%s, size=%i', file_id, - internal_fname, blob.size) - process_file(bucket, file_id, local_file) - - # Local processing is done, we can close the local file so it is removed. - if local_file is not None: - local_file.close() + if may_process_file: + log.debug('Processing uploaded file id=%s, fname=%s, size=%i', file_id, + internal_fname, blob.size) + process_file(bucket, file_id, local_file) log.debug('Handled uploaded file id=%s, fname=%s, size=%i, status=%i', file_id, internal_fname, blob.size, status) @@ -981,7 +984,50 @@ def compute_aggregate_length_items(file_docs): compute_aggregate_length(file_doc) +def get_file_url(file_id: ObjectId, variation='') -> str: + """Return the URL of a file in storage. + + Note that this function is cached, see setup_app(). + + :param file_id: the ID of the file + :param variation: if non-empty, indicates the variation of of the file + to return the URL for; if empty, returns the URL of the original. + + :return: the URL, or an empty string if the file/variation does not exist. + """ + + file_coll = current_app.db('files') + db_file = file_coll.find_one({'_id': file_id}) + if not db_file: + return '' + + ensure_valid_link(db_file) + + if variation: + variations = file_doc.get('variations', ()) + for file_var in variations: + if file_var['size'] == variation: + return file_var['link'] + return '' + + return db_file['link'] + + +def update_file_doc(file_id, **updates): + files = current_app.data.driver.db['files'] + res = files.update_one({'_id': ObjectId(file_id)}, + {'$set': updates}) + log.debug('update_file_doc(%s, %s): %i matched, %i updated.', + file_id, updates, res.matched_count, res.modified_count) + return res + + def setup_app(app, url_prefix): + global get_file_url + + cached = app.cache.memoize(timeout=10) + get_file_url = cached(get_file_url) + app.on_pre_GET_files += on_pre_get_files app.on_fetched_item_files += before_returning_file @@ -992,12 +1038,3 @@ def setup_app(app, url_prefix): app.on_insert_files += compute_aggregate_length_items app.register_api_blueprint(file_storage, url_prefix=url_prefix) - - -def update_file_doc(file_id, **updates): - files = current_app.data.driver.db['files'] - res = files.update_one({'_id': ObjectId(file_id)}, - {'$set': updates}) - log.debug('update_file_doc(%s, %s): %i matched, %i updated.', - file_id, updates, res.matched_count, res.modified_count) - return res diff --git a/pillar/api/nodes/activities.py b/pillar/api/nodes/activities.py index 9a821d52..41518e49 100644 --- a/pillar/api/nodes/activities.py +++ b/pillar/api/nodes/activities.py @@ -1,6 +1,6 @@ from eve.methods import get -from pillar.api.utils import gravatar +import pillar.api.users.avatar def for_node(node_id): @@ -25,9 +25,9 @@ def _user_info(user_id): users, _, _, status, _ = get('users', {'_id': user_id}) if len(users['_items']) > 0: user = users['_items'][0] - user['gravatar'] = gravatar(user['email']) + user['avatar'] = pillar.api.users.avatar.url(user) - public_fields = {'full_name', 'username', 'gravatar'} + public_fields = {'full_name', 'username', 'avatar'} for field in list(user.keys()): if field not in public_fields: del user[field] diff --git a/pillar/api/nodes/comments.py b/pillar/api/nodes/comments.py index 23b51ca4..00cfc573 100644 --- a/pillar/api/nodes/comments.py +++ b/pillar/api/nodes/comments.py @@ -10,8 +10,9 @@ import werkzeug.exceptions as wz_exceptions import pillar from pillar import current_app, shortcodes +import pillar.api.users.avatar from pillar.api.nodes.custom.comment import patch_comment -from pillar.api.utils import jsonify, gravatar +from pillar.api.utils import jsonify from pillar.auth import current_user import pillar.markdown @@ -22,7 +23,7 @@ log = logging.getLogger(__name__) class UserDO: id: str full_name: str - gravatar: str + avatar_url: str badges_html: str @@ -255,7 +256,7 @@ def to_comment_data_object(mongo_comment: dict) -> CommentDO: user = UserDO( id=str(mongo_comment['user']['_id']), full_name=user_dict['full_name'], - gravatar=gravatar(user_dict['email']), + avatar_url=pillar.api.users.avatar.url(user_dict), badges_html=user_dict.get('badges', {}).get('html', '') ) html = _get_markdowned_html(mongo_comment['properties'], 'content') diff --git a/pillar/api/organizations/__init__.py b/pillar/api/organizations/__init__.py index f6903f8e..0bfa89d2 100644 --- a/pillar/api/organizations/__init__.py +++ b/pillar/api/organizations/__init__.py @@ -374,7 +374,7 @@ class OrgManager: member_ids = [str2id(uid) for uid in member_sting_ids] users_coll = current_app.db('users') users = users_coll.find({'_id': {'$in': member_ids}}, - projection={'_id': 1, 'full_name': 1, 'email': 1}) + projection={'_id': 1, 'full_name': 1, 'email': 1, 'avatar': 1}) return list(users) def user_has_organizations(self, user_id: bson.ObjectId) -> bool: diff --git a/pillar/api/projects/routes.py b/pillar/api/projects/routes.py index 670cd85a..d9bf9c5c 100644 --- a/pillar/api/projects/routes.py +++ b/pillar/api/projects/routes.py @@ -5,6 +5,7 @@ from bson import ObjectId from flask import Blueprint, request, current_app, make_response, url_for from werkzeug import exceptions as wz_exceptions +import pillar.api.users.avatar from pillar.api.utils import authorization, jsonify, str2id from pillar.api.utils import mongo from pillar.api.utils.authorization import require_login, check_permissions @@ -54,10 +55,13 @@ def project_manage_users(): project = projects_collection.find_one({'_id': ObjectId(project_id)}) admin_group_id = project['permissions']['groups'][0]['group'] - users = users_collection.find( + users = list(users_collection.find( {'groups': {'$in': [admin_group_id]}}, - {'username': 1, 'email': 1, 'full_name': 1}) - return jsonify({'_status': 'OK', '_items': list(users)}) + {'username': 1, 'email': 1, 'full_name': 1, 'avatar': 1})) + for user in users: + user['avatar_url'] = pillar.api.users.avatar.url(user) + user.pop('avatar', None) + return jsonify({'_status': 'OK', '_items': users}) # The request is not a form, since it comes from the API sdk data = json.loads(request.data) diff --git a/pillar/api/users/avatar.py b/pillar/api/users/avatar.py new file mode 100644 index 00000000..7a1674bb --- /dev/null +++ b/pillar/api/users/avatar.py @@ -0,0 +1,159 @@ +import functools +import io +import logging +import mimetypes +import typing + +from bson import ObjectId +from eve.methods.get import getitem_internal +import flask + +from pillar import current_app +from pillar.api import blender_id +from pillar.api.blender_cloud import home_project +import pillar.api.file_storage +from werkzeug.datastructures import FileStorage + +log = logging.getLogger(__name__) + +DEFAULT_AVATAR = 'assets/img/default_user_avatar.png' + + +def url(user: dict) -> str: + """Return the avatar URL for this user. + + :param user: dictionary from the MongoDB 'users' collection. + """ + assert isinstance(user, dict), f'user must be dict, not {type(user)}' + + avatar_id = user.get('avatar', {}).get('file') + if not avatar_id: + return _default_avatar() + + # The file may not exist, in which case we get an empty string back. + return pillar.api.file_storage.get_file_url(avatar_id) or _default_avatar() + + +@functools.lru_cache(maxsize=1) +def _default_avatar() -> str: + """Return the URL path of the default avatar. + + Doesn't change after the app has started, so we just cache it. + """ + return flask.url_for('static_pillar', filename=DEFAULT_AVATAR) + + +def _extension_for_mime(mime_type: str) -> str: + # Take the longest extension. I'd rather have '.jpeg' than the weird '.jpe'. + extensions: typing.List[str] = mimetypes.guess_all_extensions(mime_type) + + try: + return max(extensions, key=len) + except ValueError: + # Raised when extensions is empty, e.g. when the mime type is unknown. + return '' + + +def _get_file_link(file_id: ObjectId) -> str: + # Get the file document via Eve to make it update the link. + file_doc, _, _, status = getitem_internal('files', _id=file_id) + assert status == 200 + + return file_doc['link'] + + +def sync_avatar(user_id: ObjectId) -> str: + """Fetch the user's avatar from Blender ID and save to storage. + + Errors are logged but do not raise an exception. + + :return: the link to the avatar, or '' if it was not processed. + """ + + users_coll = current_app.db('users') + db_user = users_coll.find_one({'_id': user_id}) + old_avatar_info = db_user.get('avatar', {}) + if isinstance(old_avatar_info, ObjectId): + old_avatar_info = {'file': old_avatar_info} + + home_proj = home_project.get_home_project(user_id) + if not home_project: + log.error('Home project of user %s does not exist, unable to store avatar', user_id) + return '' + + bid_userid = blender_id.get_user_blenderid(db_user) + if not bid_userid: + log.error('User %s has no Blender ID user-id, unable to fetch avatar', user_id) + return '' + + avatar_url = blender_id.avatar_url(bid_userid) + bid_session = blender_id.Session() + + # Avoid re-downloading the same avatar. + request_headers = {} + if avatar_url == old_avatar_info.get('last_downloaded_url') and \ + old_avatar_info.get('last_modified'): + request_headers['If-Modified-Since'] = old_avatar_info.get('last_modified') + + log.info('Downloading avatar for user %s from %s', user_id, avatar_url) + resp = bid_session.get(avatar_url, headers=request_headers, allow_redirects=True) + if resp.status_code == 304: + # File was not modified, we can keep the old file. + log.debug('Avatar for user %s was not modified on Blender ID, not re-downloading', user_id) + return _get_file_link(old_avatar_info['file']) + + resp.raise_for_status() + + mime_type = resp.headers['Content-Type'] + file_extension = _extension_for_mime(mime_type) + if not file_extension: + log.error('No file extension known for mime type %s, unable to handle avatar of user %s', + mime_type, user_id) + return '' + + filename = f'avatar-{user_id}{file_extension}' + fake_local_file = io.BytesIO(resp.content) + fake_local_file.name = filename + + # Act as if this file was just uploaded by the user, so we can reuse + # existing Pillar file-handling code. + log.debug("Uploading avatar for user %s to storage", user_id) + uploaded_file = FileStorage( + stream=fake_local_file, + filename=filename, + headers=resp.headers, + content_type=mime_type, + content_length=resp.headers['Content-Length'], + ) + + with pillar.auth.temporary_user(db_user): + upload_data = pillar.api.file_storage.upload_and_process( + fake_local_file, + uploaded_file, + str(home_proj['_id']), + # Disallow image processing, as it's a tiny file anyway and + # we'll just serve the original. + may_process_file=False, + ) + file_id = ObjectId(upload_data['file_id']) + + avatar_info = { + 'file': file_id, + 'last_downloaded_url': resp.url, + 'last_modified': resp.headers.get('Last-Modified'), + } + + # Update the user to store the reference to their avatar. + old_avatar_file_id = old_avatar_info.get('file') + update_result = users_coll.update_one({'_id': user_id}, + {'$set': {'avatar': avatar_info}}) + if update_result.matched_count == 1: + log.debug('Updated avatar for user ID %s to file %s', user_id, file_id) + else: + log.warning('Matched %d users while setting avatar for user ID %s to file %s', + update_result.matched_count, user_id, file_id) + + if old_avatar_file_id: + current_app.delete_internal('files', _id=old_avatar_file_id) + + return _get_file_link(file_id) diff --git a/pillar/api/users/hooks.py b/pillar/api/users/hooks.py index 95154f6e..7ad1bb42 100644 --- a/pillar/api/users/hooks.py +++ b/pillar/api/users/hooks.py @@ -1,13 +1,12 @@ import copy import json -import bson from eve.utils import parse_request from werkzeug import exceptions as wz_exceptions from pillar import current_app from pillar.api.users.routes import log -from pillar.api.utils.authorization import user_has_role +import pillar.api.users.avatar import pillar.auth USER_EDITABLE_FIELDS = {'full_name', 'username', 'email', 'settings'} @@ -126,7 +125,7 @@ def check_put_access(request, lookup): raise wz_exceptions.Forbidden() -def after_fetching_user(user): +def after_fetching_user(user: dict) -> None: # Deny access to auth block; authentication stuff is managed by # custom end-points. user.pop('auth', None) diff --git a/pillar/api/utils/__init__.py b/pillar/api/utils/__init__.py index 997aa4b8..803e9e6b 100644 --- a/pillar/api/utils/__init__.py +++ b/pillar/api/utils/__init__.py @@ -8,6 +8,7 @@ import logging import random import typing import urllib.request, urllib.parse, urllib.error +import warnings import bson.objectid import bson.tz_util @@ -186,6 +187,16 @@ def str2id(document_id: str) -> bson.ObjectId: def gravatar(email: str, size=64) -> typing.Optional[str]: + """Deprecated: return the Gravatar URL. + + .. deprecated:: + Use of Gravatar is deprecated, in favour of our self-hosted avatars. + See pillar.api.users.avatar.url(user). + """ + warnings.warn('pillar.api.utils.gravatar() is deprecated, ' + 'use pillar.api.users.avatar.url() instead', + category=DeprecationWarning) + if email is None: return None diff --git a/pillar/auth/__init__.py b/pillar/auth/__init__.py index 41d97c48..f7ccb248 100644 --- a/pillar/auth/__init__.py +++ b/pillar/auth/__init__.py @@ -1,11 +1,14 @@ """Authentication code common to the web and api modules.""" import collections +import contextlib +import copy +import functools import logging import typing import blinker -import bson +from bson import ObjectId from flask import session, g import flask_login from werkzeug.local import LocalProxy @@ -31,19 +34,22 @@ class UserClass(flask_login.UserMixin): def __init__(self, token: typing.Optional[str]): # We store the Token instead of ID self.id = token + self.auth_token = token self.username: str = None self.full_name: str = None - self.user_id: bson.ObjectId = None + self.user_id: ObjectId = None self.objectid: str = None - self.gravatar: str = None self.email: str = None self.roles: typing.List[str] = [] self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs. - self.group_ids: typing.List[bson.ObjectId] = [] + self.group_ids: typing.List[ObjectId] = [] self.capabilities: typing.Set[str] = set() self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema. self.badges_html: str = '' + # Stored when constructing a user from the database + self._db_user = {} + # Lazily evaluated self._has_organizations: typing.Optional[bool] = None @@ -51,10 +57,9 @@ class UserClass(flask_login.UserMixin): def construct(cls, token: str, db_user: dict) -> 'UserClass': """Constructs a new UserClass instance from a Mongo user document.""" - from ..api import utils - user = cls(token) + user._db_user = copy.deepcopy(db_user) user.user_id = db_user.get('_id') user.roles = db_user.get('roles') or [] user.group_ids = db_user.get('groups') or [] @@ -63,14 +68,13 @@ class UserClass(flask_login.UserMixin): user.full_name = db_user.get('full_name') or '' user.badges_html = db_user.get('badges', {}).get('html') or '' - # Be a little more specific than just db_user['nodes'] + # Be a little more specific than just db_user['nodes'] or db_user['avatar'] user.nodes = { 'view_progress': db_user.get('nodes', {}).get('view_progress', {}), } # Derived properties user.objectid = str(user.user_id or '') - user.gravatar = utils.gravatar(user.email) user.groups = [str(g) for g in user.group_ids] user.collect_capabilities() @@ -170,13 +174,24 @@ class UserClass(flask_login.UserMixin): 'user_id': str(self.user_id), 'username': self.username, 'full_name': self.full_name, - 'gravatar': self.gravatar, + 'avatar_url': self.avatar_url, 'email': self.email, 'capabilities': list(self.capabilities), 'badges_html': self.badges_html, 'is_authenticated': self.is_authenticated, } + @property + @functools.lru_cache(maxsize=1) + def avatar_url(self) -> str: + """Return the Avatar image URL for this user. + + :return: The avatar URL (the default one if the user has no avatar). + """ + + import pillar.api.users.avatar + return pillar.api.users.avatar.url(self._db_user) + class AnonymousUser(flask_login.AnonymousUserMixin, UserClass): def __init__(self): @@ -260,6 +275,25 @@ def logout_user(): g.current_user = AnonymousUser() +@contextlib.contextmanager +def temporary_user(db_user: dict): + """Temporarily sets the given user as 'current user'. + + Does not trigger login signals, as this is not a real login action. + """ + try: + actual_current_user = g.current_user + except AttributeError: + actual_current_user = AnonymousUser() + + temp_user = UserClass.construct('', db_user) + try: + g.current_user = temp_user + yield + finally: + g.current_user = actual_current_user + + def get_blender_id_oauth_token() -> str: """Returns the Blender ID auth token, or an empty string if there is none.""" diff --git a/pillar/celery/avatar.py b/pillar/celery/avatar.py new file mode 100644 index 00000000..141d990b --- /dev/null +++ b/pillar/celery/avatar.py @@ -0,0 +1,29 @@ +"""Avatar synchronisation. + +Note that this module can only be imported when an application context is +active. Best to late-import this in the functions where it's needed. +""" +import logging + +from bson import ObjectId +import celery + +from pillar import current_app +from pillar.api.users.avatar import sync_avatar + +log = logging.getLogger(__name__) + + +@current_app.celery.task(bind=True, ignore_result=True, acks_late=True) +def sync_avatar_for_user(self: celery.Task, user_id: str): + """Downloads the user's avatar from Blender ID.""" + # WARNING: when changing the signature of this function, also change the + # self.retry() call below. + + uid = ObjectId(user_id) + + try: + sync_avatar(uid) + except (IOError, OSError): + log.exception('Error downloading Blender ID avatar for user %s, will retry later') + self.retry((user_id, ), countdown=current_app.config['AVATAR_DOWNLOAD_CELERY_RETRY']) diff --git a/pillar/config.py b/pillar/config.py index a428c22d..9e39aabc 100644 --- a/pillar/config.py +++ b/pillar/config.py @@ -217,6 +217,8 @@ CELERY_BEAT_SCHEDULE = { # TODO(Sybren): A proper value should be determined after we actually have users with badges. BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4) +# How many times the Celery task for downloading an avatar is retried. +AVATAR_DOWNLOAD_CELERY_RETRY = 3 # Mapping from user role to capabilities obtained by users with that role. USER_CAPABILITIES = defaultdict(**{ diff --git a/pillar/web/organizations/routes.py b/pillar/web/organizations/routes.py index d33c3465..8be1d227 100644 --- a/pillar/web/organizations/routes.py +++ b/pillar/web/organizations/routes.py @@ -6,7 +6,8 @@ from flask_login import current_user import pillar.flask_extra from pillar import current_app -from pillar.api.utils import authorization, str2id, gravatar, jsonify +import pillar.api.users.avatar +from pillar.api.utils import authorization, str2id, jsonify from pillar.web.system_util import pillar_api from pillarsdk import Organization, User @@ -47,7 +48,7 @@ def view_embed(organization_id: str): members = om.org_members(organization.members) for member in members: - member['avatar'] = gravatar(member.get('email')) + member['avatar'] = pillar.api.users.avatar.url(member) member['_id'] = str(member['_id']) admin_user = User.find(organization.admin_uid, api=api) diff --git a/pillar/web/projects/routes.py b/pillar/web/projects/routes.py index 3fc33387..9d1a9c4f 100644 --- a/pillar/web/projects/routes.py +++ b/pillar/web/projects/routes.py @@ -22,6 +22,7 @@ import werkzeug.exceptions as wz_exceptions from pillar import current_app from pillar.api.utils import utcnow +import pillar.api.users.avatar from pillar.web import system_util from pillar.web import utils from pillar.web.nodes import finders @@ -109,7 +110,6 @@ def index(): return render_template( 'projects/index_dashboard.html', - gravatar=utils.gravatar(current_user.email, size=128), projects_user=projects_user['_items'], projects_deleted=projects_deleted['_items'], projects_shared=projects_shared['_items'], @@ -402,7 +402,6 @@ def render_project(project, api, extra_context=None, template_name=None): template_name = template_name or 'projects/home_index.html' return render_template( template_name, - gravatar=utils.gravatar(current_user.email, size=128), project=project, api=system_util.pillar_api(), **extra_context) @@ -708,15 +707,12 @@ def sharing(project_url): api = system_util.pillar_api() # Fetch the project or 404 try: - project = Project.find_one({ - 'where': '{"url" : "%s"}' % (project_url)}, api=api) + project = Project.find_one({'where': {'url': project_url}}, api=api) except ResourceNotFound: return abort(404) # Fetch users that are part of the admin group users = project.get_users(api=api) - for user in users['_items']: - user['avatar'] = utils.gravatar(user['email']) if request.method == 'POST': user_id = request.form['user_id'] @@ -726,13 +722,14 @@ def sharing(project_url): user = project.add_user(user_id, api=api) elif action == 'remove': user = project.remove_user(user_id, api=api) + else: + raise wz_exceptions.BadRequest(f'invalid action {action}') except ResourceNotFound: log.info('/p/%s/edit/sharing: User %s not found', project_url, user_id) return jsonify({'_status': 'ERROR', 'message': 'User %s not found' % user_id}), 404 - # Add gravatar to user - user['avatar'] = utils.gravatar(user['email']) + user['avatar'] = pillar.api.users.avatar.url(user) return jsonify(user) utils.attach_project_pictures(project, api) diff --git a/pillar/web/settings/routes.py b/pillar/web/settings/routes.py index d0eb32d4..5d17cffc 100644 --- a/pillar/web/settings/routes.py +++ b/pillar/web/settings/routes.py @@ -3,14 +3,16 @@ import logging import urllib.parse from flask import Blueprint, flash, render_template -from flask_login import login_required, current_user +from flask_login import login_required from werkzeug.exceptions import abort from pillar import current_app +from pillar.api.utils import jsonify +import pillar.api.users.avatar from pillar.auth import current_user from pillar.web import system_util from pillar.web.users import forms -from pillarsdk import User, exceptions as sdk_exceptions +from pillarsdk import File, User, exceptions as sdk_exceptions log = logging.getLogger(__name__) blueprint = Blueprint('settings', __name__) @@ -51,3 +53,19 @@ def profile(): def roles(): """Show roles and capabilties of the current user.""" return render_template('users/settings/roles.html', title='roles') + + +@blueprint.route('/profile/sync-avatar', methods=['POST']) +@login_required +def sync_avatar(): + """Fetch the user's avatar from Blender ID and save to storage. + + This is an API-like endpoint, in the sense that it returns JSON. + It's here in this file to have it close to the endpoint that + serves the only page that calls on this endpoint. + """ + + new_url = pillar.api.users.avatar.sync_avatar(current_user.user_id) + if not new_url: + return jsonify({'_message': 'Your avatar could not be updated'}) + return new_url diff --git a/pillar/web/static/assets/img/default_user_avatar.png b/pillar/web/static/assets/img/default_user_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..8e9b99040246267491059982f5e6d29ac77ad170 GIT binary patch literal 496 zcmV005u@0{{R3_Mwly0000CP)t-sySuy0 z%)89Y%-!AHR8YhC00001VoOIv0Eh)0NB{r;32;bRa{vGf6951U69E94oEQKA00(qQ zO+^Re1r-V!4yhrzTL1t7YDq*vR9M69m^p64Fc3uvvXctkBPo0Y=V$_Q5te~cq%kaaHV>sZiA2}FqKQAN-(=;cl=^sM93D25$sJ)Q4uO}Z%9j$6AcftHvP z`B|Z#a@>1v@J%+}T-`)%B)i5M<`+BtRQSzOP~@XxuDxgE6_eTyRj?;5e;3@yg1!nG z7NmWo!9ji{oB+}Zr@?JHJ^u=HT!EhvtXDWL2I~5g`3gbLb?YU-^`qvCf-YQo_NW^G mc%Z(9r>XrBXliQux7q`229*})#E^gh0000 `; diff --git a/src/scripts/js/es6/common/vuecomponents/user/CurrentUserAvatar.js b/src/scripts/js/es6/common/vuecomponents/user/CurrentUserAvatar.js new file mode 100644 index 00000000..4d0927c8 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/user/CurrentUserAvatar.js @@ -0,0 +1,23 @@ +const TEMPLATE = ` +Your avatar +` + +export let CurrentUserAvatar = Vue.component("current-user-avatar", { + data: function() { return { + avatarUrl: "", + }}, + template: TEMPLATE, + created: function() { + pillar.utils.currentUserEventBus.$on(pillar.utils.UserEvents.USER_LOADED, this.updateAvatarURL); + this.updateAvatarURL(pillar.utils.getCurrentUser()); + }, + methods: { + updateAvatarURL(user) { + if (typeof user === 'undefined') { + this.avatarUrl = ''; + return; + } + this.avatarUrl = user.avatar_url; + }, + }, +}); diff --git a/src/scripts/js/es6/individual/avatar/AvatarSync.js b/src/scripts/js/es6/individual/avatar/AvatarSync.js new file mode 100644 index 00000000..7f69096a --- /dev/null +++ b/src/scripts/js/es6/individual/avatar/AvatarSync.js @@ -0,0 +1,39 @@ +// The is given a fixed width so that the button doesn't resize when we change the icon. +const TEMPLATE = ` + +` + +Vue.component("avatar-sync-button", { + template: TEMPLATE, + data() { return { + isSyncing: false, + }}, + methods: { + syncAvatar() { + this.isSyncing = true; + + $.ajax({ + type: 'POST', + url: `/settings/profile/sync-avatar`, + }) + .then(response => { + toastr.info("sync was OK"); + + let user = pillar.utils.getCurrentUser(); + user.avatar_url = response; + pillar.utils.updateCurrentUser(user); + }) + .catch(err => { + toastr.error(xhrErrorResponseMessage(err), "There was an error syncing your avatar"); + }) + .then(() => { + this.isSyncing = false; + }) + }, + }, +}); diff --git a/src/scripts/js/es6/individual/avatar/init.js b/src/scripts/js/es6/individual/avatar/init.js new file mode 100644 index 00000000..f5935dcb --- /dev/null +++ b/src/scripts/js/es6/individual/avatar/init.js @@ -0,0 +1 @@ +export { AvatarSync } from './AvatarSync'; diff --git a/src/styles/_user.sass b/src/styles/_user.sass index 56486a52..42186775 100644 --- a/src/styles/_user.sass +++ b/src/styles/_user.sass @@ -101,3 +101,9 @@ color: $color-success &.fail color: $color-danger + +img.user-avatar + border-radius: 1em + box-shadow: 0 0 0 0.2em $color-background-light + height: 160px + width: 160px diff --git a/src/templates/menus/user_base.pug b/src/templates/menus/user_base.pug index c123dd59..87e814e1 100644 --- a/src/templates/menus/user_base.pug +++ b/src/templates/menus/user_base.pug @@ -4,9 +4,9 @@ li.dropdown | {% block menu_avatar %} a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}") - img.gravatar( - src="{{ current_user.gravatar }}", - alt="Avatar") + current-user-avatar + script. + new Vue({el: 'current-user-avatar'}) | {% endblock menu_avatar %} ul.dropdown-menu.dropdown-menu-right diff --git a/src/templates/organizations/view_embed.pug b/src/templates/organizations/view_embed.pug index 9bc2a31b..07737c08 100644 --- a/src/templates/organizations/view_embed.pug +++ b/src/templates/organizations/view_embed.pug @@ -165,7 +165,7 @@ h4 Organization members | {% for email in organization.unknown_members %} li.sharing-users-item.unknown-member(data-user-email='{{ email }}') .sharing-users-avatar - img(src="{{ email | gravatar }}") + img(src="{{ url_for('static_pillar', filename='assets/img/default_user_avatar.png') }}") .sharing-users-details span.sharing-users-email {{ email }} .sharing-users-action diff --git a/src/templates/projects/sharing.pug b/src/templates/projects/sharing.pug index 1da20871..5d599cb0 100644 --- a/src/templates/projects/sharing.pug +++ b/src/templates/projects/sharing.pug @@ -19,7 +19,7 @@ user-id="{{ user['_id'] }}", class="{% if current_user.objectid == user['_id'] %}self{% endif %}") .sharing-users-avatar - img(src="{{ user['avatar'] }}") + img(src="{{ user['avatar_url'] }}") .sharing-users-details span.sharing-users-name | {{user['full_name']}} diff --git a/src/templates/users/settings/profile.pug b/src/templates/users/settings/profile.pug index 686ea4cf..b975ba8c 100644 --- a/src/templates/users/settings/profile.pug +++ b/src/templates/users/settings/profile.pug @@ -21,38 +21,50 @@ style. | {% block settings_page_content %} .settings-form form#settings-form(method='POST', action="{{url_for('settings.profile')}}") - .pb-3 - .form-group + .row + .form-group.col-md-6 | {{ form.username.label }} | {{ form.username(size=20, class='form-control') }} | {% if form.username.errors %} | {% for error in form.username.errors %}{{ error|e }}{% endfor %} | {% endif %} - .form-group - label {{ _("Full name") }} - p {{ current_user.full_name }} - .form-group - label {{ _("E-mail") }} - p {{ current_user.email }} + button.mt-3.btn.btn-outline-success.px-5.button-submit(type='submit') + i.pi-check.pr-2 + | {{ _("Save Changes") }} - .form-group - | {{ _("Change your full name, email, and password at") }} #[a(href="{{ blender_profile_url }}",target='_blank') Blender ID]. + .row.mt-3 + .col-md-9 + .form-group + label {{ _("Full name") }} + p {{ current_user.full_name }} + .form-group + label {{ _("E-mail") }} + p {{ current_user.email }} + .form-group + | {{ _("Change your full name, email, avatar, and password at") }} #[a(href="{{ blender_profile_url }}",target='_blank') Blender ID]. - | {% if current_user.badges_html %} - .form-group - p Your Blender ID badges: - | {{ current_user.badges_html|safe }} - p.hint-text Note that updates to these badges may take a few minutes to be visible here. - | {% endif %} + | {% if current_user.badges_html %} + .form-group + p Your Blender ID badges: + | {{ current_user.badges_html|safe }} + p.hint-text Note that updates to these badges may take a few minutes to be visible here. + | {% endif %} - .py-3 - a(href="https://gravatar.com/") - img.rounded-circle(src="{{ current_user.gravatar }}") - span.p-3 {{ _("Change Gravatar") }} + .col-md-3 + a(href="{{ blender_profile_url }}",target='_blank') + current-user-avatar + p + small Your #[a(href="{{ blender_profile_url }}",target='_blank') Blender ID] avatar + //- Avatar Sync button is commented out here, because it's not used by Blender Cloud. + //- This tag, and the commented-out script tag below, are just examples. + //- avatar-sync-button - .py-3 - button.btn.btn-outline-success.px-5.button-submit(type='submit') - i.pi-check.pr-2 - | {{ _("Save Changes") }} +| {% endblock %} + +| {% block footer_scripts %} +| {{ super() }} +//- script(src="{{ url_for('static_pillar', filename='assets/js/avatar.min.js') }}") +script. + new Vue({el:'#settings-form'}); | {% endblock %} diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py index 09c13d91..764d2f00 100644 --- a/tests/test_api/test_auth.py +++ b/tests/test_api/test_auth.py @@ -320,7 +320,7 @@ class UserListTests(AbstractPillarTest): user_info = json.loads(resp.data) regular_info = remove_private_keys(user_info) - self.assertEqual(PUBLIC_USER_FIELDS, set(regular_info.keys())) + self.assertEqual(set(), set(regular_info.keys()) - PUBLIC_USER_FIELDS) def test_own_user_subscriber(self): # Regular access should result in only your own info. @@ -342,7 +342,7 @@ class UserListTests(AbstractPillarTest): self.assertNotIn('auth', user_info) regular_info = remove_private_keys(user_info) - self.assertEqual(PUBLIC_USER_FIELDS, set(regular_info.keys())) + self.assertEqual(set(), set(regular_info.keys()) - PUBLIC_USER_FIELDS) def test_put_user(self): from pillar.api.utils import remove_private_keys