From 2597bcef3fc330457a6818362ac4b99e7dbec8cc Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 11 Aug 2014 15:47:44 -0400 Subject: [PATCH 001/160] Add support for login with Google. Note that this CL is not complete --- config.py | 12 +- endpoints/callbacks.py | 128 ++++++++++++++---- initdb.py | 2 + static/directives/external-login-button.html | 17 +++ static/directives/signin-form.html | 6 +- static/directives/signup-form.html | 6 +- static/js/app.js | 100 +++++++++----- static/js/controllers.js | 5 +- static/partials/user-admin.html | 30 +++- .../{githuberror.html => ologinerror.html} | 8 +- 10 files changed, 231 insertions(+), 83 deletions(-) create mode 100644 static/directives/external-login-button.html rename templates/{githuberror.html => ologinerror.html} (66%) diff --git a/config.py b/config.py index a903fa29a..708449714 100644 --- a/config.py +++ b/config.py @@ -19,7 +19,7 @@ def build_requests_session(): CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'GITHUB_CLIENT_ID', 'GITHUB_LOGIN_CLIENT_ID', 'MIXPANEL_KEY', 'STRIPE_PUBLISHABLE_KEY', 'ENTERPRISE_LOGO_URL', 'SENTRY_PUBLIC_DSN', 'AUTHENTICATION_TYPE', - 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT'] + 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', 'GOOGLE_LOGIN_CLIENT_ID'] def getFrontendVisibleConfig(config_dict): @@ -115,6 +115,13 @@ class DefaultConfig(object): GITHUB_LOGIN_CLIENT_ID = '' GITHUB_LOGIN_CLIENT_SECRET = '' + # Google Config. + GOOGLE_TOKEN_URL = 'https://accounts.google.com/o/oauth2/token' + GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v1/userinfo' + + GOOGLE_LOGIN_CLIENT_ID = '' + GOOGLE_LOGIN_CLIENT_SECRET = '' + # Requests based HTTP client with a large request pool HTTPCLIENT = build_requests_session() @@ -144,6 +151,9 @@ class DefaultConfig(object): # Feature Flag: Whether GitHub login is supported. FEATURE_GITHUB_LOGIN = False + # Feature Flag: Whether Google login is supported. + FEATURE_GOOGLE_LOGIN = False + # Feature flag, whether to enable olark chat FEATURE_OLARK_CHAT = False diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index 015f3c3a7..ba53cbc5c 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -7,6 +7,7 @@ from endpoints.common import render_page_template, common_login, route_show_if from app import app, analytics from data import model from util.names import parse_repository_name +from util.validation import generate_valid_usernames from util.http import abort from auth.permissions import AdministerRepositoryPermission from auth.auth import require_session_login @@ -21,19 +22,31 @@ client = app.config['HTTPCLIENT'] callback = Blueprint('callback', __name__) -def exchange_github_code_for_token(code, for_login=True): +def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False): code = request.args.get('code') + id_config = service_name + '_LOGIN_CLIENT_ID' if for_login else service_name + '_CLIENT_ID' + secret_config = service_name + '_LOGIN_CLIENT_SECRET' if for_login else service_name + '_CLIENT_SECRET' + payload = { - 'client_id': app.config['GITHUB_LOGIN_CLIENT_ID' if for_login else 'GITHUB_CLIENT_ID'], - 'client_secret': app.config['GITHUB_LOGIN_CLIENT_SECRET' if for_login else 'GITHUB_CLIENT_SECRET'], + 'client_id': app.config[id_config], + 'client_secret': app.config[secret_config], 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': '%s://%s/oauth2/%s/callback' % (app.config['PREFERRED_URL_SCHEME'], + app.config['SERVER_HOSTNAME'], + service_name.lower()) } + headers = { 'Accept': 'application/json' } - get_access_token = client.post(app.config['GITHUB_TOKEN_URL'], - params=payload, headers=headers) + if form_encode: + get_access_token = client.post(app.config[service_name + '_TOKEN_URL'], + data=payload, headers=headers) + else: + get_access_token = client.post(app.config[service_name + '_TOKEN_URL'], + params=payload, headers=headers) json_data = get_access_token.json() if not json_data: @@ -52,17 +65,76 @@ def get_github_user(token): return get_user.json() +def get_google_user(token): + token_param = { + 'access_token': token, + 'alt': 'json', + } + + get_user = client.get(app.config['GOOGLE_USER_URL'], params=token_param) + return get_user.json() + +def conduct_oauth_login(service_name, user_id, username, email): + to_login = model.verify_federated_login(service_name.lower(), user_id) + if not to_login: + # try to create the user + try: + valid = next(generate_valid_usernames(username)) + to_login = model.create_federated_user(valid, email, service_name.lower(), + user_id, set_password_notification=True) + + # Success, tell analytics + analytics.track(to_login.username, 'register', {'service': service_name.lower()}) + + state = request.args.get('state', None) + if state: + logger.debug('Aliasing with state: %s' % state) + analytics.alias(to_login.username, state) + + except model.DataModelException, ex: + return render_page_template('ologinerror.html', service_name=service_name, + error_message=ex.message) + + if common_login(to_login): + return redirect(url_for('web.index')) + + return render_page_template('ologinerror.html', service_name=service_name, + error_message='Unknown error') + + +@callback.route('/google/callback', methods=['GET']) +@route_show_if(features.GOOGLE_LOGIN) +def google_oauth_callback(): + error = request.args.get('error', None) + if error: + return render_page_template('ologinerror.html', service_name='Google', error_message=error) + + token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE', form_encode=True) + user_data = get_google_user(token) + if not user_data or not user_data.get('id', None) or not user_data.get('email', None): + return render_page_template('ologinerror.html', service_name = 'Google', + error_message='Could not load user data') + + username = user_data['email'] + at = username.find('@') + if at > 0: + username = username[0:at] + + return conduct_oauth_login('Google', user_data['id'], username, user_data['email']) + + @callback.route('/github/callback', methods=['GET']) @route_show_if(features.GITHUB_LOGIN) def github_oauth_callback(): error = request.args.get('error', None) if error: - return render_page_template('githuberror.html', error_message=error) + return render_page_template('ologinerror.html', service_name = 'GitHub', error_message=error) - token = exchange_github_code_for_token(request.args.get('code')) + token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB') user_data = get_github_user(token) if not user_data: - return render_page_template('githuberror.html', error_message='Could not load user data') + return render_page_template('ologinerror.html', service_name = 'GitHub', + error_message='Could not load user data') username = user_data['login'] github_id = user_data['id'] @@ -84,38 +156,34 @@ def github_oauth_callback(): if user_email['primary']: break - to_login = model.verify_federated_login('github', github_id) - if not to_login: - # try to create the user - try: - to_login = model.create_federated_user(username, found_email, 'github', - github_id, set_password_notification=True) + return conduct_oauth_login('github', github_id, username, found_email) - # Success, tell analytics - analytics.track(to_login.username, 'register', {'service': 'github'}) - state = request.args.get('state', None) - if state: - logger.debug('Aliasing with state: %s' % state) - analytics.alias(to_login.username, state) +@callback.route('/google/callback/attach', methods=['GET']) +@route_show_if(features.GOOGLE_LOGIN) +@require_session_login +def google_oauth_attach(): + token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE') + user_data = get_google_user(token) + if not user_data or not user_data.get('id', None): + return render_page_template('ologinerror.html', service_name = 'Google', + error_message='Could not load user data') - except model.DataModelException, ex: - return render_page_template('githuberror.html', error_message=ex.message) - - if common_login(to_login): - return redirect(url_for('web.index')) - - return render_page_template('githuberror.html') + google_id = user_data['id'] + user_obj = current_user.db_user() + model.attach_federated_login(user_obj, 'google', google_id) + return redirect(url_for('web.user')) @callback.route('/github/callback/attach', methods=['GET']) @route_show_if(features.GITHUB_LOGIN) @require_session_login def github_oauth_attach(): - token = exchange_github_code_for_token(request.args.get('code')) + token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB') user_data = get_github_user(token) if not user_data: - return render_page_template('githuberror.html', error_message='Could not load user data') + return render_page_template('ologinerror.html', service_name = 'GitHub', + error_message='Could not load user data') github_id = user_data['id'] user_obj = current_user.db_user() @@ -130,7 +198,7 @@ def github_oauth_attach(): def attach_github_build_trigger(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): - token = exchange_github_code_for_token(request.args.get('code'), for_login=False) + token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB', for_login=False) repo = model.get_repository(namespace, repository) if not repo: msg = 'Invalid repository: %s/%s' % (namespace, repository) diff --git a/initdb.py b/initdb.py index 7e48ae3af..5d10a2039 100644 --- a/initdb.py +++ b/initdb.py @@ -179,6 +179,8 @@ def initialize_database(): TeamRole.create(name='member') Visibility.create(name='public') Visibility.create(name='private') + + LoginService.create(name='google') LoginService.create(name='github') LoginService.create(name='quayrobot') LoginService.create(name='ldap') diff --git a/static/directives/external-login-button.html b/static/directives/external-login-button.html new file mode 100644 index 000000000..cc1d39bbd --- /dev/null +++ b/static/directives/external-login-button.html @@ -0,0 +1,17 @@ + + + + + Sign In with GitHub + Attach to GitHub Account + + + + + + + Sign In with Google + Attach to Google Account + + + diff --git a/static/directives/signin-form.html b/static/directives/signin-form.html index f56b8f8db..ec57619a8 100644 --- a/static/directives/signin-form.html +++ b/static/directives/signin-form.html @@ -11,10 +11,8 @@ OR - - Sign In with GitHub - +
+
Invalid username or password.
diff --git a/static/directives/signup-form.html b/static/directives/signup-form.html index fb0ccc6fa..249bff31c 100644 --- a/static/directives/signup-form.html +++ b/static/directives/signup-form.html @@ -18,10 +18,8 @@ OR - - Sign In with GitHub - +
+

No credit card required.

diff --git a/static/js/app.js b/static/js/app.js index 8daec75d4..978012bb6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1278,10 +1278,41 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading var keyService = {} keyService['stripePublishableKey'] = Config['STRIPE_PUBLISHABLE_KEY']; + keyService['githubClientId'] = Config['GITHUB_CLIENT_ID']; keyService['githubLoginClientId'] = Config['GITHUB_LOGIN_CLIENT_ID']; keyService['githubRedirectUri'] = Config.getUrl('/oauth2/github/callback'); + keyService['googleLoginClientId'] = Config['GOOGLE_LOGIN_CLIENT_ID']; + keyService['googleRedirectUri'] = Config.getUrl('/oauth2/google/callback'); + + keyService['googleLoginUrl'] = 'https://accounts.google.com/o/oauth2/auth?response_type=code&'; + keyService['githubLoginUrl'] = 'https://github.com/login/oauth/authorize?'; + + keyService['googleLoginScope'] = 'openid email'; + keyService['githubLoginScope'] = 'user:email'; + + keyService.getExternalLoginUrl = function(service, action) { + var state_clause = ''; + if (Config.MIXPANEL_KEY && window.mixpanel) { + if (mixpanel.get_distinct_id !== undefined) { + state_clause = "&state=" + encodeURIComponent(mixpanel.get_distinct_id()); + } + } + + var client_id = keyService[service + 'LoginClientId']; + var scope = keyService[service + 'LoginScope']; + var redirect_uri = keyService[service + 'RedirectUri']; + if (action == 'attach') { + redirect_uri += '/attach'; + } + + var url = keyService[service + 'LoginUrl'] + 'client_id=' + client_id + '&scope=' + scope + + '&redirect_uri=' + redirect_uri + state_clause; + + return url; + }; + return keyService; }]); @@ -2150,6 +2181,41 @@ quayApp.directive('userSetup', function () { }); +quayApp.directive('externalLoginButton', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/external-login-button.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'signInStarted': '&signInStarted', + 'redirectUrl': '=redirectUrl', + 'provider': '@provider', + 'action': '@action' + }, + controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { + $scope.startSignin = function(service) { + $scope.signInStarted({'service': service}); + + var url = KeyService.getExternalLoginUrl(service, $scope.action || 'login'); + + // Save the redirect URL in a cookie so that we can redirect back after the service returns to us. + var redirectURL = $scope.redirectUrl || window.location.toString(); + CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); + + // Needed to ensure that UI work done by the started callback is finished before the location + // changes. + $timeout(function() { + document.location = url; + }, 250); + }; + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('signinForm', function () { var directiveDefinitionObject = { priority: 0, @@ -2163,29 +2229,6 @@ quayApp.directive('signinForm', function () { 'signedIn': '&signedIn' }, controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { - $scope.showGithub = function() { - if (!Features.GITHUB_LOGIN) { return; } - - $scope.markStarted(); - - var mixpanelDistinctIdClause = ''; - if (Config.MIXPANEL_KEY && mixpanel.get_distinct_id !== undefined) { - $scope.mixpanelDistinctIdClause = "&state=" + encodeURIComponent(mixpanel.get_distinct_id()); - } - - // Save the redirect URL in a cookie so that we can redirect back after GitHub returns to us. - var redirectURL = $scope.redirectUrl || window.location.toString(); - CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); - - // Needed to ensure that UI work done by the started callback is finished before the location - // changes. - $timeout(function() { - var url = 'https://github.com/login/oauth/authorize?client_id=' + encodeURIComponent(KeyService.githubLoginClientId) + - '&scope=user:email' + mixpanelDistinctIdClause; - document.location = url; - }, 250); - }; - $scope.markStarted = function() { if ($scope.signInStarted != null) { $scope.signInStarted(); @@ -2235,18 +2278,9 @@ quayApp.directive('signupForm', function () { scope: { }, - controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) { + controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) { $('.form-signup').popover(); - if (Config.MIXPANEL_KEY) { - angulartics.waitForVendorApi(mixpanel, 500, function(loadedMixpanel) { - var mixpanelId = loadedMixpanel.get_distinct_id(); - $scope.github_state_clause = '&state=' + mixpanelId; - }); - } - - $scope.githubClientId = KeyService.githubLoginClientId; - $scope.awaitingConfirmation = false; $scope.registering = false; diff --git a/static/js/controllers.js b/static/js/controllers.js index d77ffb298..7690f79f6 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -1681,6 +1681,10 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.githubLogin = resp.login; }); } + + if ($scope.cuser.logins[i].service == 'google') { + $scope.hasGoogleLogin = true; + } } } }); @@ -1697,7 +1701,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.convertStep = 0; $scope.org = {}; $scope.githubRedirectUri = KeyService.githubRedirectUri; - $scope.githubClientId = KeyService.githubLoginClientId; $scope.authorizedApps = null; $scope.logsShown = 0; diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 783c5f87a..fc0acb89e 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -33,7 +33,7 @@
  • Account E-mail
  • Robot Accounts
  • Change Password
  • -
  • GitHub Login
  • +
  • External Logins
  • Authorized Applications
  • Usage Logs @@ -162,12 +162,14 @@ - -
    + +
    -
    + + +
    GitHub Login:
    @@ -175,12 +177,28 @@ {{githubLogin}}
    -
    + + +
    +
    +
    Google Login:
    +
    +
    + Account tied to your Google account. +
    +
    + +
    +
    +
    +
    +
    diff --git a/templates/githuberror.html b/templates/ologinerror.html similarity index 66% rename from templates/githuberror.html rename to templates/ologinerror.html index acb803f57..cd921eec2 100644 --- a/templates/githuberror.html +++ b/templates/ologinerror.html @@ -1,14 +1,14 @@ {% extends "base.html" %} {% block title %} - Error Logging in with GitHub · Quay.io + Error Logging in with {{ service_name }} · Quay.io {% endblock %} {% block body_content %}
    -

    There was an error logging in with GitHub.

    +

    There was an error logging in with {{ service_name }}.

    {% if error_message %}
    {{ error_message }}
    @@ -16,11 +16,11 @@
    Please register using the registration form to continue. - You will be able to connect your github account to your Quay.io account + You will be able to connect your account to your Quay.io account in the user settings.
    -{% endblock %} \ No newline at end of file +{% endblock %} From 389c88a7c4b2b08b879b772a0deca139e004ef3e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 11 Aug 2014 18:25:01 -0400 Subject: [PATCH 002/160] Update federated login to store metadata and have the UI pull the information from the metadata --- data/database.py | 1 + data/model/legacy.py | 13 ++++-- endpoints/api/user.py | 8 ++++ endpoints/callbacks.py | 81 ++++++++++++++++++++++++++------- static/js/controllers.js | 9 ++-- static/partials/user-admin.html | 17 +++++-- 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/data/database.py b/data/database.py index 76a0af9df..6099cf5d9 100644 --- a/data/database.py +++ b/data/database.py @@ -116,6 +116,7 @@ class FederatedLogin(BaseModel): user = ForeignKeyField(User, index=True) service = ForeignKeyField(LoginService, index=True) service_ident = CharField() + metadata_json = TextField(default='{}') class Meta: database = db diff --git a/data/model/legacy.py b/data/model/legacy.py index b5afdfeb8..bfa310046 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -346,7 +346,8 @@ def set_team_org_permission(team, team_role_name, set_by_username): return team -def create_federated_user(username, email, service_name, service_id, set_password_notification): +def create_federated_user(username, email, service_name, service_id, + set_password_notification, metadata={}): if not is_create_user_allowed(): raise TooManyUsersException() @@ -356,7 +357,8 @@ def create_federated_user(username, email, service_name, service_id, set_passwor service = LoginService.get(LoginService.name == service_name) FederatedLogin.create(user=new_user, service=service, - service_ident=service_id) + service_ident=service_id, + metadata_json=json.dumps(metadata)) if set_password_notification: create_notification('password_required', new_user) @@ -364,9 +366,10 @@ def create_federated_user(username, email, service_name, service_id, set_passwor return new_user -def attach_federated_login(user, service_name, service_id): +def attach_federated_login(user, service_name, service_id, metadata={}): service = LoginService.get(LoginService.name == service_name) - FederatedLogin.create(user=user, service=service, service_ident=service_id) + FederatedLogin.create(user=user, service=service, service_ident=service_id, + metadata_json=json.dumps(metadata)) return user @@ -385,7 +388,7 @@ def verify_federated_login(service_name, service_id): def list_federated_logins(user): selected = FederatedLogin.select(FederatedLogin.service_ident, - LoginService.name) + LoginService.name, FederatedLogin.metadata_json) joined = selected.join(LoginService) return joined.where(LoginService.name != 'quayrobot', FederatedLogin.user == user) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 3d79a806d..23bc3137a 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -39,9 +39,16 @@ def user_view(user): organizations = model.get_user_organizations(user.username) def login_view(login): + print login.metadata_json + try: + metadata = json.loads(login.metadata_json) + except: + metadata = None + return { 'service': login.service.name, 'service_identifier': login.service_ident, + 'metadata': metadata } logins = model.list_federated_logins(user) @@ -88,6 +95,7 @@ class User(ApiResource): """ Operations related to users. """ schemas = { 'NewUser': { + 'id': 'NewUser', 'type': 'object', 'description': 'Fields which must be specified for a new user.', diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index ba53cbc5c..49fa1e8a6 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -11,6 +11,7 @@ from util.validation import generate_valid_usernames from util.http import abort from auth.permissions import AdministerRepositoryPermission from auth.auth import require_session_login +from peewee import IntegrityError import features @@ -22,7 +23,8 @@ client = app.config['HTTPCLIENT'] callback = Blueprint('callback', __name__) -def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False): +def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False, + redirect_suffix=''): code = request.args.get('code') id_config = service_name + '_LOGIN_CLIENT_ID' if for_login else service_name + '_CLIENT_ID' secret_config = service_name + '_LOGIN_CLIENT_SECRET' if for_login else service_name + '_CLIENT_SECRET' @@ -32,9 +34,10 @@ def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_en 'client_secret': app.config[secret_config], 'code': code, 'grant_type': 'authorization_code', - 'redirect_uri': '%s://%s/oauth2/%s/callback' % (app.config['PREFERRED_URL_SCHEME'], - app.config['SERVER_HOSTNAME'], - service_name.lower()) + 'redirect_uri': '%s://%s/oauth2/%s/callback%s' % (app.config['PREFERRED_URL_SCHEME'], + app.config['SERVER_HOSTNAME'], + service_name.lower(), + redirect_suffix) } headers = { @@ -74,14 +77,15 @@ def get_google_user(token): get_user = client.get(app.config['GOOGLE_USER_URL'], params=token_param) return get_user.json() -def conduct_oauth_login(service_name, user_id, username, email): +def conduct_oauth_login(service_name, user_id, username, email, metadata={}): to_login = model.verify_federated_login(service_name.lower(), user_id) if not to_login: # try to create the user try: valid = next(generate_valid_usernames(username)) to_login = model.create_federated_user(valid, email, service_name.lower(), - user_id, set_password_notification=True) + user_id, set_password_notification=True, + metadata=metadata) # Success, tell analytics analytics.track(to_login.username, 'register', {'service': service_name.lower()}) @@ -102,6 +106,15 @@ def conduct_oauth_login(service_name, user_id, username, email): error_message='Unknown error') +def get_google_username(user_data): + username = user_data['email'] + at = username.find('@') + if at > 0: + username = username[0:at] + + return username + + @callback.route('/google/callback', methods=['GET']) @route_show_if(features.GOOGLE_LOGIN) def google_oauth_callback(): @@ -115,12 +128,13 @@ def google_oauth_callback(): return render_page_template('ologinerror.html', service_name = 'Google', error_message='Could not load user data') - username = user_data['email'] - at = username.find('@') - if at > 0: - username = username[0:at] + username = get_google_username(user_data) + metadata = { + 'service_username': username + } - return conduct_oauth_login('Google', user_data['id'], username, user_data['email']) + return conduct_oauth_login('Google', user_data['id'], username, user_data['email'], + metadata=metadata) @callback.route('/github/callback', methods=['GET']) @@ -156,14 +170,20 @@ def github_oauth_callback(): if user_email['primary']: break - return conduct_oauth_login('github', github_id, username, found_email) + metadata = { + 'service_username': username + } + + return conduct_oauth_login('github', github_id, username, found_email, metadata=metadata) @callback.route('/google/callback/attach', methods=['GET']) @route_show_if(features.GOOGLE_LOGIN) @require_session_login def google_oauth_attach(): - token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE') + token = exchange_code_for_token(request.args.get('code'), service_name='GOOGLE', + redirect_suffix='/attach', form_encode=True) + user_data = get_google_user(token) if not user_data or not user_data.get('id', None): return render_page_template('ologinerror.html', service_name = 'Google', @@ -171,7 +191,21 @@ def google_oauth_attach(): google_id = user_data['id'] user_obj = current_user.db_user() - model.attach_federated_login(user_obj, 'google', google_id) + + username = get_google_username(user_data) + metadata = { + 'service_username': username + } + + try: + model.attach_federated_login(user_obj, 'google', google_id, metadata=metadata) + except IntegrityError: + err = 'Google account %s is already attached to a %s account' % ( + username, app.config['REGISTRY_TITLE_SHORT']) + + return render_page_template('ologinerror.html', service_name = 'Google', + error_message=err) + return redirect(url_for('web.user')) @@ -187,7 +221,21 @@ def github_oauth_attach(): github_id = user_data['id'] user_obj = current_user.db_user() - model.attach_federated_login(user_obj, 'github', github_id) + + username = user_data['login'] + metadata = { + 'service_username': username + } + + try: + model.attach_federated_login(user_obj, 'github', github_id, metadata=metadata) + except IntegrityError: + err = 'Github account %s is already attached to a %s account' % ( + username, app.config['REGISTRY_TITLE_SHORT']) + + return render_page_template('ologinerror.html', service_name = 'Github', + error_message=err) + return redirect(url_for('web.user')) @@ -198,7 +246,8 @@ def github_oauth_attach(): def attach_github_build_trigger(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): - token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB', for_login=False) + token = exchange_code_for_token(request.args.get('code'), service_name='GITHUB', + for_login=False) repo = model.get_repository(namespace, repository) if not repo: msg = 'Invalid repository: %s/%s' % (namespace, repository) diff --git a/static/js/controllers.js b/static/js/controllers.js index 7690f79f6..5278e4efe 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -1673,17 +1673,16 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use UserService.updateUserIn($scope, function(user) { $scope.cuser = jQuery.extend({}, user); - if (Features.GITHUB_LOGIN && $scope.cuser.logins) { + if ($scope.cuser.logins) { for (var i = 0; i < $scope.cuser.logins.length; i++) { if ($scope.cuser.logins[i].service == 'github') { - var githubId = $scope.cuser.logins[i].service_identifier; - $http.get('https://api.github.com/user/' + githubId).success(function(resp) { - $scope.githubLogin = resp.login; - }); + $scope.hasGithubLogin = true; + $scope.githubLogin = $scope.cuser.logins[i].metadata['service_username']; } if ($scope.cuser.logins[i].service == 'google') { $scope.hasGoogleLogin = true; + $scope.googleLogin = $scope.cuser.logins[i].metadata['service_username']; } } } diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index fc0acb89e..ca76342dd 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -173,11 +173,15 @@
    GitHub Login:
    -
    + -
    +
    + + Account attached to Github Account +
    +
    @@ -189,8 +193,13 @@
    Google Login:
    -
    - Account tied to your Google account. +
    + + {{ googleLogin }} +
    +
    + + Account attached to Google Account
    From 11176215e10a069045892420a2c41c6492fe11af Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 11 Aug 2014 18:35:26 -0400 Subject: [PATCH 003/160] Commit new DB changes and make sure the metadata is always present in some form --- endpoints/api/user.py | 2 +- test/data/test.db | Bin 614400 -> 614400 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 23bc3137a..d0d089dcd 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -43,7 +43,7 @@ def user_view(user): try: metadata = json.loads(login.metadata_json) except: - metadata = None + metadata = {} return { 'service': login.service.name, diff --git a/test/data/test.db b/test/data/test.db index 4d04283311e6feb845dff3fde9a4d370858119be..b64829db209fca64a5f122a3c96d74f8f7d66845 100644 GIT binary patch delta 6425 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 Zz%1^cZ7@42q7wXOJyDF6-1R-{{{h72SJ(gm delta 6334 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 MA{P?No{u#D4?#ajMgRZ+ From 56d7a3524de95c54bfe01c4306e195bbf33958fd Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 Aug 2014 17:47:43 -0400 Subject: [PATCH 004/160] Work in progress: Require invite acceptance to join an org --- data/database.py | 10 ++++- data/model/legacy.py | 44 +++++++++++++++++++++- endpoints/api/team.py | 67 ++++++++++++++++++++++++++++----- initdb.py | 4 +- static/js/app.js | 17 +++++++++ static/js/controllers.js | 3 +- static/partials/team-view.html | 1 + test/data/test.db | Bin 614400 -> 626688 bytes util/useremails.py | 24 ++++++++++++ 9 files changed, 157 insertions(+), 13 deletions(-) diff --git a/data/database.py b/data/database.py index 76a0af9df..3e98b83be 100644 --- a/data/database.py +++ b/data/database.py @@ -108,6 +108,14 @@ class TeamMember(BaseModel): ) +class TeamMemberInvite(BaseModel): + # Note: Either user OR email will be filled in, but not both. + user = ForeignKeyField(User, index=True, null=True) + email = CharField(null=True) + team = ForeignKeyField(Team, index=True) + invite_token = CharField(default=uuid_generator) + + class LoginService(BaseModel): name = CharField(unique=True, index=True) @@ -405,4 +413,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, Notification, ImageStorageLocation, ImageStoragePlacement, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, - RepositoryAuthorizedEmail] + RepositoryAuthorizedEmail, TeamMemberInvite] diff --git a/data/model/legacy.py b/data/model/legacy.py index b5afdfeb8..64bf8d4f8 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -43,6 +43,9 @@ class InvalidRobotException(DataModelException): class InvalidTeamException(DataModelException): pass +class InvalidTeamMemberException(DataModelException): + pass + class InvalidPasswordException(DataModelException): pass @@ -291,11 +294,46 @@ def remove_team(org_name, team_name, removed_by_username): team.delete_instance(recursive=True, delete_nullable=True) +def add_or_invite_to_team(team, user=None, email=None, adder=None): + # If the user is a member of the organization, then we simply add the + # user directly to the team. Otherwise, an invite is created for the user/email. + # We return None if the user was directly added and the invite object if the user was invited. + if email: + try: + user = User.get(email=email) + except User.DoesNotExist: + pass + + requires_invite = True + if user: + orgname = team.organization.username + + # If the user is part of the organization (or a robot), then no invite is required. + if user.robot: + requires_invite = False + if not user.username.startswith(orgname + '+'): + raise InvalidTeamMemberException('Cannot add the specified robot to this team, ' + + 'as it is not a member of the organization') + else: + Org = User.alias() + found = User.select(User.username) + found = found.where(User.username == user.username).join(TeamMember).join(Team) + found = found.join(Org, on=(Org.username == orgname)).limit(1) + requires_invite = not any(found) + + # If we have a valid user and no invite is required, simply add the user to the team. + if user and not requires_invite: + add_user_to_team(user, team) + return None + + return TeamMemberInvite.create(user=user, email=email if not user else None, team=team) + + def add_user_to_team(user, team): try: return TeamMember.create(user=user, team=team) except Exception: - raise DataModelException('Unable to add user \'%s\' to team: \'%s\'' % + raise DataModelException('User \'%s\' is already a member of team \'%s\'' % (user.username, team.name)) @@ -570,6 +608,10 @@ def get_organization_team_members(teamid): query = joined.where(Team.id == teamid) return query +def get_organization_team_member_invites(teamid): + joined = TeamMemberInvite.select().join(Team).join(User) + query = joined.where(Team.id == teamid) + return query def get_organization_member_set(orgname): Org = User.alias() diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 0631cc028..47eeed1f4 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -1,12 +1,32 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, - log_action, Unauthorized, NotFound, internal_only, require_scope) + log_action, Unauthorized, NotFound, internal_only, require_scope, + query_param, truthy_bool, parse_args) from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.auth_context import get_authenticated_user from auth import scopes from data import model +from util.useremails import send_org_invite_email +def add_or_invite_to_team(team, user=None, email=None, adder=None): + invite = model.add_or_invite_to_team(team, user, email, adder) + if not invite: + # User was added to the team directly. + return + + orgname = team.organization.username + if user: + model.create_notification('org_team_invite', user, metadata = { + 'code': invite.invite_token, + 'adder': adder, + 'org': orgname, + 'team': team.name + }) + + send_org_invite_email(user.username if user else email, user.email if user else email, + orgname, team.name, adder, invite.invite_token) + return invite def team_view(orgname, team): view_permission = ViewTeamPermission(orgname, team.name) @@ -19,14 +39,26 @@ def team_view(orgname, team): 'role': role } -def member_view(member): +def member_view(member, invited=False): return { 'name': member.username, 'kind': 'user', 'is_robot': member.robot, + 'invited': invited, } +def invite_view(invite): + if invite.user: + return member_view(invite.user, invited=True) + else: + return { + 'email': invite.email, + 'kind': 'invite', + 'invited': True + } + + @resource('/v1/organization//team/') @internal_only class OrganizationTeam(ApiResource): @@ -114,8 +146,10 @@ class OrganizationTeam(ApiResource): @internal_only class TeamMemberList(ApiResource): """ Resource for managing the list of members for a team. """ + @parse_args + @query_param('includePending', 'Whether to include pending members', type=truthy_bool, default=False) @nickname('getOrganizationTeamMembers') - def get(self, orgname, teamname): + def get(self, args, orgname, teamname): """ Retrieve the list of members for the specified team. """ view_permission = ViewTeamPermission(orgname, teamname) edit_permission = AdministerOrganizationPermission(orgname) @@ -128,11 +162,17 @@ class TeamMemberList(ApiResource): raise NotFound() members = model.get_organization_team_members(team.id) - return { + data = { 'members': {m.username : member_view(m) for m in members}, 'can_edit': edit_permission.can() } + if args['includePending'] and edit_permission.can(): + invites = model.get_organization_team_member_invites(team.id) + data['pending'] = [invite_view(i) for i in invites] + + return data + raise Unauthorized() @@ -142,7 +182,7 @@ class TeamMember(ApiResource): @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationTeamMember') def put(self, orgname, teamname, membername): - """ Add a member to an existing team. """ + """ Adds or invites a member to an existing team. """ permission = AdministerOrganizationPermission(orgname) if permission.can(): team = None @@ -159,10 +199,19 @@ class TeamMember(ApiResource): if not user: raise request_error(message='Unknown user') - # Add the user to the team. - model.add_user_to_team(user, team) - log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) - return member_view(user) + # Add or invite the user to the team. + adder = None + if get_authenticated_user(): + adder = get_authenticated_user().username + + invite = add_or_invite_to_team(team, user=user, adder=adder) + if not invite: + log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) + return member_view(user, invited=False) + + # User was invited. + log_action('org_invite_team_member', orgname, {'member': membername, 'team': teamname}) + return member_view(user, invited=True) raise Unauthorized() diff --git a/initdb.py b/initdb.py index 7e48ae3af..21485d5a4 100644 --- a/initdb.py +++ b/initdb.py @@ -212,6 +212,7 @@ def initialize_database(): LogEntryKind.create(name='org_create_team') LogEntryKind.create(name='org_delete_team') + LogEntryKind.create(name='org_invite_team_member') LogEntryKind.create(name='org_add_team_member') LogEntryKind.create(name='org_remove_team_member') LogEntryKind.create(name='org_set_team_description') @@ -261,6 +262,7 @@ def initialize_database(): NotificationKind.create(name='over_private_usage') NotificationKind.create(name='expiring_license') NotificationKind.create(name='maintenance') + NotificationKind.create(name='org_team_invite') NotificationKind.create(name='test_notification') @@ -292,7 +294,7 @@ def populate_database(): new_user_2.verified = True new_user_2.save() - new_user_3 = model.create_user('freshuser', 'password', 'no@thanks.com') + new_user_3 = model.create_user('freshuser', 'password', 'jschorr+test@devtable.com') new_user_3.verified = True new_user_3.save() diff --git a/static/js/app.js b/static/js/app.js index 9c4095db6..3dba1519e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -736,6 +736,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading // We already have /api/v1/ on the URLs, so remove them from the paths. path = path.substr('/api/v1/'.length, path.length); + // Build the path, adjusted with the inline parameters. + var used = {}; var url = ''; for (var i = 0; i < path.length; ++i) { var c = path[i]; @@ -747,6 +749,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading throw new Error('Missing parameter: ' + varName); } + used[varName] = true; url += parameters[varName]; i = end; continue; @@ -755,6 +758,20 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading url += c; } + // Append any query parameters. + var isFirst = true; + for (var paramName in parameters) { + if (!parameters.hasOwnProperty(paramName)) { continue; } + if (used[paramName]) { continue; } + + var value = parameters[paramName]; + if (value) { + url += isFirst ? '?' : '&'; + url += paramName + '=' + encodeURIComponent(value) + isFirst = false; + } + } + return url; }; diff --git a/static/js/controllers.js b/static/js/controllers.js index e04dd0a3c..e05fdc0c3 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2411,7 +2411,8 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) var loadMembers = function() { var params = { 'orgname': orgname, - 'teamname': teamname + 'teamname': teamname, + 'includePending': true }; $scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) { diff --git a/static/partials/team-view.html b/static/partials/team-view.html index b55721455..c63bcea1d 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -15,6 +15,7 @@ + {{ member.invited }} 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 %s/authrepoemail?code=%s """ +INVITE_TO_ORG_TEAM_MESSAGE = """ +Hi {0},
    +{1} has invited you to join the team {2} under organization {3} on {5}. +

    +To join the team, please click the following link:
    +{4}/confirminvite?code={6} +

    +If you were not expecting this invitation, you can ignore this email. +

    +Thanks,
    +- {5} Support +""" + SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}' @@ -123,3 +136,14 @@ def send_payment_failed(customer_email, quay_username): recipients=[customer_email]) msg.html = PAYMENT_FAILED.format(quay_username) mail.send(msg) + + +def send_org_invite_email(member_name, member_email, orgname, team, adder, code): + app_title = app.config['REGISTRY_TITLE_SHORT'] + app_url = get_app_url() + + title = '%s has invited you to join a team in %s' % (adder, app_title) + msg = Message(title, sender='support@quay.io', recipients=[member_email]) + msg.html = INVITE_TO_ORG_TEAM_MESSAGE.format(member_name, adder, team, orgname, + app_url, app_title, code) + mail.send(msg) From 7d7cca39ccdb2cf90d80cc3d46f271418b44c614 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 Aug 2014 20:51:31 -0400 Subject: [PATCH 005/160] New team view interface --- endpoints/api/team.py | 14 ++-- static/css/quay.css | 46 +++++++++++- static/directives/entity-reference.html | 10 ++- static/directives/entity-search.html | 2 +- static/directives/team-view-add.html | 14 ++++ static/js/app.js | 6 +- static/js/controllers.js | 33 +++++++-- static/partials/team-view.html | 93 +++++++++++++++++-------- 8 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 static/directives/team-view-add.html diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 47eeed1f4..3c0751a56 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -8,6 +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 def add_or_invite_to_team(team, user=None, email=None, adder=None): invite = model.add_or_invite_to_team(team, user, email, adder) @@ -44,6 +45,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, 'invited': invited, } @@ -55,6 +57,7 @@ def invite_view(invite): return { 'email': invite.email, 'kind': 'invite', + 'gravatar': compute_hash(invite.email), 'invited': True } @@ -162,14 +165,15 @@ class TeamMemberList(ApiResource): raise NotFound() members = model.get_organization_team_members(team.id) - data = { - 'members': {m.username : member_view(m) for m in members}, - 'can_edit': edit_permission.can() - } + invites = [] if args['includePending'] and edit_permission.can(): invites = model.get_organization_team_member_invites(team.id) - data['pending'] = [invite_view(i) for i in invites] + + data = { + 'members': [member_view(m) for m in members] + [invite_view(i) for i in invites], + 'can_edit': edit_permission.can() + } return data diff --git a/static/css/quay.css b/static/css/quay.css index 20a81200f..4743b6e50 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4593,4 +4593,48 @@ i.quay-icon { .external-notification-view-element:hover .side-controls button { border: 1px solid #eee; -} \ No newline at end of file +} + +.member-listing { + width: 100%; +} + +.member-listing .section-header { + color: #ccc; + margin-top: 20px; + margin-bottom: 10px; +} + +.member-listing .gravatar { + vertical-align: middle; + margin-right: 10px; +} + +.member-listing .entity-reference { + margin-bottom: 10px; + display: inline-block; +} + +.organization-header .popover { + max-width: none !important; +} + +.organization-header .popover.bottom-right .arrow:after { + border-bottom-color: #f7f7f7; + top: 2px; +} + +.organization-header .popover-content { + font-size: 14px; + padding-top: 6px; +} + +.organization-header .popover-content input { + background: white; +} + +.organization-header .popover-content .help-text { + font-size: 13px; + color: #ccc; + margin-top: 10px; +} diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index d01b100ee..ea65db875 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -7,15 +7,19 @@
    - + {{entity.name}} {{entity.name}} - - + + + + + + {{ getPrefix(entity.name) }}+{{ getShortenedName(entity.name) }} diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html index fec00b393..63abb1528 100644 --- a/static/directives/entity-search.html +++ b/static/directives/entity-search.html @@ -5,7 +5,7 @@ ng-click="lazyLoad()"> -
    diff --git a/static/js/app.js b/static/js/app.js index f5651c007..74a7b3454 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -535,6 +535,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading stringBuilderService.buildString = function(value_or_func, metadata) { var fieldIcons = { + 'adder': 'user', 'username': 'user', 'activating_username': 'user', 'delegate_user': 'user', @@ -1132,6 +1133,19 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'page': '/about/', 'dismissable': true }, + 'org_team_invite': { + 'level': 'primary', + 'message': '{adder} is inviting you to join team {team} under organization {org}', + 'actions': [ + { + 'title': 'Join team', + 'kind': 'primary', + 'handler': function(notification) { + } + }, + {'title': 'Decline', 'kind': 'default'} + ] + }, 'password_required': { 'level': 'error', 'message': 'In order to begin pushing and pulling repositories, a password must be set for your account', @@ -1224,6 +1238,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading } }; + notificationService.getActions = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return []; + } + + return kindInfo['actions'] || []; + }; + notificationService.canDismiss = function(notification) { var kindInfo = notificationKinds[notification['kind']]; if (!kindInfo) { @@ -5104,6 +5127,10 @@ quayApp.directive('notificationView', function () { $scope.getClass = function(notification) { return NotificationService.getClass(notification); }; + + $scope.getActions = function(notification) { + return NotificationService.getActions(notification); + }; } }; return directiveDefinitionObject; From 43b6695f9cae84f7efa7dd434b4be4411694b91c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 18 Aug 2014 17:24:00 -0400 Subject: [PATCH 010/160] Get team invite confirmation working and fully tested --- data/database.py | 1 + data/model/legacy.py | 87 +++++++++- endpoints/api/team.py | 66 ++++++-- endpoints/web.py | 13 +- initdb.py | 2 + static/js/app.js | 51 ++++-- static/js/controllers.js | 36 +++++ static/partials/confirm-team-invite.html | 13 ++ static/partials/signin.html | 2 +- test/test_api_security.py | 33 +++- test/test_api_usage.py | 195 ++++++++++++++++++++++- util/useremails.py | 2 +- 12 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 static/partials/confirm-team-invite.html diff --git a/data/database.py b/data/database.py index 3e98b83be..a659e7d50 100644 --- a/data/database.py +++ b/data/database.py @@ -113,6 +113,7 @@ class TeamMemberInvite(BaseModel): user = ForeignKeyField(User, index=True, null=True) email = CharField(null=True) team = ForeignKeyField(Team, index=True) + inviter = ForeignKeyField(User, related_name='inviter') invite_token = CharField(default=uuid_generator) diff --git a/data/model/legacy.py b/data/model/legacy.py index 64bf8d4f8..52248958c 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -71,6 +71,10 @@ class TooManyUsersException(DataModelException): pass +class UserAlreadyInTeam(DataModelException): + pass + + def is_create_user_allowed(): return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] @@ -294,7 +298,7 @@ def remove_team(org_name, team_name, removed_by_username): team.delete_instance(recursive=True, delete_nullable=True) -def add_or_invite_to_team(team, user=None, email=None, adder=None): +def add_or_invite_to_team(inviter, team, user=None, email=None): # If the user is a member of the organization, then we simply add the # user directly to the team. Otherwise, an invite is created for the user/email. # We return None if the user was directly added and the invite object if the user was invited. @@ -326,15 +330,16 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None): add_user_to_team(user, team) return None - return TeamMemberInvite.create(user=user, email=email if not user else None, team=team) + return TeamMemberInvite.create(user=user, email=email if not user else None, team=team, + inviter=inviter) def add_user_to_team(user, team): try: return TeamMember.create(user=user, team=team) except Exception: - raise DataModelException('User \'%s\' is already a member of team \'%s\'' % - (user.username, team.name)) + raise UserAlreadyInTeam('User \'%s\' is already a member of team \'%s\'' % + (user.username, team.name)) def remove_user_from_team(org_name, team_name, username, removed_by_username): @@ -1766,6 +1771,32 @@ def delete_notifications_by_kind(target, kind_name): Notification.delete().where(Notification.target == target, Notification.kind == kind_ref).execute() +def delete_matching_notifications(target, kind_name, **kwargs): + kind_ref = NotificationKind.get(name=kind_name) + + # Load all notifications for the user with the given kind. + notifications = list(Notification.select().where( + Notification.target == target, + Notification.kind == kind_ref)) + + # For each, match the metadata to the specified values. + for notification in notifications: + matches = True + try: + metadata = json.loads(notification.metadata_json) + except: + continue + + for (key, value) in kwargs.iteritems(): + if not key in metadata or metadata[key] != value: + matches = False + break + + if not matches: + continue + + notification.delete_instance() + def get_active_users(): return User.select().where(User.organization == False, User.robot == False) @@ -1821,3 +1852,51 @@ def confirm_email_authorization_for_repo(code): found.save() return found + + +def lookup_team_invites(user): + return TeamMemberInvite.select().where(TeamMemberInvite.user == user) + +def lookup_team_invite(code, user): + # Lookup the invite code. + try: + found = TeamMemberInvite.get(TeamMemberInvite.invite_token == code) + except TeamMemberInvite.DoesNotExist: + raise DataModelException('Invalid confirmation code.') + + # Verify the code applies to the current user. + if found.user: + if found.user != user: + raise DataModelException('Invalid confirmation code.') + else: + if found.email != user.email: + raise DataModelException('Invalid confirmation code.') + + return found + + +def delete_team_invite(code, user): + found = lookup_team_invite(code, user) + + team = found.team + inviter = found.inviter + + found.delete_instance() + + return (team, inviter) + +def confirm_team_invite(code, user): + found = lookup_team_invite(code, user) + + # Add the user to the team. + try: + add_user_to_team(user, found.team) + except UserAlreadyInTeam: + # Ignore. + pass + + # Delete the invite and return the team. + team = found.team + inviter = found.inviter + found.delete_instance() + return (team, inviter) diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 3c0751a56..37efd44f2 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -2,7 +2,7 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, log_action, Unauthorized, NotFound, internal_only, require_scope, - query_param, truthy_bool, parse_args) + query_param, truthy_bool, parse_args, require_user_admin) from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.auth_context import get_authenticated_user from auth import scopes @@ -10,8 +10,8 @@ from data import model from util.useremails import send_org_invite_email from util.gravatar import compute_hash -def add_or_invite_to_team(team, user=None, email=None, adder=None): - invite = model.add_or_invite_to_team(team, user, email, adder) +def add_or_invite_to_team(inviter, team, user=None, email=None): + invite = model.add_or_invite_to_team(inviter, team, user, email) if not invite: # User was added to the team directly. return @@ -20,13 +20,13 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None): if user: model.create_notification('org_team_invite', user, metadata = { 'code': invite.invite_token, - 'adder': adder, + 'inviter': inviter.username, 'org': orgname, 'team': team.name }) send_org_invite_email(user.username if user else email, user.email if user else email, - orgname, team.name, adder, invite.invite_token) + orgname, team.name, inviter.username, invite.invite_token) return invite def team_view(orgname, team): @@ -204,11 +204,8 @@ class TeamMember(ApiResource): raise request_error(message='Unknown user') # Add or invite the user to the team. - adder = None - if get_authenticated_user(): - adder = get_authenticated_user().username - - invite = add_or_invite_to_team(team, user=user, adder=adder) + inviter = get_authenticated_user() + invite = add_or_invite_to_team(inviter, team, user=user) if not invite: log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) return member_view(user, invited=False) @@ -232,3 +229,52 @@ class TeamMember(ApiResource): return 'Deleted', 204 raise Unauthorized() + + +@resource('/v1/teaminvite/') +@internal_only +class TeamMemberInvite(ApiResource): + """ Resource for managing invites to jon a team. """ + @require_user_admin + @nickname('acceptOrganizationTeamInvite') + def put(self, code): + """ Accepts an invite to join a team in an organization. """ + # Accept the invite for the current user. + try: + (team, inviter) = model.confirm_team_invite(code, get_authenticated_user()) + except model.DataModelException: + raise NotFound() + + model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code) + + orgname = team.organization.username + log_action('org_team_member_invite_accepted', orgname, { + 'member': get_authenticated_user().username, + 'team': team.name, + 'inviter': inviter.username + }) + + return { + 'org': orgname, + 'team': team.name + } + + @nickname('declineOrganizationTeamInvite') + @require_user_admin + def delete(self, code): + """ Delete an existing member of a team. """ + try: + (team, inviter) = model.delete_team_invite(code, get_authenticated_user()) + except model.DataModelException: + raise NotFound() + + model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code) + + orgname = team.organization.username + log_action('org_team_member_invite_declined', orgname, { + 'member': get_authenticated_user().username, + 'team': team.name, + 'inviter': inviter.username + }) + + return 'Deleted', 204 diff --git a/endpoints/web.py b/endpoints/web.py index 19f9bb7f1..b1e69bc5e 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -32,8 +32,8 @@ STATUS_TAGS = app.config['STATUS_TAGS'] @web.route('/', methods=['GET'], defaults={'path': ''}) @web.route('/organization/', methods=['GET']) @no_cache -def index(path): - return render_page_template('index.html') +def index(path, **kwargs): + return render_page_template('index.html', **kwargs) @web.route('/500', methods=['GET']) @@ -101,7 +101,7 @@ def superuser(): @web.route('/signin/') @no_cache -def signin(): +def signin(redirect=None): return index('') @@ -123,6 +123,13 @@ def new(): return index('') +@web.route('/confirminvite') +@no_cache +def confirm_invite(): + code = request.values['code'] + return index('', code=code) + + @web.route('/repository/', defaults={'path': ''}) @web.route('/repository/', methods=['GET']) @no_cache diff --git a/initdb.py b/initdb.py index 21485d5a4..860b4e135 100644 --- a/initdb.py +++ b/initdb.py @@ -214,6 +214,8 @@ def initialize_database(): LogEntryKind.create(name='org_delete_team') LogEntryKind.create(name='org_invite_team_member') LogEntryKind.create(name='org_add_team_member') + LogEntryKind.create(name='org_team_member_invite_accepted') + LogEntryKind.create(name='org_team_member_invite_declined') LogEntryKind.create(name='org_remove_team_member') LogEntryKind.create(name='org_set_team_description') LogEntryKind.create(name='org_set_team_role') diff --git a/static/js/app.js b/static/js/app.js index 74a7b3454..e17e36e6f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -535,7 +535,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading stringBuilderService.buildString = function(value_or_func, metadata) { var fieldIcons = { - 'adder': 'user', + 'inviter': 'user', 'username': 'user', 'activating_username': 'user', 'delegate_user': 'user', @@ -1115,8 +1115,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return externalNotificationData; }]); - $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', - function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config) { + $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location', + function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) { var notificationService = { 'user': null, 'notifications': [], @@ -1135,15 +1135,24 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading }, 'org_team_invite': { 'level': 'primary', - 'message': '{adder} is inviting you to join team {team} under organization {org}', + 'message': '{inviter} is inviting you to join team {team} under organization {org}', 'actions': [ { 'title': 'Join team', 'kind': 'primary', 'handler': function(notification) { + window.location = '/confirminvite?code=' + notification.metadata['code']; } }, - {'title': 'Decline', 'kind': 'default'} + { + 'title': 'Decline', + 'kind': 'default', + 'handler': function(notification) { + ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() { + notificationService.update(); + }); + } + } ] }, 'password_required': { @@ -1725,7 +1734,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data', templateUrl: '/static/partials/security.html'}). - when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html'}). + when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html', controller: SignInCtrl, reloadOnSearch: false}). when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile', templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}). when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations', @@ -1746,6 +1755,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). + when('/confirminvite', {title: 'Confirm Team Invite', templateUrl: '/static/partials/confirm-team-invite.html', controller: ConfirmInviteCtrl, reloadOnSearch: false}). + when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl, pageClass: 'landing-page'}). otherwise({redirectTo: '/'}); @@ -2244,6 +2255,10 @@ quayApp.directive('signinForm', function () { 'signedIn': '&signedIn' }, controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { + var getRedirectUrl = function() { + return $scope.redirectUrl; + }; + $scope.showGithub = function() { if (!Features.GITHUB_LOGIN) { return; } @@ -2255,7 +2270,7 @@ quayApp.directive('signinForm', function () { } // Save the redirect URL in a cookie so that we can redirect back after GitHub returns to us. - var redirectURL = $scope.redirectUrl || window.location.toString(); + var redirectURL = getRedirectUrl() || window.location.toString(); CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); // Needed to ensure that UI work done by the started callback is finished before the location @@ -2283,17 +2298,19 @@ quayApp.directive('signinForm', function () { if ($scope.signedIn != null) { $scope.signedIn(); } - + + // Load the newly created user. UserService.load(); // Redirect to the specified page or the landing page // Note: The timeout of 500ms is needed to ensure dialogs containing sign in // forms get removed before the location changes. $timeout(function() { - if ($scope.redirectUrl == $location.path()) { - return; - } - $location.path($scope.redirectUrl ? $scope.redirectUrl : '/'); + var redirectUrl = getRedirectUrl(); + if (redirectUrl == $location.path()) { + return; + } + window.location = (redirectUrl ? redirectUrl : '/'); }, 500); }, function(result) { $scope.needsEmailVerification = result.data.needsEmailVerification; @@ -2629,8 +2646,12 @@ quayApp.directive('logsView', function () { 'org_create_team': 'Create team: {team}', 'org_delete_team': 'Delete team: {team}', 'org_add_team_member': 'Add member {member} to team {team}', - 'org_invite_team_member': 'Invite user {member} to team {team}', 'org_remove_team_member': 'Remove member {member} from team {team}', + 'org_invite_team_member': 'Invite user {member} to team {team}', + + 'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, accepted to join team {team}', + 'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}', + 'org_set_team_description': 'Change description of team {team}: {description}', 'org_set_team_role': 'Change permission of team {team} to {role}', 'create_prototype_permission': function(metadata) { @@ -2711,6 +2732,8 @@ quayApp.directive('logsView', function () { 'org_add_team_member': 'Add team member', 'org_invite_team_member': 'Invite team member', 'org_remove_team_member': 'Remove team member', + 'org_team_member_invite_accepted': 'Team invite accepted', + 'org_team_member_invite_declined': 'Team invite declined', 'org_set_team_description': 'Change team description', 'org_set_team_role': 'Change team permission', 'create_prototype_permission': 'Create default permission', @@ -5346,7 +5369,7 @@ quayApp.directive('dockerfileBuildForm', function () { var data = { 'mimeType': mimeType }; - + var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) { conductUpload(file, resp.url, resp.file_id, mimeType); }, function() { diff --git a/static/js/controllers.js b/static/js/controllers.js index 56689e80a..82ed5c684 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -20,6 +20,17 @@ $.fn.clipboardCopy = function() { }); }; +function SignInCtrl($scope, $location) { + var redirect = $location.search()['redirect']; + if (redirect && redirect.indexOf('/') < 0) { + delete $location.search()['redirect']; + $scope.redirectUrl = '/' + redirect; + return; + } + + $scope.redirectUrl = '/'; +} + function GuideCtrl() { } @@ -2855,3 +2866,28 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { function TourCtrl($scope, $location) { $scope.kind = $location.path().substring('/tour/'.length); } + +function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) { + // Monitor any user changes and place the current user into the scope. + $scope.loading = false; + + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous && !$scope.loading) { + $scope.loading = true; + + var params = { + 'code': $location.search()['code'] + }; + + ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) { + NotificationService.update(); + $location.path('/organization/' + resp.org + '/teams/' + resp.team); + }, function() { + $scope.loading = false; + $scope.invalid = true; + }); + } + }); + + $scope.redirectUrl = 'confirminvite?code=' + $location.search()['code']; +} \ No newline at end of file diff --git a/static/partials/confirm-team-invite.html b/static/partials/confirm-team-invite.html new file mode 100644 index 000000000..625e9e262 --- /dev/null +++ b/static/partials/confirm-team-invite.html @@ -0,0 +1,13 @@ +
    + +
    diff --git a/static/partials/signin.html b/static/partials/signin.html index 4aac6cb7e..2a1a9563d 100644 --- a/static/partials/signin.html +++ b/static/partials/signin.html @@ -1,7 +1,7 @@ diff --git a/test/test_api_security.py b/test/test_api_security.py index 5b3e5612d..364432d4b 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -8,7 +8,7 @@ from app import app from initdb import setup_database_for_testing, finished_database_for_testing from endpoints.api import api_bp, api -from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam +from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList @@ -3424,6 +3424,36 @@ class TestSuperUserLogs(ApiTestCase): self._run_test('GET', 200, 'devtable', None) +class TestTeamMemberInvite(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(TeamMemberInvite, code='foobarbaz') + + def test_put_anonymous(self): + self._run_test('PUT', 401, None, None) + + def test_put_freshuser(self): + self._run_test('PUT', 404, 'freshuser', None) + + def test_put_reader(self): + self._run_test('PUT', 404, 'reader', None) + + def test_put_devtable(self): + self._run_test('PUT', 404, 'devtable', None) + + def test_delete_anonymous(self): + self._run_test('DELETE', 401, None, None) + + def test_delete_freshuser(self): + self._run_test('DELETE', 404, 'freshuser', None) + + def test_delete_reader(self): + self._run_test('DELETE', 404, 'reader', None) + + def test_delete_devtable(self): + self._run_test('DELETE', 404, 'devtable', None) + + class TestSuperUserList(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) @@ -3442,7 +3472,6 @@ class TestSuperUserList(ApiTestCase): self._run_test('GET', 200, 'devtable', None) - class TestSuperUserManagement(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index c91005c5c..99086e45b 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -11,7 +11,7 @@ from app import app from initdb import setup_database_for_testing, finished_database_for_testing from data import model, database -from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam +from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.image import RepositoryImage, RepositoryImageList @@ -734,16 +734,50 @@ class TestGetOrganizationTeamMembers(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert READ_ACCESS_USER in json['members'] + self.assertEquals(READ_ACCESS_USER, json['members'][1]['name']) class TestUpdateOrganizationTeamMember(ApiTestCase): - def test_addmember(self): + def assertInTeam(self, data, membername): + for memberData in data['members']: + if memberData['name'] == membername: + return + + self.fail(membername + ' not found in team: ' + json.dumps(data)) + + def test_addmember_alreadyteammember(self): self.login(ADMIN_ACCESS_USER) + membername = READ_ACCESS_USER + self.putResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='readers', + membername=membername), + expected_code=400) + + + def test_addmember_orgmember(self): + self.login(ADMIN_ACCESS_USER) + + membername = READ_ACCESS_USER + self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + # Verify the user was added to the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='owners')) + + self.assertInTeam(json, membername) + + + def test_addmember_robot(self): + self.login(ADMIN_ACCESS_USER) + + membername = ORGANIZATION + '+coolrobot' self.putJsonResponse(TeamMember, params=dict(orgname=ORGANIZATION, teamname='readers', - membername=NO_ACCESS_USER)) + membername=membername)) # Verify the user was added to the team. @@ -751,7 +785,152 @@ class TestUpdateOrganizationTeamMember(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert NO_ACCESS_USER in json['members'] + self.assertInTeam(json, membername) + + + def test_addmember_invalidrobot(self): + self.login(ADMIN_ACCESS_USER) + + membername = 'freshuser+anotherrobot' + self.putResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='readers', + membername=membername), + expected_code=400) + + + def test_addmember_nonorgmember(self): + self.login(ADMIN_ACCESS_USER) + + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + + self.assertEquals(True, response['invited']) + + # Make sure the user is not (yet) part of the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='readers')) + + for member in json['members']: + self.assertNotEqual(membername, member['name']) + + +class TestAcceptTeamMemberInvite(ApiTestCase): + def assertInTeam(self, data, membername): + for memberData in data['members']: + if memberData['name'] == membername: + return + + self.fail(membername + ' not found in team: ' + json.dumps(data)) + + def test_accept_wronguser(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Try to accept the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.putResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + def test_accept(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Login as the user. + self.login(membername) + + # Accept the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.putJsonResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token)) + + # Verify the user is now on the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='owners')) + + self.assertInTeam(json, membername) + + # Verify the accept now fails. + self.putResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + +class TestDeclineTeamMemberInvite(ApiTestCase): + def test_decline_wronguser(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Try to decline the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + def test_decline(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Login as the user. + self.login(membername) + + # Decline the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token)) + + # Make sure the invite was deleted. + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) class TestDeleteOrganizationTeamMember(ApiTestCase): @@ -768,7 +947,7 @@ class TestDeleteOrganizationTeamMember(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert not READ_ACCESS_USER in json['members'] + assert len(json['members']) == 1 class TestCreateRepo(ApiTestCase): @@ -2064,7 +2243,7 @@ class TestSuperUserManagement(ApiTestCase): json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) self.assertEquals('freshuser', json['username']) - self.assertEquals('no@thanks.com', json['email']) + self.assertEquals('jschorr+test@devtable.com', json['email']) self.assertEquals(False, json['super_user']) def test_delete_user(self): @@ -2087,7 +2266,7 @@ class TestSuperUserManagement(ApiTestCase): # Verify the user exists. json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) self.assertEquals('freshuser', json['username']) - self.assertEquals('no@thanks.com', json['email']) + self.assertEquals('jschorr+test@devtable.com', json['email']) # Update the user. self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com')) diff --git a/util/useremails.py b/util/useremails.py index 33307623c..7e727cb40 100644 --- a/util/useremails.py +++ b/util/useremails.py @@ -67,7 +67,7 @@ To confirm this email address, please click the following link:
    INVITE_TO_ORG_TEAM_MESSAGE = """ Hi {0},
    -{1} has invited you to join the team {2} under organization {3} on {5}. +{1} has invited you to join the team {2} under organization {3} on {5}.

    To join the team, please click the following link:
    {4}/confirminvite?code={6} From daa43c3bb959b5a8d637c85d90f17e63a64f14e4 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 18 Aug 2014 20:34:39 -0400 Subject: [PATCH 011/160] Add better messaging around pulling of base images when they fail due to invalid or missing credentials --- data/buildlogs.py | 7 +++- static/css/quay.css | 4 +- static/directives/build-log-error.html | 27 ++++++++++++-- static/js/app.js | 51 +++++++++++++++++++++++++- static/partials/repo-build.html | 2 +- workers/dockerfilebuild.py | 14 ++++++- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/data/buildlogs.py b/data/buildlogs.py index 2ccd03899..8f184de27 100644 --- a/data/buildlogs.py +++ b/data/buildlogs.py @@ -25,7 +25,7 @@ class RedisBuildLogs(object): """ return self._redis.rpush(self._logs_key(build_id), json.dumps(log_obj)) - def append_log_message(self, build_id, log_message, log_type=None): + def append_log_message(self, build_id, log_message, log_type=None, log_data=None): """ Wraps the message in an envelope and push it to the end of the log entry list and returns the index at which it was inserted. @@ -37,6 +37,9 @@ class RedisBuildLogs(object): if log_type: log_obj['type'] = log_type + if log_data: + log_obj['data'] = log_data + return self._redis.rpush(self._logs_key(build_id), json.dumps(log_obj)) - 1 def get_log_entries(self, build_id, start_index): @@ -106,4 +109,4 @@ class BuildLogs(object): return buildlogs def __getattr__(self, name): - return getattr(self.state, name, None) \ No newline at end of file + return getattr(self.state, name, None) diff --git a/static/css/quay.css b/static/css/quay.css index 01fe84e60..68834f282 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -2535,7 +2535,7 @@ p.editable:hover i { margin-top: 10px; } -.repo-build .build-log-error-element { +.repo-build .build-log-error-element .error-message-container { position: relative; display: inline-block; margin: 10px; @@ -2545,7 +2545,7 @@ p.editable:hover i { margin-left: 22px; } -.repo-build .build-log-error-element i.fa { +.repo-build .build-log-error-element .error-message-container i.fa { color: red; position: absolute; top: 13px; diff --git a/static/directives/build-log-error.html b/static/directives/build-log-error.html index 095f8edd0..cf03fa7b2 100644 --- a/static/directives/build-log-error.html +++ b/static/directives/build-log-error.html @@ -1,4 +1,23 @@ - - - - +
    + + + + + caused by attempting to pull private repository {{ getLocalPullInfo().repo }} + with inaccessible crdentials + without credentials + + + + +
    +
    + Note: The credentials {{ getLocalPullInfo().login.username }} for registry {{ getLocalPullInfo().login.registry }} cannot + access repository {{ getLocalPullInfo().repo }}. +
    +
    + Note: No robot account is specified for this build. Without such credentials, this pull will always fail. Please setup a new + build trigger with a robot account that has access to {{ getLocalPullInfo().repo }} or make the repository public. +
    +
    +
    diff --git a/static/js/app.js b/static/js/app.js index ad6527fc1..00c1a27ce 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -113,6 +113,14 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading this.currentIndex_ = 0; } + _ViewArray.prototype.length = function() { + return this.entries.length; + }; + + _ViewArray.prototype.get = function(index) { + return this.entries[index]; + }; + _ViewArray.prototype.push = function(elem) { this.entries.push(elem); this.hasEntries = true; @@ -4021,9 +4029,48 @@ quayApp.directive('buildLogError', function () { transclude: false, restrict: 'C', scope: { - 'error': '=error' + 'error': '=error', + 'entries': '=entries' }, - controller: function($scope, $element) { + controller: function($scope, $element, Config) { + $scope.getLocalPullInfo = function() { + if ($scope.entries.__localpull !== undefined) { + return $scope.entries.__localpull; + } + + var localInfo = { + 'isLocal': false + }; + + // Find the 'pulling' phase entry, and then extra any metadata found under + // it. + for (var i = 0; i < $scope.entries.length; ++i) { + var entry = $scope.entries[i]; + if (entry.type == 'phase' && entry.message == 'pulling') { + for (var j = 0; j < entry.logs.length(); ++j) { + var log = entry.logs.get(j); + if (log.data && log.data.phasestep == 'login') { + localInfo['login'] = log.data; + } + + if (log.data && log.data.phasestep == 'pull') { + var repo_url = log.data['repo_url']; + var repo_and_tag = repo_url.substring(Config.SERVER_HOSTNAME.length + 1); + var tagIndex = repo_and_tag.lastIndexOf(':'); + var repo = repo_and_tag.substring(0, tagIndex); + + localInfo['repo_url'] = repo_url; + localInfo['repo'] = repo; + + localInfo['isLocal'] = repo_url.indexOf(Config.SERVER_HOSTNAME + '/') == 0; + } + } + break; + } + } + + return $scope.entries.__localpull = localInfo; + }; } }; return directiveDefinitionObject; diff --git a/static/partials/repo-build.html b/static/partials/repo-build.html index 3afe87508..225f58701 100644 --- a/static/partials/repo-build.html +++ b/static/partials/repo-build.html @@ -77,7 +77,7 @@
    - +
    diff --git a/workers/dockerfilebuild.py b/workers/dockerfilebuild.py index a4de1cc47..d200a336e 100644 --- a/workers/dockerfilebuild.py +++ b/workers/dockerfilebuild.py @@ -223,6 +223,13 @@ class DockerfileBuildContext(object): if self._pull_credentials: logger.debug('Logging in with pull credentials: %s@%s', self._pull_credentials['username'], self._pull_credentials['registry']) + + self._build_logger('Pulling base image: %s' % image_and_tag, { + 'phasestep': 'login', + 'username': self._pull_credentials['username'], + 'registry': self._pull_credentials['registry'] + }) + self._build_cl.login(self._pull_credentials['username'], self._pull_credentials['password'], registry=self._pull_credentials['registry'], reauth=True) @@ -233,7 +240,12 @@ class DockerfileBuildContext(object): raise JobException('Missing FROM command in Dockerfile') image_and_tag = ':'.join(image_and_tag_tuple) - self._build_logger('Pulling base image: %s' % image_and_tag) + + self._build_logger('Pulling base image: %s' % image_and_tag, { + 'phasestep': 'pull', + 'repo_url': image_and_tag + }) + pull_status = self._build_cl.pull(image_and_tag, stream=True) self.__monitor_completion(pull_status, 'Downloading', self._status, 'pull_completion') From d5027d238376d1177db76e1e8a405b6b3e0b26b7 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 18 Aug 2014 20:45:48 -0400 Subject: [PATCH 012/160] Add a migration script for the new table and log entry kinds --- ...d0e_add_support_for_team_member_invites.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 data/migrations/versions/25aea439ad0e_add_support_for_team_member_invites.py diff --git a/data/migrations/versions/25aea439ad0e_add_support_for_team_member_invites.py b/data/migrations/versions/25aea439ad0e_add_support_for_team_member_invites.py new file mode 100644 index 000000000..81bc9bbc6 --- /dev/null +++ b/data/migrations/versions/25aea439ad0e_add_support_for_team_member_invites.py @@ -0,0 +1,72 @@ +"""Add support for team member invites + +Revision ID: 25aea439ad0e +Revises: 82297d834ad +Create Date: 2014-08-18 20:40:38.553951 + +""" + +# revision identifiers, used by Alembic. +revision = '25aea439ad0e' +down_revision = '82297d834ad' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('teammemberinvite', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('inviter_id', sa.Integer(), nullable=False), + sa.Column('invite_token', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['inviter_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('teammemberinvite_inviter_id', 'teammemberinvite', ['inviter_id'], unique=False) + op.create_index('teammemberinvite_team_id', 'teammemberinvite', ['team_id'], unique=False) + op.create_index('teammemberinvite_user_id', 'teammemberinvite', ['user_id'], unique=False) + ### end Alembic commands ### + + schema = gen_sqlalchemy_metadata(all_models) + + # Manually add the new logentrykind types + op.bulk_insert(schema.tables['logentrykind'], + [ + {'id':41, 'name':'org_invite_team_member'}, + {'id':42, 'name':'org_team_member_invite_accepted'}, + {'id':43, 'name':'org_team_member_invite_declined'}, + ]) + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index('teammemberinvite_user_id', table_name='teammemberinvite') + op.drop_index('teammemberinvite_team_id', table_name='teammemberinvite') + op.drop_index('teammemberinvite_inviter_id', table_name='teammemberinvite') + op.drop_table('teammemberinvite') + ### end Alembic commands ### + + schema = gen_sqlalchemy_metadata(all_models) + + logentrykind = schema.tables['logentrykind'] + + op.execute( + (logentrykind.delete() + .where(logentrykind.c.name == op.inline_literal('org_invite_team_member'))) + ) + + op.execute( + (logentrykind.delete() + .where(logentrykind.c.name == op.inline_literal('org_team_member_invite_accepted'))) + ) + + op.execute( + (logentrykind.delete() + .where(logentrykind.c.name == op.inline_literal('org_team_member_invite_declined'))) + ) From 35bd28a77e1b407a31487b8ca242c34d385b678c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 Aug 2014 14:33:33 -0400 Subject: [PATCH 013/160] Add support for the Flowdock Team chat API: https://www.flowdock.com/api/push --- endpoints/notificationevent.py | 2 + endpoints/notificationmethod.py | 55 +++++++++++++++++- initdb.py | 2 + static/css/quay.css | 7 +++ .../create-external-notification-dialog.html | 8 ++- static/img/flowdock.ico | Bin 0 -> 5558 bytes static/js/app.js | 13 +++++ test/data/test.db | Bin 614400 -> 614400 bytes 8 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 static/img/flowdock.ico diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index f1cbec42c..e393dc134 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -184,6 +184,8 @@ class BuildFailureEvent(NotificationEvent): return 'build_failure' def get_sample_data(self, repository): + build_uuid = 'fake-build-id' + return build_event_data(repository, { 'build_id': build_uuid, 'build_name': 'some-fake-build', diff --git a/endpoints/notificationmethod.py b/endpoints/notificationmethod.py index b49055157..56adcc0a5 100644 --- a/endpoints/notificationmethod.py +++ b/endpoints/notificationmethod.py @@ -4,9 +4,10 @@ import os.path import tarfile import base64 import json +import requests from flask.ext.mail import Message -from app import mail, app +from app import mail, app, get_app_url from data import model logger = logging.getLogger(__name__) @@ -187,3 +188,55 @@ class WebhookMethod(NotificationMethod): return False return True + + +class FlowdockMethod(NotificationMethod): + """ Method for sending notifications to Flowdock via the Team Inbox API: + https://www.flowdock.com/api/team-inbox + """ + @classmethod + def method_name(cls): + return 'flowdock' + + def validate(self, repository, config_data): + token = config_data.get('flow_api_token', '') + if not token: + raise CannotValidateNotificationMethodException('Missing Flowdock API Token') + + def perform(self, notification, event_handler, notification_data): + config_data = json.loads(notification.config_json) + token = config_data.get('flow_api_token', '') + if not token: + return False + + owner = model.get_user(notification.repository.namespace) + if not owner: + # Something went wrong. + return False + + url = 'https://api.flowdock.com/v1/messages/team_inbox/%s' % token + headers = {'Content-type': 'application/json'} + payload = { + 'source': 'Quay', + 'from_address': 'support@quay.io', + 'subject': event_handler.get_summary(notification_data['event_data'], notification_data), + 'content': event_handler.get_message(notification_data['event_data'], notification_data), + 'from_name': owner.username, + 'project': notification.repository.namespace + ' ' + notification.repository.name, + 'tags': ['#' + event_handler.event_name()], + 'link': notification_data['event_data']['homepage'] + } + + try: + resp = requests.post(url, data=json.dumps(payload), headers=headers) + if resp.status_code/100 != 2: + logger.error('%s response for flowdock to url: %s' % (resp.status_code, + url)) + logger.error(resp.content) + return False + + except requests.exceptions.RequestException as ex: + logger.exception('Flowdock method was unable to be sent: %s' % ex.message) + return False + + return True diff --git a/initdb.py b/initdb.py index 7e48ae3af..cb56d987e 100644 --- a/initdb.py +++ b/initdb.py @@ -251,6 +251,8 @@ def initialize_database(): ExternalNotificationMethod.create(name='email') ExternalNotificationMethod.create(name='webhook') + ExternalNotificationMethod.create(name='flowdock') + NotificationKind.create(name='repo_push') NotificationKind.create(name='build_queued') NotificationKind.create(name='build_start') diff --git a/static/css/quay.css b/static/css/quay.css index 01fe84e60..a5cdf019b 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4559,6 +4559,13 @@ i.quay-icon { height: 16px; } +i.flowdock-icon { + background-image: url(/static/img/flowdock.ico); + background-size: 16px; + width: 16px; + height: 16px; +} + .external-notification-view-element { margin: 10px; padding: 6px; diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html index d384f3f59..ba78a4ac9 100644 --- a/static/directives/create-external-notification-dialog.html +++ b/static/directives/create-external-notification-dialog.html @@ -73,7 +73,7 @@
    - {{ field.title }}: + {{ field.title }}:
    @@ -86,7 +86,11 @@ current-entity="currentConfig[field.name]" ng-model="currentConfig[field.name]" allowed-entities="['user', 'team', 'org']" - ng-switch-when="entity"> + ng-switch-when="entity">
    + +
    diff --git a/static/img/flowdock.ico b/static/img/flowdock.ico new file mode 100644 index 0000000000000000000000000000000000000000..e3d92799b7bfe01a0ae208fb9576467411a505d6 GIT binary patch literal 5558 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 K=eB?M|L{K?9?(|+ literal 0 HcmV?d00001 diff --git a/static/js/app.js b/static/js/app.js index ad6527fc1..8676d7d99 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1076,6 +1076,19 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'title': 'Webhook URL' } ] + }, + { + 'id': 'flowdock', + 'title': 'Flowdock Team Notification', + 'icon': 'flowdock-icon', + 'fields': [ + { + 'name': 'flow_api_token', + 'type': 'string', + 'title': 'Flow API Token', + 'help_url': 'https://www.flowdock.com/account/tokens' + } + ] } ]; diff --git a/test/data/test.db b/test/data/test.db index 4d04283311e6feb845dff3fde9a4d370858119be..a947e59643b1155787f4970e641fb95bf1b9aef7 100644 GIT binary patch delta 6225 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 From 32ea1d194f1d97e68649b1848fdcf7787412c466 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 Aug 2014 17:40:36 -0400 Subject: [PATCH 014/160] Add support for the Hipchat room notification API --- endpoints/notificationevent.py | 22 ++++++ endpoints/notificationmethod.py | 62 +++++++++++++++++ initdb.py | 1 + static/css/quay.css | 7 ++ .../create-external-notification-dialog.html | 4 +- static/img/hipchat.png | Bin 0 -> 2828 bytes static/js/app.js | 63 +++++++++++++++++- 7 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 static/img/hipchat.png diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index e393dc134..f3f4d6a77 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -15,6 +15,13 @@ class NotificationEvent(object): def __init__(self): pass + def get_level(self, event_data, notification_data): + """ + Returns a 'level' representing the severity of the event. + Valid values are: 'info', 'warning', 'error', 'primary' + """ + raise NotImplementedError + def get_summary(self, event_data, notification_data): """ Returns a human readable one-line summary for the given notification data. @@ -55,6 +62,9 @@ class RepoPushEvent(NotificationEvent): def event_name(cls): return 'repo_push' + def get_level(self, event_data, notification_data): + return 'info' + def get_summary(self, event_data, notification_data): return 'Repository %s updated' % (event_data['repository']) @@ -87,6 +97,9 @@ class BuildQueueEvent(NotificationEvent): @classmethod def event_name(cls): return 'build_queued' + + def get_level(self, event_data, notification_data): + return 'info' def get_sample_data(self, repository): build_uuid = 'fake-build-id' @@ -127,6 +140,9 @@ class BuildStartEvent(NotificationEvent): def event_name(cls): return 'build_start' + def get_level(self, event_data, notification_data): + return 'info' + def get_sample_data(self, repository): build_uuid = 'fake-build-id' @@ -155,6 +171,9 @@ class BuildSuccessEvent(NotificationEvent): def event_name(cls): return 'build_success' + def get_level(self, event_data, notification_data): + return 'primary' + def get_sample_data(self, repository): build_uuid = 'fake-build-id' @@ -183,6 +202,9 @@ class BuildFailureEvent(NotificationEvent): def event_name(cls): return 'build_failure' + def get_level(self, event_data, notification_data): + return 'error' + def get_sample_data(self, repository): build_uuid = 'fake-build-id' diff --git a/endpoints/notificationmethod.py b/endpoints/notificationmethod.py index 56adcc0a5..a6d958037 100644 --- a/endpoints/notificationmethod.py +++ b/endpoints/notificationmethod.py @@ -240,3 +240,65 @@ class FlowdockMethod(NotificationMethod): return False return True + + +class HipchatMethod(NotificationMethod): + """ Method for sending notifications to Hipchat via the API: + https://www.hipchat.com/docs/apiv2/method/send_room_notification + """ + @classmethod + def method_name(cls): + return 'hipchat' + + def validate(self, repository, config_data): + if not config_data.get('notification_token', ''): + raise CannotValidateNotificationMethodException('Missing Hipchat Room Notification Token') + + if not config_data.get('room_id', ''): + raise CannotValidateNotificationMethodException('Missing Hipchat Room ID') + + def perform(self, notification, event_handler, notification_data): + config_data = json.loads(notification.config_json) + + token = config_data.get('notification_token', '') + room_id = config_data.get('room_id', '') + + if not token or not room_id: + return False + + owner = model.get_user(notification.repository.namespace) + if not owner: + # Something went wrong. + return False + + url = 'https://api.hipchat.com/v2/room/%s/notification?auth_token=%s' % (room_id, token) + + level = event_handler.get_level(notification_data['event_data'], notification_data) + color = { + 'info': 'gray', + 'warning': 'yellow', + 'error': 'red', + 'primary': 'purple' + }.get(level, 'gray') + + headers = {'Content-type': 'application/json'} + payload = { + 'color': color, + 'message': event_handler.get_message(notification_data['event_data'], notification_data), + 'notify': level == 'error', + 'message_format': 'html', + } + + try: + resp = requests.post(url, data=json.dumps(payload), headers=headers) + if resp.status_code/100 != 2: + logger.error('%s response for hipchat to url: %s' % (resp.status_code, + url)) + logger.error(resp.content) + return False + + except requests.exceptions.RequestException as ex: + logger.exception('Hipchat method was unable to be sent: %s' % ex.message) + return False + + return True diff --git a/initdb.py b/initdb.py index cb56d987e..6b68cee85 100644 --- a/initdb.py +++ b/initdb.py @@ -252,6 +252,7 @@ def initialize_database(): ExternalNotificationMethod.create(name='webhook') ExternalNotificationMethod.create(name='flowdock') + ExternalNotificationMethod.create(name='hipchat') NotificationKind.create(name='repo_push') NotificationKind.create(name='build_queued') diff --git a/static/css/quay.css b/static/css/quay.css index a5cdf019b..bd8f571e8 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4566,6 +4566,13 @@ i.flowdock-icon { height: 16px; } +i.hipchat-icon { + background-image: url(/static/img/hipchat.png); + background-size: 16px; + width: 16px; + height: 16px; +} + .external-notification-view-element { margin: 10px; padding: 6px; diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html index ba78a4ac9..bf0c5da03 100644 --- a/static/directives/create-external-notification-dialog.html +++ b/static/directives/create-external-notification-dialog.html @@ -88,8 +88,8 @@ allowed-entities="['user', 'team', 'org']" ng-switch-when="entity">

    - diff --git a/static/img/hipchat.png b/static/img/hipchat.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0500763a2546cb6a4f41f94d3b4cc0c14f27a3 GIT binary patch literal 2828 zcmV+n3-k1eP) zKOSs*0qtXRxLtxMcqIZ!!E-8}Gw9UqEoz5fqvdi7$Io6lnsfPNeo;|*&2^jI7Ig|A`l5Yhae6# zT|Y3Xnl$czqDOAXG%(h`Ce8&UzxbjUAfNzE<}6!snN!pZzfxEDNm? z3=J8OBzQ&md>{Y|5E>K(N`?%`1d zZoMK>9~$yVa^k?-Vj21dnE}W-SIT!~{=6w8`|`O$a5R)kurwqes%$+6#sBxCSEITb zT3y+?mc`~snzFp~Y$3w*plNtTIGo}Ax-Wfx!c*giurw36 zjA|zg8c;N9?R1#o!j?CCMRjcv*^1VHCzqZ(nhR4f09fr^dWP>?|MDGuqR|{B_w^=f2N{Q4blg*ai%VFWSAbvz!1#gax1f z`mM2(hT*yuGJ#1`eX(rJjI}MonrZDlp7-g-445>aDb&~!hV;uxU#wI%dmo*`-gUDd zd~^gX4iFgzZPvb%A8yHP8qn+_Mn5#a^l~Zm8rWRH(V?Zgmupl(0XaL3{*Ns=f9e7Z zTDa1mO25*cyA`OYSf8PsYuoJB_Ho@ zm}=BooqiCCAkY+??q{bwa8FX7)>3sQF%ib)vnRvl0wGYU(spJ!J)(cAUhCkG`NjDq zP$}RMD5GJ;tVwNW>rCcP8xs~Ei@G|fc<;5E%^5%V$LqDWXP$y6fdSm!XC~Yo7OZVM zRVVZEqr>5mAOkGD?NE+?uwLswKNZy9VR-hbaf8}U()m0wb|4HIh(2)avAm*My9a)) zVsp)vvg_a(ctvA3GVw z{+g2Yw~*L37NQ86h9Kr%sf1svvbq*t5flX?^@%jKm!$KFk2ckRorqj%t%m2|DvLdE{HMGUa4ZM`&&79-fL|*%JhVqt2z(M0?6s?w(*?yX zmZiS6G3LHmpX@jin6cwzX6JVs{o05Z-fJ;lWp$jmR058HPf{4P{bIYJ;a7gxfZp(m zU>JDaoBn;W#mX?F4(6H{URnR>oR2E4PTV9iF#p3HBd4vvRdY;NeT&)c2T!7^7CZ;R zHz+>FsMnxDYjX1KpcxPuyy73bj|hUV>B_XZQ^JS$2gr`ChoeT%d1LMNN{bC0Koq2p zwjYjoaNf#yw!rRT;$ps7_Lmkh?|+jCf5q&R{&au+$-A)(3?DnU;%WsnL9jR$&UL1%Q?Z%9w&(2IkhzcH&)~GE9RvYx{W?HMh+&gd4MuY}K^g+R1 z_;y*J$grj>0AmoA9ZDnR?Rj@ka zlj8EeS&RVYtlPF`<9^gSp;CZlArlY+AF1y|P!w1iG!2@DB*X22l7Bko-VF<$R`4y4 zGoxm&JGAFVgy`V$B8actx2B6p=bvf}rGX7^q`)IVlweTjXXicq>UspQdiLbY87rQ9 zW{k$9hszCz7hVZ81&#s7fTbW1xIJ*XST#R6W%$qAmVEKIX@O2NSEe7@c@)7K5Q19g zvgs2VoDg_UeB|}Com;aI77P$n+9pm({&vlDw1(4FxA%BK)~SNi1tn$X8mHR}89_=# zgh_ix-{@fpy~hvl-_{*==9(?**Kdbj3!a6=k$BH-r#CHZ8W4DVmAg+|Se#RcP(37q z>$X7;Bpq1ys$Sdpr4B@tB*_QJ;91b@jamUj#v^k!>`FfblO75V)egRkzC2@cH4aT=*k_BT{(1CW_-&lJP%>`Tx~hw12+@J1QR9M+Kk?P# zf$=ddB3o~bTq!drrLM4-RKTDEO~dJiMD81rv|`4jp#ypZ#&?jYadXOYi(%4(W?*w5 zSe=#j+P!zh1xB{nNGh+kjeP#S3)y)v>cO)ReQTR1m#}P~+iGUhChpm39rKo7g-m^!9%ROq$p!IJ))jPrj;K@awr*A$4O%N!$VE59v?%i75uA^E2B!cw4 z$G_N$18~ zC)_@`@6VeTwYt|$Xf;q~wq>0tIDGcfnS$anbFJCxl0*R#0DzNenhy?xx+XiE z5CNJ1Eb5$0R5$mLbv)O2n*aT@8n}ARdOE-KTv2Ipd5yWoMhG?M>5=!}*|%xT|3RxG ev@C=EAp8%NkGu3nbEE_S0000 Date: Thu, 21 Aug 2014 15:12:43 -0400 Subject: [PATCH 015/160] Fix some of the tools --- tools/emailinvoice.py | 2 +- tools/sendconfirmemail.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/emailinvoice.py b/tools/emailinvoice.py index 63a5bd712..e9c9d0861 100644 --- a/tools/emailinvoice.py +++ b/tools/emailinvoice.py @@ -1,4 +1,4 @@ -from app import stripe +import stripe from app import app from util.invoice import renderInvoiceToHtml diff --git a/tools/sendconfirmemail.py b/tools/sendconfirmemail.py index e9333a181..94345c573 100644 --- a/tools/sendconfirmemail.py +++ b/tools/sendconfirmemail.py @@ -1,4 +1,3 @@ -from app import stripe from app import app from util.useremails import send_confirmation_email From 8866b881dba44b97b789556e27791b3c0a3044ef Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 21 Aug 2014 17:44:56 -0400 Subject: [PATCH 016/160] Remove all license code --- .dockerignore | 4 +-- app.py | 10 -------- license.py | 13 ---------- license.pyc | Bin 895 -> 0 bytes tools/createlicense.py | 38 ---------------------------- util/expiration.py | 55 ----------------------------------------- 6 files changed, 2 insertions(+), 118 deletions(-) delete mode 100644 license.py delete mode 100644 license.pyc delete mode 100644 tools/createlicense.py delete mode 100644 util/expiration.py diff --git a/.dockerignore b/.dockerignore index fcc890f76..40ff6c49f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,11 @@ conf/stack screenshots +tools test/data/registry venv .git .gitignore Bobfile README.md -license.py requirements-nover.txt -run-local.sh +run-local.sh \ No newline at end of file diff --git a/app.py b/app.py index 78746fbcf..92a2dacc1 100644 --- a/app.py +++ b/app.py @@ -21,7 +21,6 @@ from data.billing import Billing from data.buildlogs import BuildLogs from data.queue import WorkQueue from data.userevent import UserEventsBuilderModule -from license import load_license from datetime import datetime @@ -50,15 +49,6 @@ else: environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) app.config.update(environ_config) - logger.debug('Applying license config from: %s', LICENSE_FILENAME) - try: - app.config.update(load_license(LICENSE_FILENAME)) - except IOError: - raise RuntimeError('License file %s not found; please check your configuration' % LICENSE_FILENAME) - - if app.config.get('LICENSE_EXPIRATION', datetime.min) < datetime.utcnow(): - raise RuntimeError('License has expired, please contact support@quay.io') - features.import_features(app.config) Principal(app, use_sessions=False) diff --git a/license.py b/license.py deleted file mode 100644 index b45d90cf8..000000000 --- a/license.py +++ /dev/null @@ -1,13 +0,0 @@ -import pickle - -from Crypto.PublicKey import RSA - -n = 24311791124264168943780535074639421876317270880681911499019414944027362498498429776192966738844514582251884695124256895677070273097239290537016363098432785034818859765271229653729724078304186025013011992335454557504431888746007324285000011384941749613875855493086506022340155196030616409545906383713728780211095701026770053812741971198465120292345817928060114890913931047021503727972067476586739126160044293621653486418983183727572502888923949587290840425930251185737996066354726953382305020440374552871209809125535533731995494145421279907938079885061852265339259634996180877443852561265066616143910755505151318370667L -e = 65537L - -def load_license(license_path): - decryptor = RSA.construct((n, e)) - with open(license_path, 'rb') as encrypted_license: - decrypted_data = decryptor.encrypt(encrypted_license.read(), 0) - - return pickle.loads(decrypted_data[0]) diff --git a/license.pyc b/license.pyc deleted file mode 100644 index 83687adfa9c32d46b214c3197998c215e4e9fcfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 895 zcma)4Z%9*76hH62ZMxF@mtm2!%-Mu=ff&m~C(0!MjC{kCh3WR*lWjit=6iR`)eK}3 z1c803AkmNIhbW4IC?bpKOC%H&8b%pX6n&}>iS*~(=c2D&-ud10&OPUM&OPVy-*PwI z&b{dqA+reXZWO%^LBfv%1;D?d6Hqu9A>b##Nj&&@kWAn=fn_tuMyRY{tU1zG= z6Q6?9vZzcIxg9}$d9={m<620>`3cvJtq+@4o1qZF*ea z(ppgQF0o?2MEQ}n+J2k8b4@*FjRv@zFpLNeD}ul@ z$}~}p>Qsx1l(MR#2FjQgK#j>!GvtiL$xJ!s4(ZQ!}cH8jy4c3!ype zH!R9gT^@)9Xvjz*$ws`Y(E>cqu*uRu#*uD8YsLqyQh){=XaNIs8*0uTUDkAGL>EJ< zra4FBfuu7NToX%fB*hy35@utF&H%V#u8^}4@TbjV4g8G`Y6 i*(C&4kCFTz{7)H-zhOj`;)>kJdTEr9L-L7*vyeZS$mU)E diff --git a/tools/createlicense.py b/tools/createlicense.py deleted file mode 100644 index 53700d4f4..000000000 --- a/tools/createlicense.py +++ /dev/null @@ -1,38 +0,0 @@ -import argparse -import pickle - -from Crypto.PublicKey import RSA -from datetime import datetime, timedelta - -def encrypt(message, output_filename): - private_key_file = 'conf/stack/license_key' - with open(private_key_file, 'r') as private_key: - encryptor = RSA.importKey(private_key) - - encrypted_data = encryptor.decrypt(message) - - with open(output_filename, 'wb') as encrypted_file: - encrypted_file.write(encrypted_data) - -parser = argparse.ArgumentParser(description='Create a license file.') -parser.add_argument('--users', type=int, default=20, - help='Number of users allowed by the license') -parser.add_argument('--days', type=int, default=30, - help='Number of days for which the license is valid') -parser.add_argument('--warn', type=int, default=7, - help='Number of days prior to expiration to warn users') -parser.add_argument('--output', type=str, required=True, - help='File in which to store the license') - -if __name__ == "__main__": - args = parser.parse_args() - print ('Creating license for %s users for %s days in file: %s' % - (args.users, args.days, args.output)) - - license_data = { - 'LICENSE_EXPIRATION': datetime.utcnow() + timedelta(days=args.days), - 'LICENSE_USER_LIMIT': args.users, - 'LICENSE_EXPIRATION_WARNING': datetime.utcnow() + timedelta(days=(args.days - args.warn)), - } - - encrypt(pickle.dumps(license_data, 2), args.output) diff --git a/util/expiration.py b/util/expiration.py deleted file mode 100644 index 3a58885c9..000000000 --- a/util/expiration.py +++ /dev/null @@ -1,55 +0,0 @@ -import calendar -import sys - -from email.utils import formatdate -from apscheduler.schedulers.background import BackgroundScheduler -from datetime import datetime, timedelta - -from data import model - - -class ExpirationScheduler(object): - def __init__(self, utc_create_notifications_date, utc_terminate_processes_date): - self._scheduler = BackgroundScheduler() - self._termination_date = utc_terminate_processes_date - - soon = datetime.now() + timedelta(seconds=1) - - if utc_create_notifications_date > datetime.utcnow(): - self._scheduler.add_job(model.delete_all_notifications_by_kind, 'date', run_date=soon, - args=['expiring_license']) - - local_notifications_date = self._utc_to_local(utc_create_notifications_date) - self._scheduler.add_job(self._generate_notifications, 'date', - run_date=local_notifications_date) - else: - self._scheduler.add_job(self._generate_notifications, 'date', run_date=soon) - - local_termination_date = self._utc_to_local(utc_terminate_processes_date) - self._scheduler.add_job(self._terminate, 'date', run_date=local_termination_date) - - @staticmethod - def _format_date(date): - """ Output an RFC822 date format. """ - if date is None: - return None - return formatdate(calendar.timegm(date.utctimetuple())) - - @staticmethod - def _utc_to_local(utc_dt): - # get integer timestamp to avoid precision lost - timestamp = calendar.timegm(utc_dt.timetuple()) - local_dt = datetime.fromtimestamp(timestamp) - return local_dt.replace(microsecond=utc_dt.microsecond) - - def _generate_notifications(self): - for user in model.get_active_users(): - model.create_unique_notification('expiring_license', user, - {'expires_at': self._format_date(self._termination_date)}) - - @staticmethod - def _terminate(): - sys.exit(1) - - def start(self): - self._scheduler.start() From d2880807b2369b8da4be20c7246f79fe4768ae3c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 21 Aug 2014 19:21:20 -0400 Subject: [PATCH 017/160] - Further fixes for license stuff - Small fixes to ensure Quay works for Postgres --- data/database.py | 4 ++- ...670cbeced_migrate_existing_webhooks_to_.py | 8 ++--- .../5a07499ce53f_set_up_initial_database.py | 4 +-- data/model/legacy.py | 3 +- endpoints/api/superuser.py | 18 ----------- requirements-nover.txt | 1 + requirements.txt | 1 + static/js/controllers.js | 30 +------------------ static/partials/super-user.html | 16 +--------- test/test_api_security.py | 2 +- test/test_api_usage.py | 2 +- 11 files changed, 16 insertions(+), 73 deletions(-) diff --git a/data/database.py b/data/database.py index 76a0af9df..349ad1b58 100644 --- a/data/database.py +++ b/data/database.py @@ -17,6 +17,8 @@ SCHEME_DRIVERS = { 'mysql': MySQLDatabase, 'mysql+pymysql': MySQLDatabase, 'sqlite': SqliteDatabase, + 'postgresql': PostgresqlDatabase, + 'postgresql+psycopg2': PostgresqlDatabase, } db = Proxy() @@ -32,7 +34,7 @@ def _db_from_url(url, db_kwargs): if parsed_url.username: db_kwargs['user'] = parsed_url.username if parsed_url.password: - db_kwargs['passwd'] = parsed_url.password + db_kwargs['password'] = parsed_url.password return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs) diff --git a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py index 6f516e9b9..726145167 100644 --- a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py +++ b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py @@ -20,12 +20,12 @@ def get_id(query): def upgrade(): conn = op.get_bind() - event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') - method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') + event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1') + method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1') conn.execute('Insert Into repositorynotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM webhook' % (event_id, method_id)) def downgrade(): conn = op.get_bind() - event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') - method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') + event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1') + method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1') conn.execute('Insert Into webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM repositorynotification Where event_id=%s And method_id=%s' % (event_id, method_id)) diff --git a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py index 23aaf506a..ffc9d28e6 100644 --- a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py +++ b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py @@ -203,7 +203,7 @@ def upgrade(): sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('service_id', sa.Integer(), nullable=False), - sa.Column('service_ident', sa.String(length=255, collation='utf8_general_ci'), nullable=False), + sa.Column('service_ident', sa.String(length=255), nullable=False), sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint('id') @@ -375,7 +375,7 @@ def upgrade(): sa.Column('command', sa.Text(), nullable=True), sa.Column('repository_id', sa.Integer(), nullable=False), sa.Column('image_size', sa.BigInteger(), nullable=True), - sa.Column('ancestors', sa.String(length=60535, collation='latin1_swedish_ci'), nullable=True), + sa.Column('ancestors', sa.String(length=60535), nullable=True), sa.Column('storage_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ), sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ), diff --git a/data/model/legacy.py b/data/model/legacy.py index 9feea0738..f415a9d38 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -69,8 +69,7 @@ class TooManyUsersException(DataModelException): def is_create_user_allowed(): - return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] - + return True def create_user(username, password, email): """ Creates a regular user, if allowed. """ diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 3ade5f1ed..5a117289b 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -42,24 +42,6 @@ class SuperUserLogs(ApiResource): abort(403) -@resource('/v1/superuser/seats') -@internal_only -@show_if(features.SUPER_USERS) -@hide_if(features.BILLING) -class SeatUsage(ApiResource): - """ Resource for managing the seats granted in the license for the system. """ - @nickname('getSeatCount') - def get(self): - """ Returns the current number of seats being used in the system. """ - if SuperUserPermission().can(): - return { - 'count': model.get_active_user_count(), - 'allowed': app.config.get('LICENSE_USER_LIMIT', 0) - } - - abort(403) - - def user_view(user): return { 'username': user.username, diff --git a/requirements-nover.txt b/requirements-nover.txt index c0979629b..45a086a8d 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -32,5 +32,6 @@ raven python-ldap pycrypto logentries +psycopg2 git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git diff --git a/requirements.txt b/requirements.txt index 090ade690..4afd0e97b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,6 +44,7 @@ python-dateutil==2.2 python-ldap==2.4.15 python-magic==0.4.6 pytz==2014.4 +psycopg2==2.5.3 raven==5.0.0 redis==2.10.1 reportlab==2.7 diff --git a/static/js/controllers.js b/static/js/controllers.js index aa18c1b40..6c383d5d2 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2699,35 +2699,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { }, ApiService.errorDisplay('Cannot delete user')); }; - var seatUsageLoaded = function(usage) { - $scope.usageLoading = false; - - if (usage.count > usage.allowed) { - $scope.limit = 'over'; - } else if (usage.count == usage.allowed) { - $scope.limit = 'at'; - } else if (usage.count >= usage.allowed * 0.7) { - $scope.limit = 'near'; - } else { - $scope.limit = 'none'; - } - - if (!$scope.chart) { - $scope.chart = new UsageChart(); - $scope.chart.draw('seat-usage-chart'); - } - - $scope.chart.update(usage.count, usage.allowed); - }; - - var loadSeatUsage = function() { - $scope.usageLoading = true; - ApiService.getSeatCount().then(function(resp) { - seatUsageLoaded(resp); - }); - }; - - loadSeatUsage(); + $scope.loadUsers(); } function TourCtrl($scope, $location) { diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 64b043331..bc21f5c94 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -8,9 +8,6 @@
    @@ -19,19 +16,8 @@
    - -
    -
    - -
    -
    - Seat Usage -
    - -
    - -
    +
    {{ usersError }} diff --git a/test/test_api_security.py b/test/test_api_security.py index 5b3e5612d..7f70d0af6 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -37,7 +37,7 @@ from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repos from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, RepositoryTeamPermissionList, RepositoryUserPermissionList) -from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement +from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement try: diff --git a/test/test_api_usage.py b/test/test_api_usage.py index c91005c5c..b113f27d0 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -40,7 +40,7 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember, from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, RepositoryTeamPermissionList, RepositoryUserPermissionList) -from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement +from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement try: app.register_blueprint(api_bp, url_prefix='/api') From b51022c73945eedd70df4db70f1d8869b10c9f5e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 21 Aug 2014 20:36:11 -0400 Subject: [PATCH 018/160] Add support for parsing YAML override config, in addition to Python config --- app.py | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 92a2dacc1..81c59a30c 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,9 @@ import logging import os import json +import yaml -from flask import Flask +from flask import Flask as BaseFlask, Config as BaseConfig from flask.ext.principal import Principal from flask.ext.login import LoginManager from flask.ext.mail import Mail @@ -24,7 +25,34 @@ from data.userevent import UserEventsBuilderModule from datetime import datetime -OVERRIDE_CONFIG_FILENAME = 'conf/stack/config.py' +class Config(BaseConfig): + """ Flask config enhanced with a `from_yamlfile` method """ + + def from_yamlfile(self, config_file): + with open(config_file) as f: + c = yaml.load(f) + if not c: + logger.debug('Empty YAML config file') + return + + if isinstance(c, str): + raise Exception('Invalid YAML config file: ' + str(c)) + + for key in c.iterkeys(): + if key.isupper(): + self[key] = c[key] + +class Flask(BaseFlask): + """ Extends the Flask class to implement our custom Config class. """ + + def make_config(self, instance_relative=False): + root_path = self.instance_path if instance_relative else self.root_path + return Config(root_path, self.default_config) + + +OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' +OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py' + OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' LICENSE_FILENAME = 'conf/stack/license.enc' @@ -42,9 +70,13 @@ else: logger.debug('Loading default config.') app.config.from_object(DefaultConfig()) - if os.path.exists(OVERRIDE_CONFIG_FILENAME): - logger.debug('Applying config file: %s', OVERRIDE_CONFIG_FILENAME) - app.config.from_pyfile(OVERRIDE_CONFIG_FILENAME) + if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME): + logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_FILENAME) + app.config.from_pyfile(OVERRIDE_CONFIG_PY_FILENAME) + + if os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME): + logger.debug('Applying config file: %s', OVERRIDE_CONFIG_YAML_FILENAME) + app.config.from_yamlfile(OVERRIDE_CONFIG_YAML_FILENAME) environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) app.config.update(environ_config) From 5b3514b49cc8e5cc626f34e71a4bd2d956f87757 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 21 Aug 2014 20:38:30 -0400 Subject: [PATCH 019/160] Add missing pyyaml dependency --- requirements-nover.txt | 1 + requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements-nover.txt b/requirements-nover.txt index 45a086a8d..a3c74e89b 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -33,5 +33,6 @@ python-ldap pycrypto logentries psycopg2 +pyyaml git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git diff --git a/requirements.txt b/requirements.txt index 4afd0e97b..e454e6846 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ Pillow==2.5.1 PyGithub==1.25.0 PyMySQL==0.6.2 PyPDF2==1.22 +PyYAML==3.11 SQLAlchemy==0.9.7 Werkzeug==0.9.6 alembic==0.6.5 From 2a3094cfdef56718ff7bcd5bda3e3f7ca606da16 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 22 Aug 2014 15:24:56 -0400 Subject: [PATCH 020/160] - Fix zero clipboard integration to properly hide the clipboard controls when flash is not available. - Hide the download .dockercfg link in Safari, since it doesn't work there anyway --- static/css/quay.css | 8 +++ static/directives/copy-box.html | 2 +- static/directives/docker-auth-dialog.html | 2 +- static/js/app.js | 68 +++++++++++++++------- static/js/controllers.js | 27 --------- static/lib/ZeroClipboard.min.js | 17 +++--- static/lib/ZeroClipboard.swf | Bin 1635 -> 4036 bytes static/partials/view-repo.html | 9 +-- 8 files changed, 66 insertions(+), 67 deletions(-) mode change 100755 => 100644 static/lib/ZeroClipboard.min.js mode change 100755 => 100644 static/lib/ZeroClipboard.swf diff --git a/static/css/quay.css b/static/css/quay.css index 01fe84e60..e0f3d2a20 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -2257,6 +2257,14 @@ p.editable:hover i { position: relative; } +.copy-box-element.disabled .input-group-addon { + display: none; +} + +.copy-box-element.disabled input { + border-radius: 4px !important; +} + .global-zeroclipboard-container embed { cursor: pointer; } diff --git a/static/directives/copy-box.html b/static/directives/copy-box.html index 1d996cc31..07dea7407 100644 --- a/static/directives/copy-box.html +++ b/static/directives/copy-box.html @@ -1,4 +1,4 @@ -
    +
    diff --git a/static/directives/docker-auth-dialog.html b/static/directives/docker-auth-dialog.html index dcb71a25b..b7a414725 100644 --- a/static/directives/docker-auth-dialog.html +++ b/static/directives/docker-auth-dialog.html @@ -19,7 +19,7 @@ Download .dockercfg file -
    From 34c6d7f5b4d568ded05f432f80dd3a1701f98cb5 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 22 Aug 2014 16:54:53 -0400 Subject: [PATCH 021/160] Change the auth dialog to copy a full docker login command --- static/directives/docker-auth-dialog.html | 5 +++-- static/js/app.js | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/static/directives/docker-auth-dialog.html b/static/directives/docker-auth-dialog.html index b7a414725..33b4af8cd 100644 --- a/static/directives/docker-auth-dialog.html +++ b/static/directives/docker-auth-dialog.html @@ -20,9 +20,10 @@ Download .dockercfg file - + +
    diff --git a/static/js/app.js b/static/js/app.js index 250665f60..aa9d8bcba 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2383,7 +2383,15 @@ quayApp.directive('dockerAuthDialog', function (Config) { 'shown': '=shown', 'counter': '=counter' }, - controller: function($scope, $element) { + controller: function($scope, $element) { + var updateCommand = function() { + $scope.command = 'docker login -e="." -u="' + $scope.username + + '" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME']; + }; + + $scope.$watch('username', updateCommand); + $scope.$watch('token', updateCommand); + $scope.isDownloadSupported = function() { var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent); if (isSafari) { From 4140e115e532fcfe57f53faeea8accbfb10fe064 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 22 Aug 2014 18:03:22 -0400 Subject: [PATCH 022/160] Put building behind a feature flag --- config.py | 3 +++ static/partials/repo-admin.html | 5 +++-- static/partials/view-repo.html | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index a903fa29a..3712055d2 100644 --- a/config.py +++ b/config.py @@ -153,6 +153,9 @@ class DefaultConfig(object): # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False + # Feature Flag: Dockerfile build support. + FEATURE_BUILD_SUPPORT = True + DISTRIBUTED_STORAGE_CONFIG = { 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index b3ef4b51b..6bd329091 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -18,7 +18,8 @@ -
    +
    Build Triggers diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index 03ce2909c..4f588ccf2 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -18,7 +18,7 @@ +
    + + Invoice History + + +
    @@ -48,13 +55,6 @@
    -
    - - Invoice History - - -
    @@ -81,7 +81,7 @@
    -
    +
    @@ -93,9 +93,9 @@
    SSL Encryption
    Robot accounts
    Dockerfile Build
    +
    Invoice History
    Teams
    Logging
    -
    Invoice History
    Free Trial
    diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 783c5f87a..1b2ad7fd1 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -25,7 +25,7 @@
  • Billing Options
  • -
  • +
  • Billing History
  • From 09a1c4d2b5d054e2185f5a09b8d5242c1cdd5c2f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 25 Aug 2014 14:23:21 -0400 Subject: [PATCH 026/160] Add test fix and make sure Quay ups the connection count in its container --- Dockerfile.web | 1 + conf/init/doupdatelimits.sh | 5 +++++ endpoints/index.py | 6 +++++- test/specs.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100755 conf/init/doupdatelimits.sh diff --git a/Dockerfile.web b/Dockerfile.web index 56b126d53..448a7f748 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -30,6 +30,7 @@ RUN cd grunt && npm install RUN cd grunt && grunt ADD conf/init/svlogd_config /svlogd_config +ADD conf/init/doupdatelimits.sh /etc/my_init.d/ ADD conf/init/preplogsdir.sh /etc/my_init.d/ ADD conf/init/runmigration.sh /etc/my_init.d/ diff --git a/conf/init/doupdatelimits.sh b/conf/init/doupdatelimits.sh new file mode 100755 index 000000000..603559de0 --- /dev/null +++ b/conf/init/doupdatelimits.sh @@ -0,0 +1,5 @@ +#! /bin/bash +set -e + +# Update the connection limit +sysctl -w net.core.somaxconn=1024 \ No newline at end of file diff --git a/endpoints/index.py b/endpoints/index.py index bf37e14b5..4017d47e9 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -428,7 +428,11 @@ def get_search(): if user is not None: username = user.username - matching = model.get_matching_repositories(query, username) + if query: + matching = model.get_matching_repositories(query, username) + else: + matching = [] + results = [result_view(repo) for repo in matching if (repo.visibility.name == 'public' or ReadRepositoryPermission(repo.namespace, repo.name).can())] diff --git a/test/specs.py b/test/specs.py index 33db0493e..8749a025e 100644 --- a/test/specs.py +++ b/test/specs.py @@ -196,7 +196,7 @@ def build_index_specs(): IndexTestSpec(url_for('index.put_repository_auth', repository=PUBLIC_REPO), NO_REPO, 501, 501, 501, 501).set_method('PUT'), - IndexTestSpec(url_for('index.get_search'), NO_REPO, 501, 501, 501, 501), + IndexTestSpec(url_for('index.get_search'), NO_REPO, 200, 200, 200, 200), IndexTestSpec(url_for('index.ping'), NO_REPO, 200, 200, 200, 200), From 99d75bede763f0f4daa2ff1f2383699752395366 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 25 Aug 2014 15:30:29 -0400 Subject: [PATCH 027/160] Handle error cases better for external services --- endpoints/api/billing.py | 32 ++++++++++++++++++++++++++------ endpoints/api/subscribe.py | 21 ++++++++++++++++++--- static/js/app.js | 4 ++-- templates/index.html | 11 +++-------- util/analytics.py | 6 +++++- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 3e13df6b6..c41bcec77 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -4,7 +4,7 @@ from flask import request from app import billing from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, show_if, hide_if) + require_user_admin, show_if, hide_if, abort) from endpoints.api.subscribe import subscribe, subscription_view from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user @@ -23,7 +23,11 @@ def get_card(user): } if user.stripe_id: - cus = billing.Customer.retrieve(user.stripe_id) + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.APIConnectionError as e: + abort(503, message='Cannot contact Stripe') + if cus and cus.default_card: # Find the default card. default_card = None @@ -46,7 +50,11 @@ def get_card(user): def set_card(user, token): if user.stripe_id: - cus = billing.Customer.retrieve(user.stripe_id) + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.APIConnectionError as e: + abort(503, message='Cannot contact Stripe') + if cus: try: cus.card = token @@ -55,6 +63,8 @@ def set_card(user, token): return carderror_response(exc) except stripe.InvalidRequestError as exc: return carderror_response(exc) + except stripe.APIConnectionError as e: + return carderror_response(e) return get_card(user) @@ -75,7 +85,11 @@ def get_invoices(customer_id): 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None } - invoices = billing.Invoice.all(customer=customer_id, count=12) + try: + invoices = billing.Invoice.all(customer=customer_id, count=12) + except stripe.APIConnectionError as e: + abort(503, message='Cannot contact Stripe') + return { 'invoices': [invoice_view(i) for i in invoices.data] } @@ -228,7 +242,10 @@ class UserPlan(ApiResource): private_repos = model.get_private_repo_count(user.username) if user.stripe_id: - cus = billing.Customer.retrieve(user.stripe_id) + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.APIConnectionError as e: + abort(503, message='Cannot contact Stripe') if cus.subscription: return subscription_view(cus.subscription, private_repos) @@ -291,7 +308,10 @@ class OrganizationPlan(ApiResource): private_repos = model.get_private_repo_count(orgname) organization = model.get_organization(orgname) if organization.stripe_id: - cus = billing.Customer.retrieve(organization.stripe_id) + try: + cus = billing.Customer.retrieve(organization.stripe_id) + except stripe.APIConnectionError as e: + abort(503, message='Cannot contact Stripe') if cus.subscription: return subscription_view(cus.subscription, private_repos) diff --git a/endpoints/api/subscribe.py b/endpoints/api/subscribe.py index dd6de9678..2c3fba359 100644 --- a/endpoints/api/subscribe.py +++ b/endpoints/api/subscribe.py @@ -15,6 +15,9 @@ logger = logging.getLogger(__name__) def carderror_response(exc): return {'carderror': exc.message}, 402 +def connection_response(exc): + return {'message': 'Could not contact Stripe. Please try again.'}, 503 + def subscription_view(stripe_subscription, used_repos): view = { @@ -74,19 +77,29 @@ def subscribe(user, plan, token, require_business_plan): log_action('account_change_plan', user.username, {'plan': plan}) except stripe.CardError as e: return carderror_response(e) + except stripe.APIConnectionError as e: + return connection_response(e) response_json = subscription_view(cus.subscription, private_repos) status_code = 201 else: # Change the plan - cus = billing.Customer.retrieve(user.stripe_id) + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.APIConnectionError as e: + return connection_response(e) if plan_found['price'] == 0: if cus.subscription is not None: # We only have to cancel the subscription if they actually have one - cus.cancel_subscription() - cus.save() + try: + cus.cancel_subscription() + cus.save() + except stripe.APIConnectionError as e: + return connection_response(e) + + check_repository_usage(user, plan_found) log_action('account_change_plan', user.username, {'plan': plan}) @@ -101,6 +114,8 @@ def subscribe(user, plan, token, require_business_plan): cus.save() except stripe.CardError as e: return carderror_response(e) + except stripe.APIConnectionError as e: + return connection_response(e) response_json = subscription_view(cus.subscription, private_repos) check_repository_usage(user, plan_found) diff --git a/static/js/app.js b/static/js/app.js index aa9d8bcba..c718375fe 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5570,8 +5570,8 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi } } - if (!Features.BILLING && response.status == 402) { - $('#overlicenseModal').modal({}); + if (response.status == 503) { + $('#cannotContactService').modal({}); return false; } diff --git a/templates/index.html b/templates/index.html index f69251514..51d687770 100644 --- a/templates/index.html +++ b/templates/index.html @@ -35,23 +35,18 @@ -{% if not has_billing %} -
    + +
    +
    +
    + + +
    + +
    +
    + + +
    + +
    + + +
    + + +
    + +
    + + + + + + + + + + + + +
    UsernameE-mail addressTemporary Password
    {{ created_user.username }}{{ created_user.email }}{{ created_user.password }}
    +
    +
    +
    {{ usersError }} -
    +
    @@ -37,8 +83,7 @@ Username E-mail address - - + {{ current_user.email }} - - Super user - - -