From e9cac407dfeed35730811ff0f91e971863789f9c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 24 Nov 2014 19:25:13 -0500 Subject: [PATCH] Add a configurable avatar system and add an internal avatar system for enterprise --- Dockerfile.web | 2 +- app.py | 2 + avatars/__init__.py | 0 avatars/avatars.py | 63 ++++++++++++++ config.py | 4 +- data/database.py | 2 +- endpoints/api/organization.py | 27 +++--- endpoints/api/search.py | 4 +- endpoints/api/team.py | 6 +- endpoints/api/user.py | 13 +-- endpoints/web.py | 20 ++++- static/css/quay.css | 2 +- static/directives/application-info.html | 2 +- static/directives/avatar.html | 1 + static/directives/entity-reference.html | 6 +- static/directives/header-bar.html | 2 +- static/directives/namespace-selector.html | 10 +-- static/directives/notification-view.html | 2 +- static/directives/organization-header.html | 2 +- static/directives/trigger-setup-github.html | 4 +- static/img/empty.png | Bin 0 -> 95 bytes static/js/app.js | 89 ++++++++++++++++++-- static/js/controllers.js | 4 +- static/partials/about.html | 6 -- static/partials/landing-login.html | 2 +- static/partials/landing-normal.html | 2 +- static/partials/manage-application.html | 14 +-- static/partials/org-admin.html | 2 +- static/partials/organizations.html | 2 +- static/partials/team-view.html | 6 +- static/partials/user-admin.html | 6 +- templates/oauthorize.html | 5 +- test/test_api_usage.py | 4 +- util/gravatar.py | 5 -- util/jinjautil.py | 11 ++- util/useremails.py | 1 - 36 files changed, 241 insertions(+), 92 deletions(-) create mode 100644 avatars/__init__.py create mode 100644 avatars/avatars.py create mode 100644 static/directives/avatar.html create mode 100644 static/img/empty.png delete mode 100644 util/gravatar.py diff --git a/Dockerfile.web b/Dockerfile.web index 575332a3a..dd91393f7 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -8,7 +8,7 @@ ENV HOME /root RUN apt-get update # 10SEP2014 # New ubuntu packages should be added as their own apt-get install lines below the existing install commands -RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62 libjpeg62-dev libevent-2.0.5 libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap-2.4-2 libldap2-dev libsasl2-modules libsasl2-dev libpq5 libpq-dev +RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62 libjpeg62-dev libevent-2.0.5 libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap-2.4-2 libldap2-dev libsasl2-modules libsasl2-dev libpq5 libpq-dev libfreetype6-dev # Build the python dependencies ADD requirements.txt requirements.txt diff --git a/app.py b/app.py index 33c22d818..327a62d60 100644 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ from data.buildlogs import BuildLogs from data.archivedlogs import LogArchive from data.queue import WorkQueue from data.userevent import UserEventsBuilderModule +from avatars.avatars import Avatar class Config(BaseConfig): @@ -119,6 +120,7 @@ features.import_features(app.config) Principal(app, use_sessions=False) +avatar = Avatar(app) login_manager = LoginManager(app) mail = Mail(app) storage = Storage(app) diff --git a/avatars/__init__.py b/avatars/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/avatars/avatars.py b/avatars/avatars.py new file mode 100644 index 000000000..386b5fc57 --- /dev/null +++ b/avatars/avatars.py @@ -0,0 +1,63 @@ +import hashlib + +class Avatar(object): + def __init__(self, app=None): + self.app = app + self.state = self._init_app(app) + + def _init_app(self, app): + return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')]( + app.config['SERVER_HOSTNAME'], + app.config['PREFERRED_URL_SCHEME']) + + def __getattr__(self, name): + return getattr(self.state, name, None) + + +class BaseAvatar(object): + """ Base class for all avatar implementations. """ + def __init__(self, server_hostname, preferred_url_scheme): + self.server_hostname = server_hostname + self.preferred_url_scheme = preferred_url_scheme + + def get_url(self, email, size=16, name=None): + """ Returns the full URL for viewing the avatar of the given email address, with + an optional size. + """ + raise NotImplementedError + + def compute_hash(self, email, name=None): + """ Computes the avatar hash for the given email address. If the name is given and a default + avatar is being computed, the name can be used in place of the email address. """ + raise NotImplementedError + + +class GravatarAvatar(BaseAvatar): + """ Avatar system that uses gravatar for generating avatars. """ + def compute_hash(self, email, name=None): + return hashlib.md5(email.strip().lower()).hexdigest() + + def get_url(self, email, size=16, name=None): + computed = self.compute_hash(email, name=name) + return '%s://www.gravatar.com/avatar/%s?d=identicon&size=%s' % (self.preferred_url_scheme, + computed, size) + +class LocalAvatar(BaseAvatar): + """ Avatar system that uses the local system for generating avatars. """ + def compute_hash(self, email, name=None): + if not name and not email: + return '' + + prefix = name if name else email + return prefix[0] + hashlib.md5(email.strip().lower()).hexdigest() + + def get_url(self, email, size=16, name=None): + computed = self.compute_hash(email, name=name) + return '%s://%s/avatar/%s?size=%s' % (self.preferred_url_scheme, self.server_hostname, + computed, size) + + +AVATAR_CLASSES = { + 'gravatar': GravatarAvatar, + 'local': LocalAvatar +} diff --git a/config.py b/config.py index 506e23b74..2a16e292b 100644 --- a/config.py +++ b/config.py @@ -19,7 +19,7 @@ def build_requests_session(): CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY', 'STRIPE_PUBLISHABLE_KEY', 'ENTERPRISE_LOGO_URL', 'SENTRY_PUBLIC_DSN', 'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', - 'CONTACT_INFO'] + 'CONTACT_INFO', 'AVATAR_KIND'] def getFrontendVisibleConfig(config_dict): @@ -46,6 +46,8 @@ class DefaultConfig(object): PREFERRED_URL_SCHEME = 'http' SERVER_HOSTNAME = 'localhost:5000' + AVATAR_KIND = 'local' + REGISTRY_TITLE = 'Quay.io' REGISTRY_TITLE_SHORT = 'Quay.io' CONTACT_INFO = [ diff --git a/data/database.py b/data/database.py index 2cb1a51ca..21cd4ef10 100644 --- a/data/database.py +++ b/data/database.py @@ -474,7 +474,7 @@ class OAuthApplication(BaseModel): name = CharField() description = TextField(default='') - gravatar_email = CharField(null=True) + avatar_email = CharField(null=True, db_column='gravatar_email') class OAuthAuthorizationCode(BaseModel): diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index f6a381ace..46e30bd44 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -2,7 +2,7 @@ import logging from flask import request -from app import billing as stripe +from app import billing as stripe, avatar from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, related_user_resource, internal_only, Unauthorized, NotFound, require_user_admin, log_action, show_if) @@ -13,7 +13,6 @@ from auth.permissions import (AdministerOrganizationPermission, OrganizationMem from auth.auth_context import get_authenticated_user from data import model from data.billing import get_plan -from util.gravatar import compute_hash import features @@ -28,7 +27,7 @@ def org_view(o, teams): view = { 'name': o.username, 'email': o.email if is_admin else '', - 'gravatar': compute_hash(o.email), + 'avatar': avatar.compute_hash(o.email, name=o.username), 'teams': {t.name : team_view(o.username, t) for t in teams}, 'is_admin': is_admin } @@ -274,14 +273,16 @@ class ApplicationInformation(ApiResource): if not application: raise NotFound() - org_hash = compute_hash(application.organization.email) - gravatar = compute_hash(application.gravatar_email) if application.gravatar_email else org_hash + org_hash = avatar.compute_hash(application.organization.email, + name=application.organization.username) + app_hash = (avatar.compute_hash(application.avatar_email, name=application.name) if + application.avatar_email else org_hash) return { 'name': application.name, 'description': application.description, 'uri': application.application_uri, - 'gravatar': gravatar, + 'avatar': app_hash, 'organization': org_view(application.organization, []) } @@ -297,7 +298,7 @@ def app_view(application): 'client_id': application.client_id, 'client_secret': application.client_secret if is_admin else None, 'redirect_uri': application.redirect_uri if is_admin else None, - 'gravatar_email': application.gravatar_email if is_admin else None, + 'avatar_email': application.avatar_email if is_admin else None, } @@ -330,9 +331,9 @@ class OrganizationApplications(ApiResource): 'type': 'string', 'description': 'The human-readable description for the application', }, - 'gravatar_email': { + 'avatar_email': { 'type': 'string', - 'description': 'The e-mail address of the gravatar to use for the application', + 'description': 'The e-mail address of the avatar to use for the application', } }, }, @@ -371,7 +372,7 @@ class OrganizationApplications(ApiResource): app_data.get('application_uri', ''), app_data.get('redirect_uri', ''), description = app_data.get('description', ''), - gravatar_email = app_data.get('gravatar_email', None),) + avatar_email = app_data.get('avatar_email', None),) app_data.update({ @@ -416,9 +417,9 @@ class OrganizationApplicationResource(ApiResource): 'type': 'string', 'description': 'The human-readable description for the application', }, - 'gravatar_email': { + 'avatar_email': { 'type': 'string', - 'description': 'The e-mail address of the gravatar to use for the application', + 'description': 'The e-mail address of the avatar to use for the application', } }, }, @@ -462,7 +463,7 @@ class OrganizationApplicationResource(ApiResource): application.application_uri = app_data['application_uri'] application.redirect_uri = app_data['redirect_uri'] application.description = app_data.get('description', '') - application.gravatar_email = app_data.get('gravatar_email', None) + application.avatar_email = app_data.get('avatar_email', None) application.save() app_data.update({ diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 1cce618d9..728539af3 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -6,7 +6,7 @@ from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, AdministerOrganizationPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from util.gravatar import compute_hash +from app import avatar @resource('/v1/entities/') @@ -44,7 +44,7 @@ class EntitySearch(ApiResource): 'name': namespace_name, 'kind': 'org', 'is_org_member': True, - 'gravatar': compute_hash(organization.email), + 'avatar': avatar.compute_hash(organization.email, name=organization.username), }] except model.InvalidOrganizationException: diff --git a/endpoints/api/team.py b/endpoints/api/team.py index a448cefc9..cfc79f581 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -8,7 +8,7 @@ from auth.auth_context import get_authenticated_user from auth import scopes from data import model from util.useremails import send_org_invite_email -from util.gravatar import compute_hash +from app import avatar import features @@ -63,7 +63,7 @@ def member_view(member, invited=False): 'name': member.username, 'kind': 'user', 'is_robot': member.robot, - 'gravatar': compute_hash(member.email) if not member.robot else None, + 'avatar': avatar.compute_hash(member.email, name=member.username) if not member.robot else None, 'invited': invited, } @@ -75,7 +75,7 @@ def invite_view(invite): return { 'email': invite.email, 'kind': 'invite', - 'gravatar': compute_hash(invite.email), + 'avatar': avatar.compute_hash(invite.email), 'invited': True } diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 8cad4b30a..04ef50a07 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -5,7 +5,7 @@ from flask import request from flask.ext.login import logout_user from flask.ext.principal import identity_changed, AnonymousIdentity -from app import app, billing as stripe, authentication +from app import app, billing as stripe, authentication, avatar from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, parse_args, query_param, InvalidToken, require_scope, format_date, hide_if, show_if, @@ -20,7 +20,6 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository UserAdminPermission, UserReadPermission, SuperUserPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from util.gravatar import compute_hash from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email, send_password_changed) from util.names import parse_single_urn @@ -34,7 +33,7 @@ def user_view(user): admin_org = AdministerOrganizationPermission(o.username) return { 'name': o.username, - 'gravatar': compute_hash(o.email), + 'avatar': avatar.compute_hash(o.email, name=o.username), 'is_org_admin': admin_org.can(), 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), 'preferred_namespace': not (o.stripe_id is None) @@ -61,7 +60,7 @@ def user_view(user): 'anonymous': False, 'username': user.username, 'email': user.email, - 'gravatar': compute_hash(user.email), + 'avatar': avatar.compute_hash(user.email, name=user.username), } user_admin = UserAdminPermission(user.username) @@ -572,10 +571,12 @@ def authorization_view(access_token): 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, - 'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email), + 'avatar': avatar.compute_hash(oauth_app.avatar_email or oauth_app.organization.email, + name=oauth_app.name), 'organization': { 'name': oauth_app.organization.username, - 'gravatar': compute_hash(oauth_app.organization.email) + 'avatar': avatar.compute_hash(oauth_app.organization.email, + name=oauth_app.organization.username) } }, 'scopes': scopes.get_scope_information(access_token.scope), diff --git a/endpoints/web.py b/endpoints/web.py index f957e2a97..0ab3ff6db 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -3,13 +3,15 @@ import os from flask import (abort, redirect, request, url_for, make_response, Response, Blueprint, send_from_directory, jsonify) + +from avatar_generator import Avatar from flask.ext.login import current_user from urlparse import urlparse from health.healthcheck import HealthCheck from data import model from data.model.oauth import DatabaseAuthorizationProvider -from app import app, billing as stripe, build_logs +from app import app, billing as stripe, build_logs, avatar from auth.auth import require_session_login, process_oauth from auth.permissions import AdministerOrganizationPermission, ReadRepositoryPermission from util.invoice import renderInvoiceToPdf @@ -18,7 +20,6 @@ from util.cache import no_cache from endpoints.common import common_login, render_page_template, route_show_if, param_required from endpoints.csrf import csrf_protect, generate_csrf_token from util.names import parse_repository_name -from util.gravatar import compute_hash from util.useremails import send_email_changed from auth import scopes @@ -182,6 +183,18 @@ def status(): return response +@app.route("/avatar/") +def avatar(avatar_hash): + try: + size = int(request.args.get('size', 16)) + except ValueError: + size = 16 + + generated = Avatar.generate(size, avatar_hash) + headers = {'Content-Type': 'image/png'} + return make_response(generated, 200, headers) + + @web.route('/tos', methods=['GET']) @no_cache def tos(): @@ -413,7 +426,8 @@ def request_authorization_code(): 'url': oauth_app.application_uri, 'organization': { 'name': oauth_app.organization.username, - 'gravatar': compute_hash(oauth_app.organization.email) + 'avatar': avatar.compute_hash(oauth_app.organization.email, + name=oauth_app.organization.name) } } diff --git a/static/css/quay.css b/static/css/quay.css index 25934010b..3e6a7e3b0 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4819,7 +4819,7 @@ i.slack-icon { margin-bottom: 10px; } -.member-listing .gravatar { +.member-listing .avatar { vertical-align: middle; margin-right: 10px; } diff --git a/static/directives/application-info.html b/static/directives/application-info.html index bbaf56454..241fec279 100644 --- a/static/directives/application-info.html +++ b/static/directives/application-info.html @@ -1,6 +1,6 @@
- +

{{ application.name }}

{{ application.organization.name }} diff --git a/static/directives/avatar.html b/static/directives/avatar.html new file mode 100644 index 000000000..46c56afe5 --- /dev/null +++ b/static/directives/avatar.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index ea65db875..c0fbd3196 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -7,15 +7,15 @@ - + {{entity.name}} {{entity.name}} - - + + diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index fe154341f..bd1022d3d 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -35,7 +35,7 @@

-
- -

Jacob graduated from The University of Michigan with a Bachelors in Computer Engineering. From there he allowed his love of flight and mountains to lure him to Seattle where he took a job with Boeing Commercial Airplanes working on the world's most accurate flight simulator. When he realized how much he also loved web development, he moved to Amazon to work on the e-commerce back-end. Finally, desiring to move to New York City, he moved to Google, where he worked on several products related to Google APIs.

@@ -71,9 +68,6 @@ Co-Founder
-
- -

Joseph graduated from University of Pennsylvania with a Bachelors and Masters in Computer Science. After a record setting (probably) five internships with Google, he took a full time position there to continue his work on exciting products such as Google Spreadsheets, the Google Closure Compiler, and Google APIs.

diff --git a/static/partials/landing-login.html b/static/partials/landing-login.html index 0a3046d2a..82c51fbb9 100644 --- a/static/partials/landing-login.html +++ b/static/partials/landing-login.html @@ -47,7 +47,7 @@
- +
Welcome {{ user.username }}!
Browse all repositories Create a new repository diff --git a/static/partials/landing-normal.html b/static/partials/landing-normal.html index 6b9b6e42e..8533752de 100644 --- a/static/partials/landing-normal.html +++ b/static/partials/landing-normal.html @@ -57,7 +57,7 @@
- +
Welcome {{ user.username }}!
Browse all repositories Create a new repository diff --git a/static/partials/manage-application.html b/static/partials/manage-application.html index aaae745b8..9618f9b5d 100644 --- a/static/partials/manage-application.html +++ b/static/partials/manage-application.html @@ -6,11 +6,11 @@
-
- -

{{ application.name || '(Untitled)' }}

+
+

+ {{ application.name || '(Untitled)' }}

- + {{ organization.name }}

@@ -59,9 +59,9 @@
- - -
An e-mail address representing the Gravatar for the application. See above for the icon.
+ + +
An e-mail address representing the Avatar for the application. See above for the icon.
diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index af9f07a46..b23e89912 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -43,7 +43,7 @@
- +
diff --git a/static/partials/team-view.html b/static/partials/team-view.html index 90ef77696..2f56dd910 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -36,7 +36,7 @@ - + - + - + {{ member.email }} diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index d7d9b583e..a7916ef7e 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -9,7 +9,7 @@
- + {{ user.username }} @@ -72,7 +72,7 @@ - + {{ authInfo.application.name }} @@ -291,7 +291,7 @@
- + {{ user.username }}
This will continue to be the namespace for your repositories
diff --git a/templates/oauthorize.html b/templates/oauthorize.html index a83055fd4..19bde633b 100644 --- a/templates/oauthorize.html +++ b/templates/oauthorize.html @@ -13,10 +13,11 @@
- +

{{ application.name }}

- + {{ application.organization.name }}

diff --git a/test/test_api_usage.py b/test/test_api_usage.py index ab3a4a239..edda10c2a 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -2114,14 +2114,14 @@ class TestOrganizationApplicationResource(ApiTestCase): edit_json = self.putJsonResponse(OrganizationApplicationResource, params=dict(orgname=ORGANIZATION, client_id=FAKE_APPLICATION_CLIENT_ID), data=dict(name="Some App", description="foo", application_uri="bar", - redirect_uri="baz", gravatar_email="meh")) + redirect_uri="baz", avatar_email="meh")) self.assertEquals(FAKE_APPLICATION_CLIENT_ID, edit_json['client_id']) self.assertEquals("Some App", edit_json['name']) self.assertEquals("foo", edit_json['description']) self.assertEquals("bar", edit_json['application_uri']) self.assertEquals("baz", edit_json['redirect_uri']) - self.assertEquals("meh", edit_json['gravatar_email']) + self.assertEquals("meh", edit_json['avatar_email']) # Retrieve the application again. json = self.getJsonResponse(OrganizationApplicationResource, diff --git a/util/gravatar.py b/util/gravatar.py deleted file mode 100644 index 8cee241cc..000000000 --- a/util/gravatar.py +++ /dev/null @@ -1,5 +0,0 @@ -import hashlib - - -def compute_hash(email_address): - return hashlib.md5(email_address.strip().lower()).hexdigest() diff --git a/util/jinjautil.py b/util/jinjautil.py index 2d10414f8..36095a1d8 100644 --- a/util/jinjautil.py +++ b/util/jinjautil.py @@ -1,6 +1,5 @@ -from app import get_app_url +from app import get_app_url, avatar from data import model -from util.gravatar import compute_hash from util.names import parse_robot_username from jinja2 import Template, Environment, FileSystemLoader, contextfilter @@ -25,10 +24,10 @@ def user_reference(username): alt = 'Organization' if user.organization else 'User' return """ - %s %s - """ % (compute_hash(user.email), alt, username) + """ % (avatar.get_url(user.email, 16), alt, username) def repository_tag_reference(repository_path_and_tag): @@ -55,10 +54,10 @@ def repository_reference(pair): return """ - + %s/%s - """ % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository) + """ % (avatar.get_url(owner.email, 16), get_app_url(), namespace, repository, namespace, repository) def admin_reference(username): diff --git a/util/useremails.py b/util/useremails.py index 8dbbc5216..58cd402a9 100644 --- a/util/useremails.py +++ b/util/useremails.py @@ -5,7 +5,6 @@ from flask.ext.mail import Message from app import mail, app, get_app_url from data import model -from util.gravatar import compute_hash from util.jinjautil import get_template_env logger = logging.getLogger(__name__)