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 00000000..8e9b9904 Binary files /dev/null and b/pillar/web/static/assets/img/default_user_avatar.png differ diff --git a/pillar/web/utils/__init__.py b/pillar/web/utils/__init__.py index 2d000bd6..74b2bbaf 100644 --- a/pillar/web/utils/__init__.py +++ b/pillar/web/utils/__init__.py @@ -136,9 +136,16 @@ def mass_attach_project_pictures(projects: typing.Iterable[pillarsdk.Project], * def gravatar(email: str, size=64): + """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). + """ import warnings - warnings.warn("the pillar.web.gravatar function is deprecated; use hashlib instead", - DeprecationWarning, 2) + warnings.warn('pillar.web.utils.gravatar() is deprecated, ' + 'use pillar.api.users.avatar.url() instead', + category=DeprecationWarning, stacklevel=2) from pillar.api.utils import gravatar as api_gravatar return api_gravatar(email, size) diff --git a/src/scripts/js/es6/common/utils/currentuser.js b/src/scripts/js/es6/common/utils/currentuser.js index e3347fe9..e813347c 100644 --- a/src/scripts/js/es6/common/utils/currentuser.js +++ b/src/scripts/js/es6/common/utils/currentuser.js @@ -1,9 +1,14 @@ +export const UserEvents = { + USER_LOADED: 'user-loaded', +} +let currentUserEventBus = new Vue(); + class User{ constructor(kwargs) { this.user_id = kwargs['user_id'] || ''; this.username = kwargs['username'] || ''; this.full_name = kwargs['full_name'] || ''; - this.gravatar = kwargs['gravatar'] || ''; + this.avatar_url = kwargs['avatar_url'] || ''; this.email = kwargs['email'] || ''; this.capabilities = kwargs['capabilities'] || []; this.badges_html = kwargs['badges_html'] || ''; @@ -12,7 +17,7 @@ class User{ /** * """Returns True iff the user has one or more of the given capabilities.""" - * @param {...String} args + * @param {...String} args */ hasCap(...args) { for(let cap of args) { @@ -25,10 +30,16 @@ class User{ let currentUser; function initCurrentUser(kwargs){ currentUser = new User(kwargs); + currentUserEventBus.$emit(UserEvents.USER_LOADED, currentUser); } function getCurrentUser() { return currentUser; } -export { getCurrentUser, initCurrentUser } \ No newline at end of file +function updateCurrentUser(user) { + currentUser = user; + currentUserEventBus.$emit(UserEvents.USER_LOADED, currentUser); +} + +export { getCurrentUser, initCurrentUser, updateCurrentUser, currentUserEventBus } diff --git a/src/scripts/js/es6/common/utils/init.js b/src/scripts/js/es6/common/utils/init.js index 18ef5307..7a9f9a07 100644 --- a/src/scripts/js/es6/common/utils/init.js +++ b/src/scripts/js/es6/common/utils/init.js @@ -1,6 +1,6 @@ export { transformPlaceholder } from './placeholder' export { prettyDate } from './prettydate' -export { getCurrentUser, initCurrentUser } from './currentuser' +export { getCurrentUser, initCurrentUser, updateCurrentUser, currentUserEventBus, UserEvents } from './currentuser' export { thenLoadImage } from './files' @@ -19,7 +19,7 @@ export function debounced(fn, delay=1000) { /** * Extracts error message from error of type String, Error or xhrError - * @param {*} err + * @param {*} err * @returns {String} */ export function messageFromError(err){ diff --git a/src/scripts/js/es6/common/vuecomponents/init.js b/src/scripts/js/es6/common/vuecomponents/init.js index 0382fac6..ee9522bb 100644 --- a/src/scripts/js/es6/common/vuecomponents/init.js +++ b/src/scripts/js/es6/common/vuecomponents/init.js @@ -19,6 +19,7 @@ import { StatusFilter } from './table/rows/filter/StatusFilter' import { TextFilter } from './table/rows/filter/TextFilter' import { NameFilter } from './table/rows/filter/NameFilter' import { UserAvatar } from './user/Avatar' +import './user/CurrentUserAvatar' let mixins = { UnitOfWorkTracker, diff --git a/src/scripts/js/es6/common/vuecomponents/user/Avatar.js b/src/scripts/js/es6/common/vuecomponents/user/Avatar.js index 30ca53f7..f2e2e93a 100644 --- a/src/scripts/js/es6/common/vuecomponents/user/Avatar.js +++ b/src/scripts/js/es6/common/vuecomponents/user/Avatar.js @@ -1,7 +1,7 @@ const TEMPLATE = `