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).
This commit is contained in:
parent
8a19efe7a7
commit
47474ac936
@ -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]
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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': {
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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')
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
159
pillar/api/users/avatar.py
Normal file
159
pillar/api/users/avatar.py
Normal file
@ -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)
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
29
pillar/celery/avatar.py
Normal file
29
pillar/celery/avatar.py
Normal file
@ -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'])
|
@ -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(**{
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
BIN
pillar/web/static/assets/img/default_user_avatar.png
Normal file
BIN
pillar/web/static/assets/img/default_user_avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 496 B |
@ -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)
|
||||
|
@ -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 }
|
||||
function updateCurrentUser(user) {
|
||||
currentUser = user;
|
||||
currentUserEventBus.$emit(UserEvents.USER_LOADED, currentUser);
|
||||
}
|
||||
|
||||
export { getCurrentUser, initCurrentUser, updateCurrentUser, currentUserEventBus }
|
||||
|
@ -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){
|
||||
|
@ -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,
|
||||
|
@ -1,7 +1,7 @@
|
||||
const TEMPLATE = `
|
||||
<div class="user-avatar">
|
||||
<img
|
||||
:src="user.gravatar"
|
||||
:src="user.avatar_url"
|
||||
:alt="user.full_name">
|
||||
</div>
|
||||
`;
|
||||
|
@ -0,0 +1,23 @@
|
||||
const TEMPLATE = `
|
||||
<img class="user-avatar" :src="avatarUrl" alt="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;
|
||||
},
|
||||
},
|
||||
});
|
39
src/scripts/js/es6/individual/avatar/AvatarSync.js
Normal file
39
src/scripts/js/es6/individual/avatar/AvatarSync.js
Normal file
@ -0,0 +1,39 @@
|
||||
// The <i> is given a fixed width so that the button doesn't resize when we change the icon.
|
||||
const TEMPLATE = `
|
||||
<button class="btn btn-outline-primary" type="button" @click="syncAvatar"
|
||||
:disabled="isSyncing">
|
||||
<i style="width: 2em; display: inline-block"
|
||||
:class="{'pi-refresh': !isSyncing, 'pi-spin': isSyncing, spin: isSyncing}"></i>
|
||||
Fetch Avatar from Blender ID
|
||||
</button>
|
||||
`
|
||||
|
||||
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;
|
||||
})
|
||||
},
|
||||
},
|
||||
});
|
1
src/scripts/js/es6/individual/avatar/init.js
Normal file
1
src/scripts/js/es6/individual/avatar/init.js
Normal file
@ -0,0 +1 @@
|
||||
export { AvatarSync } from './AvatarSync';
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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']}}
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user