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:
Sybren A. Stüvel 2019-05-24 17:36:06 +02:00
parent 8a19efe7a7
commit 47474ac936
33 changed files with 516 additions and 93 deletions

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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': {

View File

@ -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

View File

@ -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]

View File

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

View File

@ -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:

View File

@ -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
View 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)

View File

@ -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)

View File

@ -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

View File

@ -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
View 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'])

View File

@ -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(**{

View File

@ -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)

View File

@ -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)

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

View File

@ -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)

View File

@ -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 }

View File

@ -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){

View File

@ -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,

View File

@ -1,7 +1,7 @@
const TEMPLATE = `
<div class="user-avatar">
<img
:src="user.gravatar"
:src="user.avatar_url"
:alt="user.full_name">
</div>
`;

View File

@ -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;
},
},
});

View 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;
})
},
},
});

View File

@ -0,0 +1 @@
export { AvatarSync } from './AvatarSync';

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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']}}

View File

@ -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 %}

View File

@ -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