From 2597bcef3fc330457a6818362ac4b99e7dbec8cc Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 11 Aug 2014 15:47:44 -0400
Subject: [PATCH 01/57] 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 @@
+<span class="external-login-button-element">
+  <span ng-if="provider == 'github'">
+    <a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GITHUB_LOGIN']" ng-click="startSignin('github')" style="margin-bottom: 10px">
+      <i class="fa fa-github fa-lg"></i> 
+      <span ng-if="action != 'attach'">Sign In with GitHub</span>
+      <span ng-if="action == 'attach'">Attach to GitHub Account</span>
+    </a>
+  </span>
+  
+  <span ng-if="provider == 'google'">
+    <a href="javascript:void(0)" class="btn btn-primary btn-block" quay-require="['GOOGLE_LOGIN']"  ng-click="startSignin('google')">
+      <i class="fa fa-google fa-lg"></i> 
+      <span ng-if="action != 'attach'">Sign In with Google</span>
+      <span ng-if="action == 'attach'">Attach to Google Account</span>
+    </a>
+  </span>
+</span>
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 @@
       <span class="inner-text">OR</span>
     </span>
 
-    <a id="github-signin-link" class="btn btn-primary btn-lg btn-block" href="javascript:void(0)" ng-click="showGithub()"
-       quay-require="['GITHUB_LOGIN']">
-      <i class="fa fa-github fa-lg"></i> Sign In with GitHub
-    </a>
+    <div class="external-login-button" provider="github" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
+    <div class="external-login-button" provider="google" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
   </form> 
 
   <div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
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 @@
         <i class="fa fa-circle"></i>
         <span class="inner-text">OR</span>
       </span>
-      <a href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ github_state_clause }}"
-         class="btn btn-primary btn-block" quay-require="['GITHUB_LOGIN']">
-        <i class="fa fa-github fa-lg"></i> Sign In with GitHub
-      </a>
+      <div class="external-login-button" provider="github"></div>
+      <div class="external-login-button" provider="google"></div>
       <p class="help-block" quay-require="['BILLING']">No credit card required.</p>
     </div>
   </form>
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 @@
         <li quay-classes="{'!Features.BILLING': 'active'}"><a href="javascript:void(0)" data-toggle="tab" data-target="#email">Account E-mail</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Change Password</a></li>
-        <li><a href="javascript:void(0)" data-toggle="tab" data-target="#github" quay-require="['GITHUB_LOGIN']">GitHub Login</a></li>
+        <li><a href="javascript:void(0)" data-toggle="tab" data-target="#external" quay-show="Features.GITHUB_LOGIN || Features.GOOGLE_LOGIN">External Logins</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#authorized" ng-click="loadAuthedApps()">Authorized Applications</a></li>
         <li quay-show="Features.USER_LOG_ACCESS || hasPaidBusinessPlan">
           <a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a>
@@ -162,12 +162,14 @@
           </div>
         </div>
 
-        <!-- Github tab -->
-        <div id="github" class="tab-pane" quay-require="['GITHUB_LOGIN']">
+        <!-- External Login tab -->
+        <div id="external" class="tab-pane" quay-show="Features.GITHUB_LOGIN || Features.GOOGLE_LOGIN">
           <div class="loading" ng-show="!cuser">
             <div class="quay-spinner 3x"></div>
           </div>
-          <div class="row" ng-show="cuser">
+          
+          <!-- Github -->
+          <div class="row" quay-show="cuser && Features.GITHUB_LOGIN">
             <div class="panel">
               <div class="panel-title">GitHub Login:</div>
               <div class="panel-body">
@@ -175,12 +177,28 @@
                   <i class="fa fa-github fa-lg" style="margin-right: 6px;" data-title="GitHub" bs-tooltip="tooltip.title"></i>
                   <b><a href="https://github.com/{{githubLogin}}" target="_blank">{{githubLogin}}</a></b>
                 </div>
-                <div ng-show="!githubLogin" class="col-md-8">
-                  <a href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ github_state_clause }}&redirect_uri={{ githubRedirectUri }}/attach" class="btn btn-primary"><i class="fa fa-github fa-lg"></i> Connect with GitHub</a>
+                <div ng-show="!githubLogin" class="col-md-4">
+                  <span class="external-login-button" provider="github" action="attach"></span>
                 </div>
               </div>
             </div>
           </div>
+
+          <!-- Google -->
+          <div class="row" quay-show="cuser && Features.GOOGLE_LOGIN">
+            <div class="panel">
+              <div class="panel-title">Google Login:</div>
+              <div class="panel-body">
+                <div ng-show="hasGoogleLogin" class="lead col-md-8">
+                  Account tied to your Google account.
+                </div>
+                <div ng-show="!hasGoogleLogin" class="col-md-4">
+                  <span class="external-login-button" provider="google" action="attach"></span>
+                </div>
+              </div>
+            </div>
+          </div>
+
         </div>
         
         <!-- Robot accounts tab -->
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 %}
-  <title>Error Logging in with GitHub · Quay.io</title>
+  <title>Error Logging in with {{ service_name }} · Quay.io</title>
 {% endblock %}
 
 {% block body_content %}
   <div class="container">
     <div class="row">
       <div class="col-md-12">
-        <h2>There was an error logging in with GitHub.</h2>
+        <h2>There was an error logging in with {{ service_name }}.</h2>
 
         {% if error_message %}
           <div class="alert alert-danger">{{ error_message }}</div>
@@ -16,11 +16,11 @@
 
         <div>
           Please register using the <a href="/">registration form</a> 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.
         </div>
       </div>
     </div>
 
   </div>
-{% endblock %}
\ No newline at end of file
+{% endblock %}

From 389c88a7c4b2b08b879b772a0deca139e004ef3e Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 11 Aug 2014 18:25:01 -0400
Subject: [PATCH 02/57] 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 @@
             <div class="panel">
               <div class="panel-title">GitHub Login:</div>
               <div class="panel-body">
-                <div ng-show="githubLogin" class="lead col-md-8">
+                <div ng-show="hasGithubLogin && githubLogin" class="lead col-md-8">
                   <i class="fa fa-github fa-lg" style="margin-right: 6px;" data-title="GitHub" bs-tooltip="tooltip.title"></i>
                   <b><a href="https://github.com/{{githubLogin}}" target="_blank">{{githubLogin}}</a></b>
                 </div>
-                <div ng-show="!githubLogin" class="col-md-4">
+                <div ng-show="hasGithubLogin && !githubLogin" class="lead col-md-8">
+                  <i class="fa fa-github fa-lg" style="margin-right: 6px;" data-title="GitHub" bs-tooltip="tooltip.title"></i>
+                  Account attached to Github Account
+                </div>
+                <div ng-show="!hasGithubLogin" class="col-md-4">
                   <span class="external-login-button" provider="github" action="attach"></span>
                 </div>
               </div>
@@ -189,8 +193,13 @@
             <div class="panel">
               <div class="panel-title">Google Login:</div>
               <div class="panel-body">
-                <div ng-show="hasGoogleLogin" class="lead col-md-8">
-                  Account tied to your Google account.
+                <div ng-show="hasGoogleLogin && googleLogin" class="lead col-md-8">
+                  <i class="fa fa-google fa-lg" style="margin-right: 6px;" data-title="Google" bs-tooltip="tooltip.title"></i>
+                  <b>{{ googleLogin }}</b>
+                </div>
+                <div ng-show="hasGoogleLogin && !googleLogin" class="lead col-md-8">
+                  <i class="fa fa-google fa-lg" style="margin-right: 6px;" data-title="Google" bs-tooltip="tooltip.title"></i>
+                  Account attached to Google Account
                 </div>
                 <div ng-show="!hasGoogleLogin" class="col-md-4">
                   <span class="external-login-button" provider="google" action="attach"></span>

From 11176215e10a069045892420a2c41c6492fe11af Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 11 Aug 2014 18:35:26 -0400
Subject: [PATCH 03/57] 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 61cb9d46f72eacdbf8072ce8a5406d60329d3180 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 21 Aug 2014 15:12:43 -0400
Subject: [PATCH 04/57] 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 <jschorr@gmail.com>
Date: Thu, 21 Aug 2014 17:44:56 -0400
Subject: [PATCH 05/57] 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<hBbTTt<pTmNY;orEt
z4h~hyBM81`<t*;QRbI=AaM$H8<?16R?$PDL&n&#{zSZTaP9+X`>_tuMyRY{tU1zG=
z6Q6?9vZzcIxg9}$d9={m<620>`3cvJtq<xx4^8AfZ@yZ3EALu2EopR4RZViMzSYf3
zgVUww9C7_EX{-8S)7qaV3qjiT%6s1FFAVMYIOK6&w$B%IwSMV)<Vx>+@4o1qZF*ea
z(ppgQF0o?2MEQ}n+J2k8b4@*FjR<S0uWChbc5tTi``Em<l`I~3E!25l7JnGyVYd92
zTkf4RU+Fx#?LwJ<|LIWd;6ef_-!!)OE|!EUV*HZ^`!09Gi@j6(W?)^gRvs@sRes0w
zvwEp^ZVwdo<o8DlIv#?;I?e$Bku}SNl7-TOq8~+|01*x-VQm~Dkmg_<e@&1wMw2Em
zRR-e(QXE9gkRlMVKpKzGlfXC+y*R?|KAHuQY)Ij_2y90RpM%ZBi>v@zFpLNeD}ul@
z$}~}p>Qsx1l(MR#2FjQgK#j>!<O7hSqLz$N!>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<Dm-7Lh}(?*lpCBBk>@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 <jschorr@gmail.com>
Date: Thu, 21 Aug 2014 19:21:20 -0400
Subject: [PATCH 06/57] - 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 @@
     <div class="col-md-2">
       <ul class="nav nav-pills nav-stacked">
         <li class="active">
-          <a href="javascript:void(0)" data-toggle="tab" data-target="#license">License and Usage</a>
-        </li>
-        <li>
           <a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a>
         </li>
       </ul>
@@ -19,19 +16,8 @@
     <!-- Content -->
     <div class="col-md-10">
       <div class="tab-content">
-        <!-- License tab -->
-        <div id="license" class="tab-pane active">
-          <div class="quay-spinner 3x" ng-show="usageLoading"></div>
-           <!-- Chart -->
-           <div>
-             <div id="seat-usage-chart" class="usage-chart limit-{{limit}}"></div>
-             <span class="usage-caption" ng-show="chart">Seat Usage</span>
-           </div>
-
-        </div>
-
         <!-- Users tab -->
-        <div id="users" class="tab-pane">
+        <div id="users" class="tab-pane active">
           <div class="quay-spinner" ng-show="!users"></div>
           <div class="alert alert-error" ng-show="usersError">
             {{ 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 <jschorr@gmail.com>
Date: Thu, 21 Aug 2014 20:36:11 -0400
Subject: [PATCH 07/57] 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 <jschorr@gmail.com>
Date: Thu, 21 Aug 2014 20:38:30 -0400
Subject: [PATCH 08/57] 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 <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 15:24:56 -0400
Subject: [PATCH 09/57] - 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 @@
-<div class="copy-box-element">
+<div class="copy-box-element" ng-class="disabled ? 'disabled' : ''">
   <div class="id-container">
     <div class="input-group">
       <input type="text" class="form-control" value="{{ value }}" readonly>
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 @@
            <i class="fa fa-download"></i>
            <a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
          </span>
-         <div id="clipboardCopied" style="display: none">
+         <div class="clipboard-copied-message" style="display: none">
            Copied to clipboard
          </div>
          <button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button>
diff --git a/static/js/app.js b/static/js/app.js
index ad6527fc1..250665f60 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,6 +1,42 @@
 var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$';
 var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$';
 
+$.fn.clipboardCopy = function() { 
+  if (zeroClipboardSupported) {
+    (new ZeroClipboard($(this),  { 'moviePath': 'static/lib/ZeroClipboard.swf' }));
+    return true;
+  }
+
+  this.hide();
+  return false;
+};
+
+var zeroClipboardSupported = true;
+ZeroClipboard.on("error", function(e) {
+  zeroClipboardSupported = false;  
+});
+
+ZeroClipboard.on('aftercopy', function(e) {
+  var container = e.target.parentNode.parentNode.parentNode;
+  var message = $(container).find('.clipboard-copied-message')[0];
+
+  // Resets the animation.
+  var elem = message;
+  elem.style.display = 'none';
+  elem.classList.remove('animated');
+
+  // Show the notification.
+  setTimeout(function() {
+    elem.style.display = 'inline-block';
+    elem.classList.add('animated');
+  }, 10);
+
+  // Reset the notification.
+  setTimeout(function() {
+    elem.style.display = 'none';
+  }, 5000);
+});
+
 function getRestUrl(args) {
   var url = '';
   for (var i = 0; i < arguments.length; ++i) {
@@ -2106,6 +2142,8 @@ quayApp.directive('copyBox', function () {
       'hoveringMessage': '=hoveringMessage'
     },
     controller: function($scope, $element, $rootScope) {
+      $scope.disabled = false;
+
       var number = $rootScope.__copyBoxIdCounter || 0;
       $rootScope.__copyBoxIdCounter = number + 1;
       $scope.inputId = "copy-box-input-" + number;
@@ -2115,27 +2153,7 @@ quayApp.directive('copyBox', function () {
 
       input.attr('id', $scope.inputId);
       button.attr('data-clipboard-target', $scope.inputId);
-
-      var clip = new ZeroClipboard($(button),  { 'moviePath': 'static/lib/ZeroClipboard.swf' });  
-      clip.on('complete', function(e) {
-        var message = $(this.parentNode.parentNode.parentNode).find('.clipboard-copied-message')[0];
-
-        // Resets the animation.
-        var elem = message;
-        elem.style.display = 'none';
-        elem.classList.remove('animated');
-
-        // Show the notification.
-        setTimeout(function() {
-          elem.style.display = 'inline-block';
-          elem.classList.add('animated');
-        }, 10);
-
-        // Reset the notification.
-        setTimeout(function() {
-          elem.style.display = 'none';
-        }, 5000);
-      });
+      $scope.disabled = !button.clipboardCopy();
     }
   };
   return directiveDefinitionObject;
@@ -2367,7 +2385,13 @@ quayApp.directive('dockerAuthDialog', function (Config) {
     },
     controller: function($scope, $element) {     
       $scope.isDownloadSupported = function() {
-        try { return !!new Blob(); } catch(e){}
+        var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
+        if (isSafari) {
+          // Doesn't work properly in Safari, sadly.
+          return false;
+        }
+
+        try { return !!new Blob(); } catch(e) {}
         return false;
       };
 
diff --git a/static/js/controllers.js b/static/js/controllers.js
index 6c383d5d2..f20ff8562 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -1,25 +1,3 @@
-$.fn.clipboardCopy = function() { 
-  var clip = new ZeroClipboard($(this),  { 'moviePath': 'static/lib/ZeroClipboard.swf' });  
-
-  clip.on('complete', function() {
-    // Resets the animation.
-    var elem = $('#clipboardCopied')[0];
-    if (!elem) {
-      return;
-    }
-
-    elem.style.display = 'none';
-    elem.classList.remove('animated');
-
-    // Show the notification.
-    setTimeout(function() {
-      if (!elem) { return; }
-      elem.style.display = 'inline-block';
-      elem.classList.add('animated');
-    }, 10);
-  });
-};
-
 function GuideCtrl() {
 }
 
@@ -733,8 +711,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
 
       // Load the builds for this repository. If none are active it will cancel the poll.
       startBuildInfoTimer(repo);
-
-      $('#copyClipboard').clipboardCopy();
     });
   };
 
@@ -1901,9 +1877,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
 
       // Fetch the image's changes.
       fetchChanges();
-
-      $('#copyClipboard').clipboardCopy();
-
       return image;
     });
   };
diff --git a/static/lib/ZeroClipboard.min.js b/static/lib/ZeroClipboard.min.js
old mode 100755
new mode 100644
index bfea72566..e8a4a7152
--- a/static/lib/ZeroClipboard.min.js
+++ b/static/lib/ZeroClipboard.min.js
@@ -1,9 +1,10 @@
 /*!
-* ZeroClipboard
-* The ZeroClipboard library provides an easy way to copy text to the clipboard using an invisible Adobe Flash movie and a JavaScript interface.
-* Copyright (c) 2013 Jon Rohan, James M. Greene
-* Licensed MIT
-* http://zeroclipboard.org/
-* v1.2.3
-*/
-!function(){"use strict";var a,b=function(){var a=/\-([a-z])/g,b=function(a,b){return b.toUpperCase()};return function(c){return c.replace(a,b)}}(),c=function(a,c){var d,e,f,g,h,i;if(window.getComputedStyle?d=window.getComputedStyle(a,null).getPropertyValue(c):(e=b(c),d=a.currentStyle?a.currentStyle[e]:a.style[e]),"cursor"===c&&(!d||"auto"===d))for(f=a.tagName.toLowerCase(),g=["a"],h=0,i=g.length;i>h;h++)if(f===g[h])return"pointer";return d},d=function(a){if(p.prototype._singleton){a||(a=window.event);var b;this!==window?b=this:a.target?b=a.target:a.srcElement&&(b=a.srcElement),p.prototype._singleton.setCurrent(b)}},e=function(a,b,c){a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent&&a.attachEvent("on"+b,c)},f=function(a,b,c){a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent&&a.detachEvent("on"+b,c)},g=function(a,b){if(a.addClass)return a.addClass(b),a;if(b&&"string"==typeof b){var c=(b||"").split(/\s+/);if(1===a.nodeType)if(a.className){for(var d=" "+a.className+" ",e=a.className,f=0,g=c.length;g>f;f++)d.indexOf(" "+c[f]+" ")<0&&(e+=" "+c[f]);a.className=e.replace(/^\s+|\s+$/g,"")}else a.className=b}return a},h=function(a,b){if(a.removeClass)return a.removeClass(b),a;if(b&&"string"==typeof b||void 0===b){var c=(b||"").split(/\s+/);if(1===a.nodeType&&a.className)if(b){for(var d=(" "+a.className+" ").replace(/[\n\t]/g," "),e=0,f=c.length;f>e;e++)d=d.replace(" "+c[e]+" "," ");a.className=d.replace(/^\s+|\s+$/g,"")}else a.className=""}return a},i=function(){var a,b,c,d=1;return"function"==typeof document.body.getBoundingClientRect&&(a=document.body.getBoundingClientRect(),b=a.right-a.left,c=document.body.offsetWidth,d=Math.round(100*(b/c))/100),d},j=function(a){var b={left:0,top:0,width:0,height:0,zIndex:999999999},d=c(a,"z-index");if(d&&"auto"!==d&&(b.zIndex=parseInt(d,10)),a.getBoundingClientRect){var e,f,g,h=a.getBoundingClientRect();"pageXOffset"in window&&"pageYOffset"in window?(e=window.pageXOffset,f=window.pageYOffset):(g=i(),e=Math.round(document.documentElement.scrollLeft/g),f=Math.round(document.documentElement.scrollTop/g));var j=document.documentElement.clientLeft||0,k=document.documentElement.clientTop||0;b.left=h.left+e-j,b.top=h.top+f-k,b.width="width"in h?h.width:h.right-h.left,b.height="height"in h?h.height:h.bottom-h.top}return b},k=function(a,b){var c=!(b&&b.useNoCache===!1);return c?(-1===a.indexOf("?")?"?":"&")+"nocache="+(new Date).getTime():""},l=function(a){var b=[],c=[];return a.trustedOrigins&&("string"==typeof a.trustedOrigins?c.push(a.trustedOrigins):"object"==typeof a.trustedOrigins&&"length"in a.trustedOrigins&&(c=c.concat(a.trustedOrigins))),a.trustedDomains&&("string"==typeof a.trustedDomains?c.push(a.trustedDomains):"object"==typeof a.trustedDomains&&"length"in a.trustedDomains&&(c=c.concat(a.trustedDomains))),c.length&&b.push("trustedOrigins="+encodeURIComponent(c.join(","))),"string"==typeof a.amdModuleId&&a.amdModuleId&&b.push("amdModuleId="+encodeURIComponent(a.amdModuleId)),"string"==typeof a.cjsModuleId&&a.cjsModuleId&&b.push("cjsModuleId="+encodeURIComponent(a.cjsModuleId)),b.join("&")},m=function(a,b){if(b.indexOf)return b.indexOf(a);for(var c=0,d=b.length;d>c;c++)if(b[c]===a)return c;return-1},n=function(a){if("string"==typeof a)throw new TypeError("ZeroClipboard doesn't accept query strings.");return a.length?a:[a]},o=function(a,b,c,d,e){e?window.setTimeout(function(){a.call(b,c,d)},0):a.call(b,c,d)},p=function(a,b){if(a&&(p.prototype._singleton||this).glue(a),p.prototype._singleton)return p.prototype._singleton;p.prototype._singleton=this,this.options={};for(var c in s)this.options[c]=s[c];for(var d in b)this.options[d]=b[d];this.handlers={},p.detectFlashSupport()&&v()},q=[];p.prototype.setCurrent=function(b){a=b,this.reposition();var d=b.getAttribute("title");d&&this.setTitle(d);var e=this.options.forceHandCursor===!0||"pointer"===c(b,"cursor");return r.call(this,e),this},p.prototype.setText=function(a){return a&&""!==a&&(this.options.text=a,this.ready()&&this.flashBridge.setText(a)),this},p.prototype.setTitle=function(a){return a&&""!==a&&this.htmlBridge.setAttribute("title",a),this},p.prototype.setSize=function(a,b){return this.ready()&&this.flashBridge.setSize(a,b),this},p.prototype.setHandCursor=function(a){return a="boolean"==typeof a?a:!!a,r.call(this,a),this.options.forceHandCursor=a,this};var r=function(a){this.ready()&&this.flashBridge.setHandCursor(a)};p.version="1.2.3";var s={moviePath:"ZeroClipboard.swf",trustedOrigins:null,text:null,hoverClass:"zeroclipboard-is-hover",activeClass:"zeroclipboard-is-active",allowScriptAccess:"sameDomain",useNoCache:!0,forceHandCursor:!1};p.setDefaults=function(a){for(var b in a)s[b]=a[b]},p.destroy=function(){p.prototype._singleton.unglue(q);var a=p.prototype._singleton.htmlBridge;a.parentNode.removeChild(a),delete p.prototype._singleton},p.detectFlashSupport=function(){var a=!1;if("function"==typeof ActiveXObject)try{new ActiveXObject("ShockwaveFlash.ShockwaveFlash")&&(a=!0)}catch(b){}return!a&&navigator.mimeTypes["application/x-shockwave-flash"]&&(a=!0),a};var t=null,u=null,v=function(){var a,b,c=p.prototype._singleton,d=document.getElementById("global-zeroclipboard-html-bridge");if(!d){var e={};for(var f in c.options)e[f]=c.options[f];e.amdModuleId=t,e.cjsModuleId=u;var g=l(e),h='      <object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" id="global-zeroclipboard-flash-bridge" width="100%" height="100%">         <param name="movie" value="'+c.options.moviePath+k(c.options.moviePath,c.options)+'"/>         <param name="allowScriptAccess" value="'+c.options.allowScriptAccess+'"/>         <param name="scale" value="exactfit"/>         <param name="loop" value="false"/>         <param name="menu" value="false"/>         <param name="quality" value="best" />         <param name="bgcolor" value="#ffffff"/>         <param name="wmode" value="transparent"/>         <param name="flashvars" value="'+g+'"/>         <embed src="'+c.options.moviePath+k(c.options.moviePath,c.options)+'"           loop="false" menu="false"           quality="best" bgcolor="#ffffff"           width="100%" height="100%"           name="global-zeroclipboard-flash-bridge"           allowScriptAccess="always"           allowFullScreen="false"           type="application/x-shockwave-flash"           wmode="transparent"           pluginspage="http://www.macromedia.com/go/getflashplayer"           flashvars="'+g+'"           scale="exactfit">         </embed>       </object>';d=document.createElement("div"),d.id="global-zeroclipboard-html-bridge",d.setAttribute("class","global-zeroclipboard-container"),d.setAttribute("data-clipboard-ready",!1),d.style.position="absolute",d.style.left="-9999px",d.style.top="-9999px",d.style.width="15px",d.style.height="15px",d.style.zIndex="9999",d.innerHTML=h,document.body.appendChild(d)}c.htmlBridge=d,a=document["global-zeroclipboard-flash-bridge"],a&&(b=a.length)&&(a=a[b-1]),c.flashBridge=a||d.children[0].lastElementChild};p.prototype.resetBridge=function(){return this.htmlBridge.style.left="-9999px",this.htmlBridge.style.top="-9999px",this.htmlBridge.removeAttribute("title"),this.htmlBridge.removeAttribute("data-clipboard-text"),h(a,this.options.activeClass),a=null,this.options.text=null,this},p.prototype.ready=function(){var a=this.htmlBridge.getAttribute("data-clipboard-ready");return"true"===a||a===!0},p.prototype.reposition=function(){if(!a)return!1;var b=j(a);return this.htmlBridge.style.top=b.top+"px",this.htmlBridge.style.left=b.left+"px",this.htmlBridge.style.width=b.width+"px",this.htmlBridge.style.height=b.height+"px",this.htmlBridge.style.zIndex=b.zIndex+1,this.setSize(b.width,b.height),this},p.dispatch=function(a,b){p.prototype._singleton.receiveEvent(a,b)},p.prototype.on=function(a,b){for(var c=a.toString().split(/\s/g),d=0;d<c.length;d++)a=c[d].toLowerCase().replace(/^on/,""),this.handlers[a]||(this.handlers[a]=b);return this.handlers.noflash&&!p.detectFlashSupport()&&this.receiveEvent("onNoFlash",null),this},p.prototype.addEventListener=p.prototype.on,p.prototype.off=function(a,b){for(var c=a.toString().split(/\s/g),d=0;d<c.length;d++){a=c[d].toLowerCase().replace(/^on/,"");for(var e in this.handlers)e===a&&this.handlers[e]===b&&delete this.handlers[e]}return this},p.prototype.removeEventListener=p.prototype.off,p.prototype.receiveEvent=function(b,c){b=b.toString().toLowerCase().replace(/^on/,"");var d=a,e=!0;switch(b){case"load":if(c&&parseFloat(c.flashVersion.replace(",",".").replace(/[^0-9\.]/gi,""))<10)return this.receiveEvent("onWrongFlash",{flashVersion:c.flashVersion}),void 0;this.htmlBridge.setAttribute("data-clipboard-ready",!0);break;case"mouseover":g(d,this.options.hoverClass);break;case"mouseout":h(d,this.options.hoverClass),this.resetBridge();break;case"mousedown":g(d,this.options.activeClass);break;case"mouseup":h(d,this.options.activeClass);break;case"datarequested":var f=d.getAttribute("data-clipboard-target"),i=f?document.getElementById(f):null;if(i){var j=i.value||i.textContent||i.innerText;j&&this.setText(j)}else{var k=d.getAttribute("data-clipboard-text");k&&this.setText(k)}e=!1;break;case"complete":this.options.text=null}if(this.handlers[b]){var l=this.handlers[b];"string"==typeof l&&"function"==typeof window[l]&&(l=window[l]),"function"==typeof l&&o(l,d,this,c,e)}},p.prototype.glue=function(a){a=n(a);for(var b=0;b<a.length;b++)-1==m(a[b],q)&&(q.push(a[b]),e(a[b],"mouseover",d));return this},p.prototype.unglue=function(a){a=n(a);for(var b=0;b<a.length;b++){f(a[b],"mouseover",d);var c=m(a[b],q);-1!=c&&q.splice(c,1)}return this},"function"==typeof define&&define.amd?define(["require","exports","module"],function(a,b,c){return t=c&&c.id||null,p}):"object"==typeof module&&module&&"object"==typeof module.exports&&module.exports?(u=module.id||null,module.exports=p):window.ZeroClipboard=p}();
\ No newline at end of file
+ * ZeroClipboard
+ * The ZeroClipboard library provides an easy way to copy text to the clipboard using an invisible Adobe Flash movie and a JavaScript interface.
+ * Copyright (c) 2014 Jon Rohan, James M. Greene
+ * Licensed MIT
+ * http://zeroclipboard.org/
+ * v2.1.6
+ */
+!function(a,b){"use strict";var c,d,e=a,f=e.document,g=e.navigator,h=e.setTimeout,i=e.encodeURIComponent,j=e.ActiveXObject,k=e.Error,l=e.Number.parseInt||e.parseInt,m=e.Number.parseFloat||e.parseFloat,n=e.Number.isNaN||e.isNaN,o=e.Math.round,p=e.Date.now,q=e.Object.keys,r=e.Object.defineProperty,s=e.Object.prototype.hasOwnProperty,t=e.Array.prototype.slice,u=function(){var a=function(a){return a};if("function"==typeof e.wrap&&"function"==typeof e.unwrap)try{var b=f.createElement("div"),c=e.unwrap(b);1===b.nodeType&&c&&1===c.nodeType&&(a=e.unwrap)}catch(d){}return a}(),v=function(a){return t.call(a,0)},w=function(){var a,c,d,e,f,g,h=v(arguments),i=h[0]||{};for(a=1,c=h.length;c>a;a++)if(null!=(d=h[a]))for(e in d)s.call(d,e)&&(f=i[e],g=d[e],i!==g&&g!==b&&(i[e]=g));return i},x=function(a){var b,c,d,e;if("object"!=typeof a||null==a)b=a;else if("number"==typeof a.length)for(b=[],c=0,d=a.length;d>c;c++)s.call(a,c)&&(b[c]=x(a[c]));else{b={};for(e in a)s.call(a,e)&&(b[e]=x(a[e]))}return b},y=function(a,b){for(var c={},d=0,e=b.length;e>d;d++)b[d]in a&&(c[b[d]]=a[b[d]]);return c},z=function(a,b){var c={};for(var d in a)-1===b.indexOf(d)&&(c[d]=a[d]);return c},A=function(a){if(a)for(var b in a)s.call(a,b)&&delete a[b];return a},B=function(a,b){if(a&&1===a.nodeType&&a.ownerDocument&&b&&(1===b.nodeType&&b.ownerDocument&&b.ownerDocument===a.ownerDocument||9===b.nodeType&&!b.ownerDocument&&b===a.ownerDocument))do{if(a===b)return!0;a=a.parentNode}while(a);return!1},C=function(a){var b;return"string"==typeof a&&a&&(b=a.split("#")[0].split("?")[0],b=a.slice(0,a.lastIndexOf("/")+1)),b},D=function(a){var b,c;return"string"==typeof a&&a&&(c=a.match(/^(?:|[^:@]*@|.+\)@(?=http[s]?|file)|.+?\s+(?: at |@)(?:[^:\(]+ )*[\(]?)((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/),c&&c[1]?b=c[1]:(c=a.match(/\)@((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/),c&&c[1]&&(b=c[1]))),b},E=function(){var a,b;try{throw new k}catch(c){b=c}return b&&(a=b.sourceURL||b.fileName||D(b.stack)),a},F=function(){var a,c,d;if(f.currentScript&&(a=f.currentScript.src))return a;if(c=f.getElementsByTagName("script"),1===c.length)return c[0].src||b;if("readyState"in c[0])for(d=c.length;d--;)if("interactive"===c[d].readyState&&(a=c[d].src))return a;return"loading"===f.readyState&&(a=c[c.length-1].src)?a:(a=E())?a:b},G=function(){var a,c,d,e=f.getElementsByTagName("script");for(a=e.length;a--;){if(!(d=e[a].src)){c=null;break}if(d=C(d),null==c)c=d;else if(c!==d){c=null;break}}return c||b},H=function(){var a=C(F())||G()||"";return a+"ZeroClipboard.swf"},I={bridge:null,version:"0.0.0",pluginType:"unknown",disabled:null,outdated:null,unavailable:null,deactivated:null,overdue:null,ready:null},J="11.0.0",K={},L={},M=null,N={ready:"Flash communication is established",error:{"flash-disabled":"Flash is disabled or not installed","flash-outdated":"Flash is too outdated to support ZeroClipboard","flash-unavailable":"Flash is unable to communicate bidirectionally with JavaScript","flash-deactivated":"Flash is too outdated for your browser and/or is configured as click-to-activate","flash-overdue":"Flash communication was established but NOT within the acceptable time limit"}},O={swfPath:H(),trustedDomains:a.location.host?[a.location.host]:[],cacheBust:!0,forceEnhancedClipboard:!1,flashLoadTimeout:3e4,autoActivate:!0,bubbleEvents:!0,containerId:"global-zeroclipboard-html-bridge",containerClass:"global-zeroclipboard-container",swfObjectId:"global-zeroclipboard-flash-bridge",hoverClass:"zeroclipboard-is-hover",activeClass:"zeroclipboard-is-active",forceHandCursor:!1,title:null,zIndex:999999999},P=function(a){if("object"==typeof a&&null!==a)for(var b in a)if(s.call(a,b))if(/^(?:forceHandCursor|title|zIndex|bubbleEvents)$/.test(b))O[b]=a[b];else if(null==I.bridge)if("containerId"===b||"swfObjectId"===b){if(!cb(a[b]))throw new Error("The specified `"+b+"` value is not valid as an HTML4 Element ID");O[b]=a[b]}else O[b]=a[b];{if("string"!=typeof a||!a)return x(O);if(s.call(O,a))return O[a]}},Q=function(){return{browser:y(g,["userAgent","platform","appName"]),flash:z(I,["bridge"]),zeroclipboard:{version:Fb.version,config:Fb.config()}}},R=function(){return!!(I.disabled||I.outdated||I.unavailable||I.deactivated)},S=function(a,b){var c,d,e,f={};if("string"==typeof a&&a)e=a.toLowerCase().split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&Fb.on(c,a[c]);if(e&&e.length){for(c=0,d=e.length;d>c;c++)a=e[c].replace(/^on/,""),f[a]=!0,K[a]||(K[a]=[]),K[a].push(b);if(f.ready&&I.ready&&Fb.emit({type:"ready"}),f.error){var g=["disabled","outdated","unavailable","deactivated","overdue"];for(c=0,d=g.length;d>c;c++)if(I[g[c]]===!0){Fb.emit({type:"error",name:"flash-"+g[c]});break}}}return Fb},T=function(a,b){var c,d,e,f,g;if(0===arguments.length)f=q(K);else if("string"==typeof a&&a)f=a.split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&Fb.off(c,a[c]);if(f&&f.length)for(c=0,d=f.length;d>c;c++)if(a=f[c].toLowerCase().replace(/^on/,""),g=K[a],g&&g.length)if(b)for(e=g.indexOf(b);-1!==e;)g.splice(e,1),e=g.indexOf(b,e);else g.length=0;return Fb},U=function(a){var b;return b="string"==typeof a&&a?x(K[a])||null:x(K)},V=function(a){var b,c,d;return a=db(a),a&&!jb(a)?"ready"===a.type&&I.overdue===!0?Fb.emit({type:"error",name:"flash-overdue"}):(b=w({},a),ib.call(this,b),"copy"===a.type&&(d=pb(L),c=d.data,M=d.formatMap),c):void 0},W=function(){if("boolean"!=typeof I.ready&&(I.ready=!1),!Fb.isFlashUnusable()&&null===I.bridge){var a=O.flashLoadTimeout;"number"==typeof a&&a>=0&&h(function(){"boolean"!=typeof I.deactivated&&(I.deactivated=!0),I.deactivated===!0&&Fb.emit({type:"error",name:"flash-deactivated"})},a),I.overdue=!1,nb()}},X=function(){Fb.clearData(),Fb.blur(),Fb.emit("destroy"),ob(),Fb.off()},Y=function(a,b){var c;if("object"==typeof a&&a&&"undefined"==typeof b)c=a,Fb.clearData();else{if("string"!=typeof a||!a)return;c={},c[a]=b}for(var d in c)"string"==typeof d&&d&&s.call(c,d)&&"string"==typeof c[d]&&c[d]&&(L[d]=c[d])},Z=function(a){"undefined"==typeof a?(A(L),M=null):"string"==typeof a&&s.call(L,a)&&delete L[a]},$=function(a){return"undefined"==typeof a?x(L):"string"==typeof a&&s.call(L,a)?L[a]:void 0},_=function(a){if(a&&1===a.nodeType){c&&(xb(c,O.activeClass),c!==a&&xb(c,O.hoverClass)),c=a,wb(a,O.hoverClass);var b=a.getAttribute("title")||O.title;if("string"==typeof b&&b){var d=mb(I.bridge);d&&d.setAttribute("title",b)}var e=O.forceHandCursor===!0||"pointer"===yb(a,"cursor");Cb(e),Bb()}},ab=function(){var a=mb(I.bridge);a&&(a.removeAttribute("title"),a.style.left="0px",a.style.top="-9999px",a.style.width="1px",a.style.top="1px"),c&&(xb(c,O.hoverClass),xb(c,O.activeClass),c=null)},bb=function(){return c||null},cb=function(a){return"string"==typeof a&&a&&/^[A-Za-z][A-Za-z0-9_:\-\.]*$/.test(a)},db=function(a){var b;if("string"==typeof a&&a?(b=a,a={}):"object"==typeof a&&a&&"string"==typeof a.type&&a.type&&(b=a.type),b){!a.target&&/^(copy|aftercopy|_click)$/.test(b.toLowerCase())&&(a.target=d),w(a,{type:b.toLowerCase(),target:a.target||c||null,relatedTarget:a.relatedTarget||null,currentTarget:I&&I.bridge||null,timeStamp:a.timeStamp||p()||null});var e=N[a.type];return"error"===a.type&&a.name&&e&&(e=e[a.name]),e&&(a.message=e),"ready"===a.type&&w(a,{target:null,version:I.version}),"error"===a.type&&(/^flash-(disabled|outdated|unavailable|deactivated|overdue)$/.test(a.name)&&w(a,{target:null,minimumVersion:J}),/^flash-(outdated|unavailable|deactivated|overdue)$/.test(a.name)&&w(a,{version:I.version})),"copy"===a.type&&(a.clipboardData={setData:Fb.setData,clearData:Fb.clearData}),"aftercopy"===a.type&&(a=qb(a,M)),a.target&&!a.relatedTarget&&(a.relatedTarget=eb(a.target)),a=fb(a)}},eb=function(a){var b=a&&a.getAttribute&&a.getAttribute("data-clipboard-target");return b?f.getElementById(b):null},fb=function(a){if(a&&/^_(?:click|mouse(?:over|out|down|up|move))$/.test(a.type)){var c=a.target,d="_mouseover"===a.type&&a.relatedTarget?a.relatedTarget:b,g="_mouseout"===a.type&&a.relatedTarget?a.relatedTarget:b,h=Ab(c),i=e.screenLeft||e.screenX||0,j=e.screenTop||e.screenY||0,k=f.body.scrollLeft+f.documentElement.scrollLeft,l=f.body.scrollTop+f.documentElement.scrollTop,m=h.left+("number"==typeof a._stageX?a._stageX:0),n=h.top+("number"==typeof a._stageY?a._stageY:0),o=m-k,p=n-l,q=i+o,r=j+p,s="number"==typeof a.movementX?a.movementX:0,t="number"==typeof a.movementY?a.movementY:0;delete a._stageX,delete a._stageY,w(a,{srcElement:c,fromElement:d,toElement:g,screenX:q,screenY:r,pageX:m,pageY:n,clientX:o,clientY:p,x:o,y:p,movementX:s,movementY:t,offsetX:0,offsetY:0,layerX:0,layerY:0})}return a},gb=function(a){var b=a&&"string"==typeof a.type&&a.type||"";return!/^(?:(?:before)?copy|destroy)$/.test(b)},hb=function(a,b,c,d){d?h(function(){a.apply(b,c)},0):a.apply(b,c)},ib=function(a){if("object"==typeof a&&a&&a.type){var b=gb(a),c=K["*"]||[],d=K[a.type]||[],f=c.concat(d);if(f&&f.length){var g,h,i,j,k,l=this;for(g=0,h=f.length;h>g;g++)i=f[g],j=l,"string"==typeof i&&"function"==typeof e[i]&&(i=e[i]),"object"==typeof i&&i&&"function"==typeof i.handleEvent&&(j=i,i=i.handleEvent),"function"==typeof i&&(k=w({},a),hb(i,j,[k],b))}return this}},jb=function(a){var b=a.target||c||null,e="swf"===a._source;delete a._source;var f=["flash-disabled","flash-outdated","flash-unavailable","flash-deactivated","flash-overdue"];switch(a.type){case"error":-1!==f.indexOf(a.name)&&w(I,{disabled:"flash-disabled"===a.name,outdated:"flash-outdated"===a.name,unavailable:"flash-unavailable"===a.name,deactivated:"flash-deactivated"===a.name,overdue:"flash-overdue"===a.name,ready:!1});break;case"ready":var g=I.deactivated===!0;w(I,{disabled:!1,outdated:!1,unavailable:!1,deactivated:!1,overdue:g,ready:!g});break;case"beforecopy":d=b;break;case"copy":var h,i,j=a.relatedTarget;!L["text/html"]&&!L["text/plain"]&&j&&(i=j.value||j.outerHTML||j.innerHTML)&&(h=j.value||j.textContent||j.innerText)?(a.clipboardData.clearData(),a.clipboardData.setData("text/plain",h),i!==h&&a.clipboardData.setData("text/html",i)):!L["text/plain"]&&a.target&&(h=a.target.getAttribute("data-clipboard-text"))&&(a.clipboardData.clearData(),a.clipboardData.setData("text/plain",h));break;case"aftercopy":Fb.clearData(),b&&b!==vb()&&b.focus&&b.focus();break;case"_mouseover":Fb.focus(b),O.bubbleEvents===!0&&e&&(b&&b!==a.relatedTarget&&!B(a.relatedTarget,b)&&kb(w({},a,{type:"mouseenter",bubbles:!1,cancelable:!1})),kb(w({},a,{type:"mouseover"})));break;case"_mouseout":Fb.blur(),O.bubbleEvents===!0&&e&&(b&&b!==a.relatedTarget&&!B(a.relatedTarget,b)&&kb(w({},a,{type:"mouseleave",bubbles:!1,cancelable:!1})),kb(w({},a,{type:"mouseout"})));break;case"_mousedown":wb(b,O.activeClass),O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_mouseup":xb(b,O.activeClass),O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_click":d=null,O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_mousemove":O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}))}return/^_(?:click|mouse(?:over|out|down|up|move))$/.test(a.type)?!0:void 0},kb=function(a){if(a&&"string"==typeof a.type&&a){var b,c=a.target||null,d=c&&c.ownerDocument||f,g={view:d.defaultView||e,canBubble:!0,cancelable:!0,detail:"click"===a.type?1:0,button:"number"==typeof a.which?a.which-1:"number"==typeof a.button?a.button:d.createEvent?0:1},h=w(g,a);c&&d.createEvent&&c.dispatchEvent&&(h=[h.type,h.canBubble,h.cancelable,h.view,h.detail,h.screenX,h.screenY,h.clientX,h.clientY,h.ctrlKey,h.altKey,h.shiftKey,h.metaKey,h.button,h.relatedTarget],b=d.createEvent("MouseEvents"),b.initMouseEvent&&(b.initMouseEvent.apply(b,h),b._source="js",c.dispatchEvent(b)))}},lb=function(){var a=f.createElement("div");return a.id=O.containerId,a.className=O.containerClass,a.style.position="absolute",a.style.left="0px",a.style.top="-9999px",a.style.width="1px",a.style.height="1px",a.style.zIndex=""+Db(O.zIndex),a},mb=function(a){for(var b=a&&a.parentNode;b&&"OBJECT"===b.nodeName&&b.parentNode;)b=b.parentNode;return b||null},nb=function(){var a,b=I.bridge,c=mb(b);if(!b){var d=ub(e.location.host,O),g="never"===d?"none":"all",h=sb(O),i=O.swfPath+rb(O.swfPath,O);c=lb();var j=f.createElement("div");c.appendChild(j),f.body.appendChild(c);var k=f.createElement("div"),l="activex"===I.pluginType;k.innerHTML='<object id="'+O.swfObjectId+'" name="'+O.swfObjectId+'" width="100%" height="100%" '+(l?'classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000"':'type="application/x-shockwave-flash" data="'+i+'"')+">"+(l?'<param name="movie" value="'+i+'"/>':"")+'<param name="allowScriptAccess" value="'+d+'"/><param name="allowNetworking" value="'+g+'"/><param name="menu" value="false"/><param name="wmode" value="transparent"/><param name="flashvars" value="'+h+'"/></object>',b=k.firstChild,k=null,u(b).ZeroClipboard=Fb,c.replaceChild(b,j)}return b||(b=f[O.swfObjectId],b&&(a=b.length)&&(b=b[a-1]),!b&&c&&(b=c.firstChild)),I.bridge=b||null,b},ob=function(){var a=I.bridge;if(a){var b=mb(a);b&&("activex"===I.pluginType&&"readyState"in a?(a.style.display="none",function c(){if(4===a.readyState){for(var d in a)"function"==typeof a[d]&&(a[d]=null);a.parentNode&&a.parentNode.removeChild(a),b.parentNode&&b.parentNode.removeChild(b)}else h(c,10)}()):(a.parentNode&&a.parentNode.removeChild(a),b.parentNode&&b.parentNode.removeChild(b))),I.ready=null,I.bridge=null,I.deactivated=null}},pb=function(a){var b={},c={};if("object"==typeof a&&a){for(var d in a)if(d&&s.call(a,d)&&"string"==typeof a[d]&&a[d])switch(d.toLowerCase()){case"text/plain":case"text":case"air:text":case"flash:text":b.text=a[d],c.text=d;break;case"text/html":case"html":case"air:html":case"flash:html":b.html=a[d],c.html=d;break;case"application/rtf":case"text/rtf":case"rtf":case"richtext":case"air:rtf":case"flash:rtf":b.rtf=a[d],c.rtf=d}return{data:b,formatMap:c}}},qb=function(a,b){if("object"!=typeof a||!a||"object"!=typeof b||!b)return a;var c={};for(var d in a)if(s.call(a,d)){if("success"!==d&&"data"!==d){c[d]=a[d];continue}c[d]={};var e=a[d];for(var f in e)f&&s.call(e,f)&&s.call(b,f)&&(c[d][b[f]]=e[f])}return c},rb=function(a,b){var c=null==b||b&&b.cacheBust===!0;return c?(-1===a.indexOf("?")?"?":"&")+"noCache="+p():""},sb=function(a){var b,c,d,f,g="",h=[];if(a.trustedDomains&&("string"==typeof a.trustedDomains?f=[a.trustedDomains]:"object"==typeof a.trustedDomains&&"length"in a.trustedDomains&&(f=a.trustedDomains)),f&&f.length)for(b=0,c=f.length;c>b;b++)if(s.call(f,b)&&f[b]&&"string"==typeof f[b]){if(d=tb(f[b]),!d)continue;if("*"===d){h.length=0,h.push(d);break}h.push.apply(h,[d,"//"+d,e.location.protocol+"//"+d])}return h.length&&(g+="trustedOrigins="+i(h.join(","))),a.forceEnhancedClipboard===!0&&(g+=(g?"&":"")+"forceEnhancedClipboard=true"),"string"==typeof a.swfObjectId&&a.swfObjectId&&(g+=(g?"&":"")+"swfObjectId="+i(a.swfObjectId)),g},tb=function(a){if(null==a||""===a)return null;if(a=a.replace(/^\s+|\s+$/g,""),""===a)return null;var b=a.indexOf("//");a=-1===b?a:a.slice(b+2);var c=a.indexOf("/");return a=-1===c?a:-1===b||0===c?null:a.slice(0,c),a&&".swf"===a.slice(-4).toLowerCase()?null:a||null},ub=function(){var a=function(a){var b,c,d,e=[];if("string"==typeof a&&(a=[a]),"object"!=typeof a||!a||"number"!=typeof a.length)return e;for(b=0,c=a.length;c>b;b++)if(s.call(a,b)&&(d=tb(a[b]))){if("*"===d){e.length=0,e.push("*");break}-1===e.indexOf(d)&&e.push(d)}return e};return function(b,c){var d=tb(c.swfPath);null===d&&(d=b);var e=a(c.trustedDomains),f=e.length;if(f>0){if(1===f&&"*"===e[0])return"always";if(-1!==e.indexOf(b))return 1===f&&b===d?"sameDomain":"always"}return"never"}}(),vb=function(){try{return f.activeElement}catch(a){return null}},wb=function(a,b){if(!a||1!==a.nodeType)return a;if(a.classList)return a.classList.contains(b)||a.classList.add(b),a;if(b&&"string"==typeof b){var c=(b||"").split(/\s+/);if(1===a.nodeType)if(a.className){for(var d=" "+a.className+" ",e=a.className,f=0,g=c.length;g>f;f++)d.indexOf(" "+c[f]+" ")<0&&(e+=" "+c[f]);a.className=e.replace(/^\s+|\s+$/g,"")}else a.className=b}return a},xb=function(a,b){if(!a||1!==a.nodeType)return a;if(a.classList)return a.classList.contains(b)&&a.classList.remove(b),a;if("string"==typeof b&&b){var c=b.split(/\s+/);if(1===a.nodeType&&a.className){for(var d=(" "+a.className+" ").replace(/[\n\t]/g," "),e=0,f=c.length;f>e;e++)d=d.replace(" "+c[e]+" "," ");a.className=d.replace(/^\s+|\s+$/g,"")}}return a},yb=function(a,b){var c=e.getComputedStyle(a,null).getPropertyValue(b);return"cursor"!==b||c&&"auto"!==c||"A"!==a.nodeName?c:"pointer"},zb=function(){var a,b,c,d=1;return"function"==typeof f.body.getBoundingClientRect&&(a=f.body.getBoundingClientRect(),b=a.right-a.left,c=f.body.offsetWidth,d=o(b/c*100)/100),d},Ab=function(a){var b={left:0,top:0,width:0,height:0};if(a.getBoundingClientRect){var c,d,g,h=a.getBoundingClientRect();"pageXOffset"in e&&"pageYOffset"in e?(c=e.pageXOffset,d=e.pageYOffset):(g=zb(),c=o(f.documentElement.scrollLeft/g),d=o(f.documentElement.scrollTop/g));var i=f.documentElement.clientLeft||0,j=f.documentElement.clientTop||0;b.left=h.left+c-i,b.top=h.top+d-j,b.width="width"in h?h.width:h.right-h.left,b.height="height"in h?h.height:h.bottom-h.top}return b},Bb=function(){var a;if(c&&(a=mb(I.bridge))){var b=Ab(c);w(a.style,{width:b.width+"px",height:b.height+"px",top:b.top+"px",left:b.left+"px",zIndex:""+Db(O.zIndex)})}},Cb=function(a){I.ready===!0&&(I.bridge&&"function"==typeof I.bridge.setHandCursor?I.bridge.setHandCursor(a):I.ready=!1)},Db=function(a){if(/^(?:auto|inherit)$/.test(a))return a;var b;return"number"!=typeof a||n(a)?"string"==typeof a&&(b=Db(l(a,10))):b=a,"number"==typeof b?b:"auto"},Eb=function(a){function b(a){var b=a.match(/[\d]+/g);return b.length=3,b.join(".")}function c(a){return!!a&&(a=a.toLowerCase())&&(/^(pepflashplayer\.dll|libpepflashplayer\.so|pepperflashplayer\.plugin)$/.test(a)||"chrome.plugin"===a.slice(-13))}function d(a){a&&(i=!0,a.version&&(l=b(a.version)),!l&&a.description&&(l=b(a.description)),a.filename&&(k=c(a.filename)))}var e,f,h,i=!1,j=!1,k=!1,l="";if(g.plugins&&g.plugins.length)e=g.plugins["Shockwave Flash"],d(e),g.plugins["Shockwave Flash 2.0"]&&(i=!0,l="2.0.0.11");else if(g.mimeTypes&&g.mimeTypes.length)h=g.mimeTypes["application/x-shockwave-flash"],e=h&&h.enabledPlugin,d(e);else if("undefined"!=typeof a){j=!0;try{f=new a("ShockwaveFlash.ShockwaveFlash.7"),i=!0,l=b(f.GetVariable("$version"))}catch(n){try{f=new a("ShockwaveFlash.ShockwaveFlash.6"),i=!0,l="6.0.21"}catch(o){try{f=new a("ShockwaveFlash.ShockwaveFlash"),i=!0,l=b(f.GetVariable("$version"))}catch(p){j=!1}}}}I.disabled=i!==!0,I.outdated=l&&m(l)<m(J),I.version=l||"0.0.0",I.pluginType=k?"pepper":j?"activex":i?"netscape":"unknown"};Eb(j);var Fb=function(){return this instanceof Fb?void("function"==typeof Fb._createClient&&Fb._createClient.apply(this,v(arguments))):new Fb};r(Fb,"version",{value:"2.1.6",writable:!1,configurable:!0,enumerable:!0}),Fb.config=function(){return P.apply(this,v(arguments))},Fb.state=function(){return Q.apply(this,v(arguments))},Fb.isFlashUnusable=function(){return R.apply(this,v(arguments))},Fb.on=function(){return S.apply(this,v(arguments))},Fb.off=function(){return T.apply(this,v(arguments))},Fb.handlers=function(){return U.apply(this,v(arguments))},Fb.emit=function(){return V.apply(this,v(arguments))},Fb.create=function(){return W.apply(this,v(arguments))},Fb.destroy=function(){return X.apply(this,v(arguments))},Fb.setData=function(){return Y.apply(this,v(arguments))},Fb.clearData=function(){return Z.apply(this,v(arguments))},Fb.getData=function(){return $.apply(this,v(arguments))},Fb.focus=Fb.activate=function(){return _.apply(this,v(arguments))},Fb.blur=Fb.deactivate=function(){return ab.apply(this,v(arguments))},Fb.activeElement=function(){return bb.apply(this,v(arguments))};var Gb=0,Hb={},Ib=0,Jb={},Kb={};w(O,{autoActivate:!0});var Lb=function(a){var b=this;b.id=""+Gb++,Hb[b.id]={instance:b,elements:[],handlers:{}},a&&b.clip(a),Fb.on("*",function(a){return b.emit(a)}),Fb.on("destroy",function(){b.destroy()}),Fb.create()},Mb=function(a,b){var c,d,e,f={},g=Hb[this.id]&&Hb[this.id].handlers;if("string"==typeof a&&a)e=a.toLowerCase().split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&this.on(c,a[c]);if(e&&e.length){for(c=0,d=e.length;d>c;c++)a=e[c].replace(/^on/,""),f[a]=!0,g[a]||(g[a]=[]),g[a].push(b);if(f.ready&&I.ready&&this.emit({type:"ready",client:this}),f.error){var h=["disabled","outdated","unavailable","deactivated","overdue"];for(c=0,d=h.length;d>c;c++)if(I[h[c]]){this.emit({type:"error",name:"flash-"+h[c],client:this});break}}}return this},Nb=function(a,b){var c,d,e,f,g,h=Hb[this.id]&&Hb[this.id].handlers;if(0===arguments.length)f=q(h);else if("string"==typeof a&&a)f=a.split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&this.off(c,a[c]);if(f&&f.length)for(c=0,d=f.length;d>c;c++)if(a=f[c].toLowerCase().replace(/^on/,""),g=h[a],g&&g.length)if(b)for(e=g.indexOf(b);-1!==e;)g.splice(e,1),e=g.indexOf(b,e);else g.length=0;return this},Ob=function(a){var b=null,c=Hb[this.id]&&Hb[this.id].handlers;return c&&(b="string"==typeof a&&a?c[a]?c[a].slice(0):[]:x(c)),b},Pb=function(a){if(Ub.call(this,a)){"object"==typeof a&&a&&"string"==typeof a.type&&a.type&&(a=w({},a));var b=w({},db(a),{client:this});Vb.call(this,b)}return this},Qb=function(a){a=Wb(a);for(var b=0;b<a.length;b++)if(s.call(a,b)&&a[b]&&1===a[b].nodeType){a[b].zcClippingId?-1===Jb[a[b].zcClippingId].indexOf(this.id)&&Jb[a[b].zcClippingId].push(this.id):(a[b].zcClippingId="zcClippingId_"+Ib++,Jb[a[b].zcClippingId]=[this.id],O.autoActivate===!0&&Xb(a[b]));var c=Hb[this.id]&&Hb[this.id].elements;-1===c.indexOf(a[b])&&c.push(a[b])}return this},Rb=function(a){var b=Hb[this.id];if(!b)return this;var c,d=b.elements;a="undefined"==typeof a?d.slice(0):Wb(a);for(var e=a.length;e--;)if(s.call(a,e)&&a[e]&&1===a[e].nodeType){for(c=0;-1!==(c=d.indexOf(a[e],c));)d.splice(c,1);var f=Jb[a[e].zcClippingId];if(f){for(c=0;-1!==(c=f.indexOf(this.id,c));)f.splice(c,1);0===f.length&&(O.autoActivate===!0&&Yb(a[e]),delete a[e].zcClippingId)}}return this},Sb=function(){var a=Hb[this.id];return a&&a.elements?a.elements.slice(0):[]},Tb=function(){this.unclip(),this.off(),delete Hb[this.id]},Ub=function(a){if(!a||!a.type)return!1;if(a.client&&a.client!==this)return!1;var b=Hb[this.id]&&Hb[this.id].elements,c=!!b&&b.length>0,d=!a.target||c&&-1!==b.indexOf(a.target),e=a.relatedTarget&&c&&-1!==b.indexOf(a.relatedTarget),f=a.client&&a.client===this;return d||e||f?!0:!1},Vb=function(a){if("object"==typeof a&&a&&a.type){var b=gb(a),c=Hb[this.id]&&Hb[this.id].handlers["*"]||[],d=Hb[this.id]&&Hb[this.id].handlers[a.type]||[],f=c.concat(d);if(f&&f.length){var g,h,i,j,k,l=this;for(g=0,h=f.length;h>g;g++)i=f[g],j=l,"string"==typeof i&&"function"==typeof e[i]&&(i=e[i]),"object"==typeof i&&i&&"function"==typeof i.handleEvent&&(j=i,i=i.handleEvent),"function"==typeof i&&(k=w({},a),hb(i,j,[k],b))}return this}},Wb=function(a){return"string"==typeof a&&(a=[]),"number"!=typeof a.length?[a]:a},Xb=function(a){if(a&&1===a.nodeType){var b=function(a){(a||(a=e.event))&&("js"!==a._source&&(a.stopImmediatePropagation(),a.preventDefault()),delete a._source)},c=function(c){(c||(c=e.event))&&(b(c),Fb.focus(a))};a.addEventListener("mouseover",c,!1),a.addEventListener("mouseout",b,!1),a.addEventListener("mouseenter",b,!1),a.addEventListener("mouseleave",b,!1),a.addEventListener("mousemove",b,!1),Kb[a.zcClippingId]={mouseover:c,mouseout:b,mouseenter:b,mouseleave:b,mousemove:b}}},Yb=function(a){if(a&&1===a.nodeType){var b=Kb[a.zcClippingId];if("object"==typeof b&&b){for(var c,d,e=["move","leave","enter","out","over"],f=0,g=e.length;g>f;f++)c="mouse"+e[f],d=b[c],"function"==typeof d&&a.removeEventListener(c,d,!1);delete Kb[a.zcClippingId]}}};Fb._createClient=function(){Lb.apply(this,v(arguments))},Fb.prototype.on=function(){return Mb.apply(this,v(arguments))},Fb.prototype.off=function(){return Nb.apply(this,v(arguments))},Fb.prototype.handlers=function(){return Ob.apply(this,v(arguments))},Fb.prototype.emit=function(){return Pb.apply(this,v(arguments))},Fb.prototype.clip=function(){return Qb.apply(this,v(arguments))},Fb.prototype.unclip=function(){return Rb.apply(this,v(arguments))},Fb.prototype.elements=function(){return Sb.apply(this,v(arguments))},Fb.prototype.destroy=function(){return Tb.apply(this,v(arguments))},Fb.prototype.setText=function(a){return Fb.setData("text/plain",a),this},Fb.prototype.setHtml=function(a){return Fb.setData("text/html",a),this},Fb.prototype.setRichText=function(a){return Fb.setData("application/rtf",a),this},Fb.prototype.setData=function(){return Fb.setData.apply(this,v(arguments)),this},Fb.prototype.clearData=function(){return Fb.clearData.apply(this,v(arguments)),this},Fb.prototype.getData=function(){return Fb.getData.apply(this,v(arguments))},"function"==typeof define&&define.amd?define(function(){return Fb}):"object"==typeof module&&module&&"object"==typeof module.exports&&module.exports?module.exports=Fb:a.ZeroClipboard=Fb}(function(){return this||window}());
+window.ZeroClipboard = ZeroClipboard;
\ No newline at end of file
diff --git a/static/lib/ZeroClipboard.swf b/static/lib/ZeroClipboard.swf
old mode 100755
new mode 100644
index 880e64ee7614e224660c6616b4bbb1ee5fab8ec9..d4e2561b366e131d3bae303acce90a137a4956e1
GIT binary patch
literal 4036
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

literal 1635
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

diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html
index 9e9e83702..03ce2909c 100644
--- a/static/partials/view-repo.html
+++ b/static/partials/view-repo.html
@@ -58,16 +58,9 @@
         <span class="pull-command visible-md-inline">
           <div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title">
             <div class="input-group">
-              <input id="pull-text" type="text" class="form-control" value="{{ 'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name }}" readonly>
-              <span id="copyClipboard" class="input-group-addon" data-title="Copy to Clipboard" data-clipboard-target="pull-text">
-                <i class="fa fa-copy"></i>
-              </span>
+              <div class="copy-box" hovering-message="true" value="'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name"></div>
             </div>
           </div>
-          
-          <div id="clipboardCopied" class="hovering" style="display: none">
-            Copied to clipboard
-          </div>
         </span>
       </div>
     </div>

From 34c6d7f5b4d568ded05f432f80dd3a1701f98cb5 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 16:54:53 -0400
Subject: [PATCH 10/57] 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 @@
            <a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
          </span>
          <div class="clipboard-copied-message" style="display: none">
-           Copied to clipboard
+           Copied
          </div>
-         <button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button>
+         <input type="hidden" name="command-data" id="command-data" value="{{ command }}">
+         <button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="command-data">Copy Login Command</button>
          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
        </div>
      </div><!-- /.modal-content -->
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 <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 18:03:22 -0400
Subject: [PATCH 11/57] 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 @@
     <div class="col-md-2">
       <ul class="nav nav-pills nav-stacked">
         <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
-        <li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()">Build Triggers</a></li>
+        <li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()"
+               quay-require="['BUILD_SUPPORT']">Build Triggers</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li>
         <li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@@ -225,7 +226,7 @@
         </div>
 
         <!-- Triggers tab -->
-        <div id="trigger" class="tab-pane">
+        <div id="trigger" class="tab-pane" quay-require="['BUILD_SUPPORT']">
           <div class="panel panel-default">
             <div class="panel-heading">Build Triggers
               <i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i>
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 @@
         <div class="dropdown" data-placement="top" style="display: inline-block" 
              bs-tooltip=""
              data-title="{{ runningBuilds.length ? 'Dockerfile Builds Running: ' + (runningBuilds.length) : 'Dockerfile Build' }}"
-             ng-show="repo.can_write || buildHistory.length">
+             quay-show="Features.BUILD_SUPPORT && (repo.can_write || buildHistory.length)">
           <button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
             <i class="fa fa-tasks fa-lg"></i>
             <span class="count" ng-class="runningBuilds.length ? 'visible' : ''"><span>{{ runningBuilds.length ? runningBuilds.length : '' }}</span></span>

From 7be345f59b186e6960319686018041cdb6c48a20 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 18:06:45 -0400
Subject: [PATCH 12/57] Made these changes to the Dockerfile, not
 Dockerfile.web :-/

---
 Dockerfile.web | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Dockerfile.web b/Dockerfile.web
index c9ba36823..56b126d53 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
 ENV HOME /root
 
 # Install the dependencies.
-RUN apt-get update  # 06AUG2014
+RUN apt-get update  # 21AUG2014
 
 # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
-RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev
+RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
 
 # Build the python dependencies
 ADD requirements.txt requirements.txt

From 80435d9c0b8aff6393c4061a0881d0e70cc8b3e0 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 19:41:22 -0400
Subject: [PATCH 13/57] Add support for docker search, now that auth is fixed

---
 endpoints/index.py | 29 ++++++++++++++++++++++++++++-
 1 file changed, 28 insertions(+), 1 deletion(-)

diff --git a/endpoints/index.py b/endpoints/index.py
index 39327e6a8..bf37e14b5 100644
--- a/endpoints/index.py
+++ b/endpoints/index.py
@@ -413,8 +413,35 @@ def put_repository_auth(namespace, repository):
 
 
 @index.route('/search', methods=['GET'])
+@process_auth
 def get_search():
-  abort(501, 'Not Implemented', issue='not-implemented')
+  def result_view(repo):
+    return {
+      "name": repo.namespace + '/' + repo.name,
+      "description": repo.description
+    }
+
+  query = request.args.get('q')
+
+  username = None
+  user = get_authenticated_user()
+  if user is not None:
+    username = user.username
+
+  matching = model.get_matching_repositories(query, username)
+  results = [result_view(repo) for repo in matching
+             if (repo.visibility.name == 'public' or 
+                 ReadRepositoryPermission(repo.namespace, repo.name).can())]
+
+  data = {
+    "query": query,
+    "num_results": len(results),
+    "results" : results
+  }
+
+  resp = make_response(json.dumps(data), 200)
+  resp.mimetype = 'application/json'
+  return resp
 
 
 @index.route('/_ping')

From ee3ad9e7c358289fe73ae1424d3adfd72f84609b Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 22 Aug 2014 19:48:58 -0400
Subject: [PATCH 14/57] Enable invoice views on all plans

---
 static/js/controllers.js        |  1 -
 static/partials/plans.html      | 18 +++++++++---------
 static/partials/user-admin.html |  2 +-
 3 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/static/js/controllers.js b/static/js/controllers.js
index f20ff8562..41e1443ea 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -1691,7 +1691,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
   };
 
   $scope.loadInvoices = function() {
-    if (!$scope.hasPaidBusinessPlan) { return; }
     $scope.invoicesShown++;
   };
 
diff --git a/static/partials/plans.html b/static/partials/plans.html
index 2265f8155..18c7deebb 100644
--- a/static/partials/plans.html
+++ b/static/partials/plans.html
@@ -34,6 +34,13 @@
             </span>
             <i class="fa fa-upload visible-lg"></i>
           </div>
+          <div class="feature">
+            <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
+                  data-title="Administrators can view and download the full invoice history for their organization">
+              Invoice History
+            </span>
+            <i class="fa fa-calendar visible-lg"></i>
+          </div>
           <div class="feature">
             <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
                   data-title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
@@ -48,13 +55,6 @@
             </span>
             <i class="fa fa-bar-chart-o visible-lg"></i>
           </div>
-          <div class="feature">
-            <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
-                  data-title="Administrators can view and download the full invoice history for their organization">
-              Invoice History
-            </span>
-            <i class="fa fa-calendar visible-lg"></i>
-          </div>
           <div class="feature">
             <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
                   data-title="All plans have a free trial">
@@ -81,7 +81,7 @@
           <div class="feature present"></div>
           <div class="feature present"></div>
           <div class="feature present"></div>
-          <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
+          <div class="feature present"></div>
           <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
           <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
           <div class="feature present"></div>
@@ -93,9 +93,9 @@
           <div class="feature present">SSL Encryption</div>
           <div class="feature present">Robot accounts</div>
           <div class="feature present">Dockerfile Build</div>
+          <div class="feature present">Invoice History</div>
           <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
           <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
-          <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
           <div class="feature present">Free Trial</div>
         </div>
 
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 @@
         <li ng-show="hasPaidPlan" quay-require="['BILLING']">
           <a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a>
         </li>
-        <li ng-show="hasPaidBusinessPlan" quay-require="['BILLING']">
+        <li ng-show="hasPaidPlan" quay-require="['BILLING']">
           <a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a>
         </li>
 

From 09a1c4d2b5d054e2185f5a09b8d5242c1cdd5c2f Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 14:23:21 -0400
Subject: [PATCH 15/57] 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 <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 15:30:29 -0400
Subject: [PATCH 16/57] 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 @@
     </div><!-- /.modal-dialog -->
   </div><!-- /.modal -->
 
-{% if not has_billing %}
   <!-- Modal message dialog -->
-  <div class="modal fade" id="overlicenseModal" data-backdrop="static">
+  <div class="modal fade" id="cannotContactService" data-backdrop="static">
     <div class="modal-dialog">
       <div class="modal-content">
         <div class="modal-header">
-          <h4 class="modal-title">Cannot create user</h4>
+          <h4 class="modal-title">Cannot Contact External Service</h4>
         </div>
         <div class="modal-body">
-          A new user cannot be created as this organization has reached its licensed seat count. Please contact your administrator.
-        </div>
-        <div class="modal-footer">
-          <a href="javascript:void(0)" class="btn btn-primary" data-dismiss="modal" onclick="location = '/signin'">Sign In</a>
+          A connection to an external service has failed. Please reload the page to try again.
         </div>
       </div><!-- /.modal-content -->
     </div><!-- /.modal-dialog -->
   </div><!-- /.modal -->
-{% endif %}
 
 {% endblock %}
diff --git a/util/analytics.py b/util/analytics.py
index 6dfdf923c..a7608aed8 100644
--- a/util/analytics.py
+++ b/util/analytics.py
@@ -30,7 +30,11 @@ class SendToMixpanel(Process):
     while True:
       mp_request = self._mp_queue.get()
       logger.debug('Got queued mixpanel reqeust.')
-      self._consumer.send(*json.loads(mp_request))
+      try:
+        self._consumer.send(*json.loads(mp_request))
+      except:
+        # Make sure we don't crash if Mixpanel request fails.
+        pass
 
 
 class FakeMixpanel(object):

From 4b2a0b5063128e4a452455d2f9d3a34f0aa39214 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 15:33:48 -0400
Subject: [PATCH 17/57] Fix ZeroClipboard path for the new version

---
 static/js/app.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/js/app.js b/static/js/app.js
index c718375fe..a97b9d9be 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -3,7 +3,7 @@ var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$';
 
 $.fn.clipboardCopy = function() { 
   if (zeroClipboardSupported) {
-    (new ZeroClipboard($(this),  { 'moviePath': 'static/lib/ZeroClipboard.swf' }));
+    (new ZeroClipboard($(this),  { 'swfPath': 'static/lib/ZeroClipboard.swf' }));
     return true;
   }
 

From 837630359cd711e2c1f6dbba3edd1b41c059eb21 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 15:59:50 -0400
Subject: [PATCH 18/57] Really fix ZeroClipboard

---
 static/js/app.js | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/static/js/app.js b/static/js/app.js
index a97b9d9be..ba8eb8a8e 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -3,7 +3,7 @@ var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$';
 
 $.fn.clipboardCopy = function() { 
   if (zeroClipboardSupported) {
-    (new ZeroClipboard($(this),  { 'swfPath': 'static/lib/ZeroClipboard.swf' }));
+    (new ZeroClipboard($(this)));
     return true;
   }
 
@@ -12,6 +12,10 @@ $.fn.clipboardCopy = function() {
 };
 
 var zeroClipboardSupported = true;
+ZeroClipboard.config({
+  'swfPath': 'static/lib/ZeroClipboard.swf'
+});
+
 ZeroClipboard.on("error", function(e) {
   zeroClipboardSupported = false;  
 });

From a129aac94b4283988d6e69f7aa7261defbfa29a7 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 17:19:23 -0400
Subject: [PATCH 19/57] Add ability to regenerate robot account credentials

---
 ...9f_add_log_kind_for_regenerating_robot_.py |  36 ++++++++++
 data/model/legacy.py                          |  33 ++++++++-
 endpoints/api/robot.py                        |  55 ++++++++++++++
 initdb.py                                     |   4 +-
 static/css/quay.css                           |  16 +++++
 static/directives/docker-auth-dialog.html     |  17 ++++-
 static/directives/robots-manager.html         |   2 +-
 static/js/app.js                              |  32 ++++++++-
 test/data/test.db                             | Bin 614400 -> 614400 bytes
 test/test_api_security.py                     |  68 +++++++++++++++++-
 test/test_api_usage.py                        |  52 +++++++++++++-
 11 files changed, 307 insertions(+), 8 deletions(-)
 create mode 100644 data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py

diff --git a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
new file mode 100644
index 000000000..2c91902f0
--- /dev/null
+++ b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
@@ -0,0 +1,36 @@
+"""add log kind for regenerating robot tokens
+
+Revision ID: 43e943c0639f
+Revises: 82297d834ad
+Create Date: 2014-08-25 17:14:42.784518
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '43e943c0639f'
+down_revision = '82297d834ad'
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
+from data.database import all_models
+
+
+def upgrade():
+    schema = gen_sqlalchemy_metadata(all_models)
+
+    op.bulk_insert(schema.tables['logentrykind'],
+    [
+        {'id': 41, 'name':'regenerate_robot_token'},
+    ])
+
+
+def downgrade():
+    schema = gen_sqlalchemy_metadata(all_models)
+
+    op.execute(
+        (logentrykind.delete()
+            .where(logentrykind.c.name == op.inline_literal('regenerate_robot_token')))
+
+    )
diff --git a/data/model/legacy.py b/data/model/legacy.py
index f415a9d38..866587f7e 100644
--- a/data/model/legacy.py
+++ b/data/model/legacy.py
@@ -180,6 +180,19 @@ def create_robot(robot_shortname, parent):
   except Exception as ex:
     raise DataModelException(ex.message)
 
+def get_robot(robot_shortname, parent):
+  robot_username = format_robot_username(parent.username, robot_shortname)
+  robot = lookup_robot(robot_username)
+
+  if not robot:
+    msg = ('Could not find robot with username: %s' %
+           robot_username)
+    raise InvalidRobotException(msg)
+
+  service = LoginService.get(name='quayrobot')
+  login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
+
+  return robot, login.service_ident
 
 def lookup_robot(robot_username):
   joined = User.select().join(FederatedLogin).join(LoginService)
@@ -190,7 +203,6 @@ def lookup_robot(robot_username):
 
   return found[0]
 
-
 def verify_robot(robot_username, password):
   joined = User.select().join(FederatedLogin).join(LoginService)
   found = list(joined.where(FederatedLogin.service_ident == password,
@@ -203,6 +215,25 @@ def verify_robot(robot_username, password):
 
   return found[0]
 
+def regenerate_robot_token(robot_shortname, parent):
+  robot_username = format_robot_username(parent.username, robot_shortname)
+
+  robot = lookup_robot(robot_username)
+  if not robot:
+    raise InvalidRobotException('Could not find robot with username: %s' %
+                                robot_username)
+
+  password = random_string_generator(length=64)()
+  robot.email = password
+
+  service = LoginService.get(name='quayrobot')
+  login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
+  login.service_ident = password
+
+  login.save()
+  robot.save()
+
+  return robot, password
 
 def delete_robot(robot_username):
   try:
diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py
index df01f1a0d..b52cd4c5b 100644
--- a/endpoints/api/robot.py
+++ b/endpoints/api/robot.py
@@ -35,6 +35,14 @@ class UserRobotList(ApiResource):
 @internal_only
 class UserRobot(ApiResource):
   """ Resource for managing a user's robots. """
+  @require_user_admin
+  @nickname('getUserRobot')
+  def get(self, robot_shortname):
+    """ Returns the user's robot with the specified name. """
+    parent = get_authenticated_user()
+    robot, password = model.get_robot(robot_shortname, parent)
+    return robot_view(robot.username, password)
+
   @require_user_admin
   @nickname('createUserRobot')
   def put(self, robot_shortname):
@@ -79,6 +87,18 @@ class OrgRobotList(ApiResource):
 @related_user_resource(UserRobot)
 class OrgRobot(ApiResource):
   """ Resource for managing an organization's robots. """
+  @require_scope(scopes.ORG_ADMIN)
+  @nickname('getOrgRobot')
+  def get(self, orgname, robot_shortname):
+    """ Returns the organization's robot with the specified name. """
+    permission = AdministerOrganizationPermission(orgname)
+    if permission.can():
+      parent = model.get_organization(orgname)
+      robot, password = model.get_robot(robot_shortname, parent)
+      return robot_view(robot.username, password)
+    
+    raise Unauthorized()
+
   @require_scope(scopes.ORG_ADMIN)
   @nickname('createOrgRobot')
   def put(self, orgname, robot_shortname):
@@ -103,3 +123,38 @@ class OrgRobot(ApiResource):
       return 'Deleted', 204
 
     raise Unauthorized()
+
+
+@resource('/v1/user/robots/<robot_shortname>/regenerate')
+@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
+@internal_only
+class RegenerateUserRobot(ApiResource):
+  """ Resource for regenerate an organization's robot's token. """
+  @require_user_admin
+  @nickname('regenerateUserRobotToken')
+  def post(self, robot_shortname):
+    """ Regenerates the token for a user's robot. """
+    parent = get_authenticated_user()
+    robot, password = model.regenerate_robot_token(robot_shortname, parent)
+    log_action('regenerate_robot_token', parent.username,  {'robot': robot_shortname})
+    return robot_view(robot.username, password)
+
+
+@resource('/v1/organization/<orgname>/robots/<robot_shortname>/regenerate')
+@path_param('orgname', 'The name of the organization')
+@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
+@related_user_resource(RegenerateUserRobot)
+class RegenerateOrgRobot(ApiResource):
+  """ Resource for regenerate an organization's robot's token. """
+  @require_scope(scopes.ORG_ADMIN)
+  @nickname('regenerateOrgRobotToken')
+  def post(self, orgname, robot_shortname):
+    """ Regenerates the token for an organization robot. """
+    permission = AdministerOrganizationPermission(orgname)
+    if permission.can():
+      parent = model.get_organization(orgname)
+      robot, password = model.regenerate_robot_token(robot_shortname, parent)
+      log_action('regenerate_robot_token', orgname,  {'robot': robot_shortname})
+      return robot_view(robot.username, password)
+
+    raise Unauthorized()
diff --git a/initdb.py b/initdb.py
index 7e48ae3af..da41d80d1 100644
--- a/initdb.py
+++ b/initdb.py
@@ -229,13 +229,15 @@ def initialize_database():
   LogEntryKind.create(name='delete_application')
   LogEntryKind.create(name='reset_application_client_secret')
 
-  # Note: These are deprecated.
+  # Note: These next two are deprecated.
   LogEntryKind.create(name='add_repo_webhook')
   LogEntryKind.create(name='delete_repo_webhook')
 
   LogEntryKind.create(name='add_repo_notification')
   LogEntryKind.create(name='delete_repo_notification')
 
+  LogEntryKind.create(name='regenerate_robot_token')
+
   ImageStorageLocation.create(name='local_eu')
   ImageStorageLocation.create(name='local_us')
 
diff --git a/static/css/quay.css b/static/css/quay.css
index e0f3d2a20..721253ab9 100644
--- a/static/css/quay.css
+++ b/static/css/quay.css
@@ -464,6 +464,22 @@ i.toggle-icon:hover {
 
 .docker-auth-dialog .token-dialog-body .well {
   margin-bottom: 0px;
+  position: relative;
+  padding-right: 24px;
+}
+
+.docker-auth-dialog .token-dialog-body .well i.fa-refresh {
+  position: absolute;
+  top: 9px;
+  right: 9px;
+  font-size: 20px;
+  color: gray;
+  transition: all 0.5s ease-in-out;
+  cursor: pointer;
+}
+
+.docker-auth-dialog .token-dialog-body .well i.fa-refresh:hover {
+  color: black;
 }
 
 .docker-auth-dialog .token-view {
diff --git a/static/directives/docker-auth-dialog.html b/static/directives/docker-auth-dialog.html
index 33b4af8cd..e45b8967d 100644
--- a/static/directives/docker-auth-dialog.html
+++ b/static/directives/docker-auth-dialog.html
@@ -10,11 +10,24 @@
        </div>
        <div class="modal-body token-dialog-body">
          <div class="alert alert-info">The docker <u>username</u> is <b>{{ username }}</b> and the <u>password</u> is the token below. You may use any value for email.</div>
-         <div class="well well-sm">
+
+         <div class="well well-sm" ng-show="regenerating">
+           Regenerating Token...
+           <i class="fa fa-refresh fa-spin"></i>
+         </div>
+         
+         <div class="well well-sm" ng-show="!regenerating">
            <input id="token-view" class="token-view" type="text" value="{{ token }}" onClick="this.select();" readonly>
+           <i class="fa fa-refresh" ng-show="supportsRegenerate" ng-click="askRegenerate()"
+              data-title="Regenerate Token"
+              data-placement="left"
+              bs-tooltip></i>
          </div>
        </div>
-       <div class="modal-footer">
+       <div class="modal-footer" ng-show="regenerating">
+         <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+       </div>
+       <div class="modal-footer" ng-show="!regenerating">
          <span class="download-cfg" ng-show="isDownloadSupported()">
            <i class="fa fa-download"></i>
            <a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html
index c696937d2..c11c07cf8 100644
--- a/static/directives/robots-manager.html
+++ b/static/directives/robots-manager.html
@@ -31,7 +31,7 @@
   </div>
 
   <div class="docker-auth-dialog" username="shownRobot.name" token="shownRobot.token"
-       shown="!!shownRobot" counter="showRobotCounter">
+       shown="!!shownRobot" counter="showRobotCounter" supports-regenerate="true" regenerate="regenerateToken(username)">
     <i class="fa fa-wrench"></i> {{ shownRobot.name }}
   </div>
 </div>
diff --git a/static/js/app.js b/static/js/app.js
index ba8eb8a8e..8844007a6 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2385,7 +2385,9 @@ quayApp.directive('dockerAuthDialog', function (Config) {
       'username': '=username',
       'token': '=token',
       'shown': '=shown',
-      'counter': '=counter'
+      'counter': '=counter',
+      'supportsRegenerate': '@supportsRegenerate',
+      'regenerate': '&regenerate'
     },
     controller: function($scope, $element) {
       var updateCommand = function() {
@@ -2396,6 +2398,15 @@ quayApp.directive('dockerAuthDialog', function (Config) {
       $scope.$watch('username', updateCommand);
       $scope.$watch('token', updateCommand);
 
+      $scope.regenerating = true;
+
+      $scope.askRegenerate = function() {
+        bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) {
+          $scope.regenerating = true;
+          $scope.regenerate({'username': $scope.username, 'token': $scope.token});
+        });
+      };
+
       $scope.isDownloadSupported = function() {
         var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
         if (isSafari) {
@@ -2421,6 +2432,8 @@ quayApp.directive('dockerAuthDialog', function (Config) {
       };
 
       var show = function(r) {
+        $scope.regenerating = false;
+
         if (!$scope.shown || !$scope.username || !$scope.token) {
           $('#dockerauthmodal').modal('hide');
           return;
@@ -2661,6 +2674,8 @@ quayApp.directive('logsView', function () {
             return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}';
           },
 
+          'regenerate_robot_token': 'Regenerated token for robot {robot}',
+
           // Note: These are deprecated.
           'add_repo_webhook': 'Add webhook in repository {repo}',
           'delete_repo_webhook': 'Delete webhook in repository {repo}'
@@ -2704,6 +2719,7 @@ quayApp.directive('logsView', function () {
         'reset_application_client_secret': 'Reset Client Secret',
         'add_repo_notification': 'Add repository notification',
         'delete_repo_notification': 'Delete repository notification',
+        'regenerate_robot_token': 'Regenerate Robot Token',
 
         // Note: these are deprecated.
         'add_repo_webhook': 'Add webhook',
@@ -2875,6 +2891,20 @@ quayApp.directive('robotsManager', function () {
       $scope.shownRobot = null;
       $scope.showRobotCounter = 0;
 
+      $scope.regenerateToken = function(username) {
+        if (!username) { return; }
+
+        var shortName = $scope.getShortenedName(username);
+        ApiService.regenerateRobotToken($scope.organization, null, {'robot_shortname': shortName}).then(function(updated) {
+          var index = $scope.findRobotIndexByName(username);
+          if (index >= 0) {
+            $scope.robots.splice(index, 1);
+            $scope.robots.push(updated);
+          }
+          $scope.shownRobot = updated;
+        }, ApiService.errorDisplay('Cannot regenerate robot account token'));
+      };
+
       $scope.showRobot = function(info) {
         $scope.shownRobot = info;
         $scope.showRobotCounter++;
diff --git a/test/data/test.db b/test/data/test.db
index 4d04283311e6feb845dff3fde9a4d370858119be..34882e11701b07d1a0e1ea1631cdc6636489f033 100644
GIT binary patch
delta 6232
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

delta 6114
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
Xc<2^zX$wud@axOTxx}JZ!p;8!0)7n{

diff --git a/test/test_api_security.py b/test/test_api_security.py
index 7f70d0af6..9a3bcfac3 100644
--- a/test/test_api_security.py
+++ b/test/test_api_security.py
@@ -14,7 +14,9 @@ from endpoints.api.search import FindRepositories, EntitySearch
 from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
 from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
                                  RepositoryBuildList)
-from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
+from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
+                                 RegenerateOrgRobot, RegenerateUserRobot)
+
 from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
                                    TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
                                    BuildTriggerList, BuildTriggerAnalyze)
@@ -1632,6 +1634,19 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
     ApiTestCase.setUp(self)
     self._set_url(OrgRobot, orgname="buynlarge", robot_shortname="Z7PD")
 
+  def test_get_anonymous(self):
+    self._run_test('GET', 401, None, None)
+
+  def test_get_freshuser(self):
+    self._run_test('GET', 403, 'freshuser', None)
+
+  def test_get_reader(self):
+    self._run_test('GET', 403, 'reader', None)
+
+  def test_get_devtable(self):
+    self._run_test('GET', 400, 'devtable', None)
+
+
   def test_put_anonymous(self):
     self._run_test('PUT', 401, None, None)
 
@@ -1644,6 +1659,7 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
   def test_put_devtable(self):
     self._run_test('PUT', 400, 'devtable', None)
 
+
   def test_delete_anonymous(self):
     self._run_test('DELETE', 401, None, None)
 
@@ -3040,6 +3056,19 @@ class TestUserRobot5vdy(ApiTestCase):
     ApiTestCase.setUp(self)
     self._set_url(UserRobot, robot_shortname="robotname")
 
+  def test_get_anonymous(self):
+    self._run_test('GET', 401, None, None)
+
+  def test_get_freshuser(self):
+    self._run_test('GET', 400, 'freshuser', None)
+
+  def test_get_reader(self):
+    self._run_test('GET', 400, 'reader', None)
+
+  def test_get_devtable(self):
+    self._run_test('GET', 400, 'devtable', None)
+
+
   def test_put_anonymous(self):
     self._run_test('PUT', 401, None, None)
 
@@ -3052,6 +3081,7 @@ class TestUserRobot5vdy(ApiTestCase):
   def test_put_devtable(self):
     self._run_test('PUT', 201, 'devtable', None)
 
+
   def test_delete_anonymous(self):
     self._run_test('DELETE', 401, None, None)
 
@@ -3065,6 +3095,42 @@ class TestUserRobot5vdy(ApiTestCase):
     self._run_test('DELETE', 400, 'devtable', None)
 
 
+class TestRegenerateUserRobot(ApiTestCase):
+  def setUp(self):
+    ApiTestCase.setUp(self)
+    self._set_url(RegenerateUserRobot, robot_shortname="robotname")
+
+  def test_post_anonymous(self):
+    self._run_test('POST', 401, None, None)
+
+  def test_post_freshuser(self):
+    self._run_test('POST', 400, 'freshuser', None)
+
+  def test_post_reader(self):
+    self._run_test('POST', 400, 'reader', None)
+
+  def test_post_devtable(self):
+    self._run_test('POST', 400, 'devtable', None)
+
+
+class TestRegenerateOrgRobot(ApiTestCase):
+  def setUp(self):
+    ApiTestCase.setUp(self)
+    self._set_url(RegenerateOrgRobot, orgname="buynlarge", robot_shortname="robotname")
+
+  def test_post_anonymous(self):
+    self._run_test('POST', 401, None, None)
+
+  def test_post_freshuser(self):
+    self._run_test('POST', 403, 'freshuser', None)
+
+  def test_post_reader(self):
+    self._run_test('POST', 403, 'reader', None)
+
+  def test_post_devtable(self):
+    self._run_test('POST', 400, 'devtable', None)
+
+
 class TestOrganizationBuynlarge(ApiTestCase):
   def setUp(self):
     ApiTestCase.setUp(self)
diff --git a/test/test_api_usage.py b/test/test_api_usage.py
index b113f27d0..bd8bb29cd 100644
--- a/test/test_api_usage.py
+++ b/test/test_api_usage.py
@@ -16,7 +16,8 @@ from endpoints.api.tag import RepositoryTagImages, RepositoryTag
 from endpoints.api.search import FindRepositories, EntitySearch
 from endpoints.api.image import RepositoryImage, RepositoryImageList
 from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList
-from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot
+from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
+                                 RegenerateUserRobot, RegenerateOrgRobot)
 from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
                                    TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
                                    BuildTriggerList, BuildTriggerAnalyze)
@@ -1572,6 +1573,30 @@ class TestUserRobots(ApiTestCase):
     robots = self.getRobotNames()
     assert not NO_ACCESS_USER + '+bender' in robots
 
+  def test_regenerate(self):
+    self.login(NO_ACCESS_USER)
+
+    # Create a robot.
+    json = self.putJsonResponse(UserRobot,
+                                params=dict(robot_shortname='bender'),
+                                expected_code=201)
+
+    token = json['token']
+
+    # Regenerate the robot.
+    json = self.postJsonResponse(RegenerateUserRobot,
+                                params=dict(robot_shortname='bender'),
+                                expected_code=200)
+
+    # Verify the token changed.
+    self.assertNotEquals(token, json['token'])
+
+    json2 = self.getJsonResponse(UserRobot,
+                                 params=dict(robot_shortname='bender'),
+                                 expected_code=200)
+
+    self.assertEquals(json['token'], json2['token'])
+
 
 class TestOrgRobots(ApiTestCase):
   def getRobotNames(self):
@@ -1601,6 +1626,31 @@ class TestOrgRobots(ApiTestCase):
     assert not ORGANIZATION + '+bender' in robots
 
 
+  def test_regenerate(self):
+    self.login(ADMIN_ACCESS_USER)
+
+    # Create a robot.
+    json = self.putJsonResponse(OrgRobot,
+                                params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
+                                expected_code=201)
+
+    token = json['token']
+
+    # Regenerate the robot.
+    json = self.postJsonResponse(RegenerateOrgRobot,
+                                params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
+                                expected_code=200)
+
+    # Verify the token changed.
+    self.assertNotEquals(token, json['token'])
+
+    json2 = self.getJsonResponse(OrgRobot,
+                                 params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
+                                 expected_code=200)
+
+    self.assertEquals(json['token'], json2['token'])
+
+
 class TestLogs(ApiTestCase):
   def test_user_logs(self):
     self.login(ADMIN_ACCESS_USER)

From 67905c277eca5901124f54b75a36eef466329e91 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Mon, 25 Aug 2014 19:13:40 -0400
Subject: [PATCH 20/57] Remove webhook worker

---
 Dockerfile.web                  |  3 ---
 conf/init/webhookworker/log/run |  2 --
 conf/init/webhookworker/run     |  8 -------
 workers/webhookworker.py        | 41 ---------------------------------
 4 files changed, 54 deletions(-)
 delete mode 100755 conf/init/webhookworker/log/run
 delete mode 100755 conf/init/webhookworker/run
 delete mode 100644 workers/webhookworker.py

diff --git a/Dockerfile.web b/Dockerfile.web
index 448a7f748..e1d253632 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -39,9 +39,6 @@ ADD conf/init/nginx /etc/service/nginx
 ADD conf/init/diffsworker /etc/service/diffsworker
 ADD conf/init/notificationworker /etc/service/notificationworker
 
-# TODO: Remove this after the prod CL push
-ADD conf/init/webhookworker /etc/service/webhookworker
-
 # Download any external libs.
 RUN mkdir static/fonts static/ldn
 RUN venv/bin/python -m external_libraries
diff --git a/conf/init/webhookworker/log/run b/conf/init/webhookworker/log/run
deleted file mode 100755
index 6738f16f8..000000000
--- a/conf/init/webhookworker/log/run
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec svlogd -t /var/log/webhookworker/
\ No newline at end of file
diff --git a/conf/init/webhookworker/run b/conf/init/webhookworker/run
deleted file mode 100755
index 04521552a..000000000
--- a/conf/init/webhookworker/run
+++ /dev/null
@@ -1,8 +0,0 @@
-#! /bin/bash
-
-echo 'Starting webhook worker'
-
-cd /
-venv/bin/python -m workers.webhookworker
-
-echo 'Webhook worker exited'
\ No newline at end of file
diff --git a/workers/webhookworker.py b/workers/webhookworker.py
deleted file mode 100644
index ccff884c2..000000000
--- a/workers/webhookworker.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import logging
-import argparse
-import requests
-import json
-
-from app import webhook_queue
-from workers.worker import Worker
-
-
-root_logger = logging.getLogger('')
-root_logger.setLevel(logging.DEBUG)
-
-FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s'
-formatter = logging.Formatter(FORMAT)
-
-logger = logging.getLogger(__name__)
-
-
-class WebhookWorker(Worker):
-  def process_queue_item(self, job_details):
-    url = job_details['url']
-    payload = job_details['payload']
-    headers = {'Content-type': 'application/json'}
-
-    try:
-      resp = requests.post(url, data=json.dumps(payload), headers=headers)
-      if resp.status_code/100 != 2:
-        logger.error('%s response for webhook to url: %s' % (resp.status_code,
-                                                             url))
-        return False
-    except requests.exceptions.RequestException as ex:
-      logger.exception('Webhook was unable to be sent: %s' % ex.message)
-      return False
-
-    return True
-
-logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False)  
-
-worker = WebhookWorker(webhook_queue, poll_period_seconds=15,
-                       reservation_seconds=3600)
-worker.start()
\ No newline at end of file

From 510bbe7889e8854ca591473d8d8be7f6aa4eff43 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 26 Aug 2014 12:41:43 -0400
Subject: [PATCH 21/57] Add more check conditions for unhealthy workers and
 make the messaging better.

---
 workers/dockerfilebuild.py | 10 ++++++----
 workers/worker.py          |  2 +-
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/workers/dockerfilebuild.py b/workers/dockerfilebuild.py
index a4de1cc47..9d3e92ae7 100644
--- a/workers/dockerfilebuild.py
+++ b/workers/dockerfilebuild.py
@@ -41,12 +41,13 @@ def matches_system_error(status_str):
   """ Returns true if the given status string matches a known system error in the
       Docker builder.
   """
-  KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied']
+  KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied',
+                   'lxc-start: The container failed']
 
   for match in KNOWN_MATCHES:
-    # 4 because we might have a Unix control code at the start.
-    found = status_str.find(match[0:len(match) + 4])
-    if found >= 0 and found <= 4: 
+    # 10 because we might have a Unix control code at the start.
+    found = status_str.find(match[0:len(match) + 10])
+    if found >= 0 and found <= 10: 
       return True
 
   return False
@@ -613,6 +614,7 @@ class DockerfileBuildWorker(Worker):
 
     except WorkerUnhealthyException as exc:
       # Spawn a notification that the build has failed.
+      log_appender('Worker has become unhealthy. Will retry shortly.', build_logs.ERROR)
       spawn_failure(exc.message, event_data)
       
       # Raise the exception to the queue.
diff --git a/workers/worker.py b/workers/worker.py
index e7750c232..c29d10f41 100644
--- a/workers/worker.py
+++ b/workers/worker.py
@@ -135,8 +135,8 @@ class Worker(object):
 
       except WorkerUnhealthyException:
         logger.error('The worker has encountered an error and will not take new jobs. Job is being requeued.')
-        self._stop.set()
         self.mark_current_incomplete(restore_retry=True)
+        self._stop.set()
 
       finally:
         # Close the db handle periodically

From c1b0b2383a9902f1366c6c1c8dd8b281505a1425 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 26 Aug 2014 15:18:59 -0400
Subject: [PATCH 22/57] Add missing dependency to the builder Dockerfile

---
 Dockerfile.buildworker | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Dockerfile.buildworker b/Dockerfile.buildworker
index c18c24589..04efe38f0 100644
--- a/Dockerfile.buildworker
+++ b/Dockerfile.buildworker
@@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
 ENV HOME /root
 
 # Install the dependencies.
-RUN apt-get update  # 06AUG2014
+RUN apt-get update  # 21AUG2014
 
 # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
-RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev
+RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
 
 # Build the python dependencies
 ADD requirements.txt requirements.txt

From d76d4704a08501abb607d5e43e46c9b801c0f159 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 26 Aug 2014 15:19:39 -0400
Subject: [PATCH 23/57] Add pagination to the notifications API and make the UI
 only show a maximum of 5 notifications (beyond that, it shows "5+").

---
 data/model/legacy.py                        | 12 +++++++---
 endpoints/api/user.py                       | 24 +++++++++++++++-----
 endpoints/notificationevent.py              |  2 ++
 static/css/quay.css                         |  2 +-
 static/directives/header-bar.html           | 16 ++-----------
 static/directives/notification-bar.html     |  5 ++++-
 static/directives/notifications-bubble.html |  7 ++++++
 static/js/app.js                            | 25 +++++++++++++++++++--
 8 files changed, 67 insertions(+), 26 deletions(-)
 create mode 100644 static/directives/notifications-bubble.html

diff --git a/data/model/legacy.py b/data/model/legacy.py
index 866587f7e..cc32b8979 100644
--- a/data/model/legacy.py
+++ b/data/model/legacy.py
@@ -1717,19 +1717,20 @@ def create_notification(kind_name, target, metadata={}):
 
 def create_unique_notification(kind_name, target, metadata={}):
   with config.app_config['DB_TRANSACTION_FACTORY'](db):
-    if list_notifications(target, kind_name).count() == 0:
+    if list_notifications(target, kind_name, limit=1).count() == 0:
       create_notification(kind_name, target, metadata)
 
 
 def lookup_notification(user, uuid):
-  results = list(list_notifications(user, id_filter=uuid, include_dismissed=True))
+  results = list(list_notifications(user, id_filter=uuid, include_dismissed=True, limit=1))
   if not results:
     return None
 
   return results[0]
 
 
-def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False):
+def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False,
+                       page=None, limit=None):
   Org = User.alias()
   AdminTeam = Team.alias()
   AdminTeamMember = TeamMember.alias()
@@ -1767,6 +1768,11 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F
       .switch(Notification)
       .where(Notification.uuid == id_filter))
 
+  if page:
+    query = query.paginate(page, limit)
+  elif limit:
+    query = query.limit(limit)
+
   return query
 
 
diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index 3d79a806d..e2e6a0ff4 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -7,8 +7,9 @@ from flask.ext.principal import identity_changed, AnonymousIdentity
 
 from app import app, billing as stripe, authentication
 from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
-                           log_action, internal_only, NotFound, require_user_admin,
-                           InvalidToken, require_scope, format_date, hide_if, show_if, license_error)
+                           log_action, internal_only, NotFound, require_user_admin, parse_args,
+                           query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
+                           license_error)
 from endpoints.api.subscribe import subscribe
 from endpoints.common import common_login
 from data import model
@@ -403,11 +404,24 @@ class Recovery(ApiResource):
 @internal_only
 class UserNotificationList(ApiResource):
   @require_user_admin
+  @parse_args
+  @query_param('page', 'Offset page number. (int)', type=int, default=0)
+  @query_param('limit', 'Limit on the number of results (int)', type=int, default=5)
   @nickname('listUserNotifications')
-  def get(self):
-    notifications = model.list_notifications(get_authenticated_user())
+  def get(self, args):
+    page = args['page']
+    limit = args['limit']
+
+    notifications = list(model.list_notifications(get_authenticated_user(), page=page, limit=limit + 1))
+    has_more = False
+
+    if len(notifications) > limit:
+      has_more = True
+      notifications = notifications[0:limit]
+
     return {
-      'notifications': [notification_view(notification) for notification in notifications]
+      'notifications': [notification_view(notification) for notification in notifications],
+      'additional': has_more
     }
 
 
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/static/css/quay.css b/static/css/quay.css
index 721253ab9..224029444 100644
--- a/static/css/quay.css
+++ b/static/css/quay.css
@@ -745,7 +745,7 @@ i.toggle-icon:hover {
 }
 
 .user-notification.notification-animated {
-  width: 21px;
+  min-width: 21px;
 
   transform: scale(0);
   -moz-transform: scale(0);
diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html
index d440e3a86..3f395b34d 100644
--- a/static/directives/header-bar.html
+++ b/static/directives/header-bar.html
@@ -37,15 +37,7 @@
       <a href="javascript:void(0)" class="dropdown-toggle user-dropdown user-view" data-toggle="dropdown">
         <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
         {{ user.username }}
-        <span class="badge user-notification notification-animated"
-              ng-show="notificationService.notifications.length"
-              ng-class="notificationService.notificationClasses"
-              bs-tooltip=""
-              data-title="User Notifications"
-              data-placement="left"
-              data-container="body">
-          {{ notificationService.notifications.length }}
-        </span>
+        <span class="notifications-bubble"></span>
         <b class="caret"></b>
       </a>
       <ul class="dropdown-menu">
@@ -58,11 +50,7 @@
           <a href="javascript:void(0)" data-template="/static/directives/notification-bar.html"
              data-animation="am-slide-right" bs-aside="aside" data-container="body">
             Notifications
-            <span class="badge user-notification"
-                  ng-class="notificationService.notificationClasses"
-                  ng-show="notificationService.notifications.length">
-              {{ notificationService.notifications.length }}
-            </span>
+            <span class="notifications-bubble"></span>
           </a>
         </li>
         <li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
diff --git a/static/directives/notification-bar.html b/static/directives/notification-bar.html
index 5d25a40b4..c6841a7f5 100644
--- a/static/directives/notification-bar.html
+++ b/static/directives/notification-bar.html
@@ -3,7 +3,10 @@
     <div class="aside-content">
       <div class="aside-header">
         <button type="button" class="close" ng-click="$hide()">&times;</button>
-        <h4 class="aside-title">Notifications</h4>
+        <h4 class="aside-title">
+          Notifications
+          <span class="notifications-bubble"></span>
+        </h4>
       </div>
       <div class="aside-body">
         <div ng-repeat="notification in notificationService.notifications">
diff --git a/static/directives/notifications-bubble.html b/static/directives/notifications-bubble.html
new file mode 100644
index 000000000..cf10cccf2
--- /dev/null
+++ b/static/directives/notifications-bubble.html
@@ -0,0 +1,7 @@
+<span class="notifications-bubble-element">
+  <span class="badge user-notification notification-animated"
+        ng-show="notificationService.notifications.length"
+        ng-class="notificationService.notificationClasses">
+    {{ notificationService.notifications.length }}<span ng-if="notificationService.additionalNotifications">+</span>
+  </span>
+</span>
diff --git a/static/js/app.js b/static/js/app.js
index 8844007a6..0c91c7c6d 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1155,7 +1155,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
         'user': null,
         'notifications': [],
         'notificationClasses': [],
-        'notificationSummaries': []
+        'notificationSummaries': [],
+        'additionalNotifications': false
       };
 
       var pollTimerHandle = null;
@@ -1251,7 +1252,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
           'uuid': notification.id
         };
         
-        ApiService.updateUserNotification(notification, params);
+        ApiService.updateUserNotification(notification, params, function() {
+          notificationService.update();
+        }, ApiService.errorDisplay('Could not update notification'));
 
         var index = $.inArray(notification, notificationService.notifications);
         if (index >= 0) {
@@ -1308,6 +1311,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
 
         ApiService.listUserNotifications().then(function(resp) {
           notificationService.notifications = resp['notifications'];
+          notificationService.additionalNotifications = resp['additional'];
           notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications);
         });
       };
@@ -5021,6 +5025,23 @@ quayApp.directive('twitterView', function () {
 });
 
 
+quayApp.directive('notificationsBubble', function () {
+  var directiveDefinitionObject = {
+    priority: 0,
+    templateUrl: '/static/directives/notifications-bubble.html',
+    replace: false,
+    transclude: false,
+    restrict: 'C',
+    scope: {
+    },
+    controller: function($scope, UserService, NotificationService) {
+      $scope.notificationService = NotificationService;
+    }
+  };
+  return directiveDefinitionObject;
+});
+
+
 quayApp.directive('notificationView', function () {
   var directiveDefinitionObject = {
     priority: 0,

From 97aa2c5aaa68c9e3f45686707057917c884829a2 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Wed, 27 Aug 2014 13:04:31 -0400
Subject: [PATCH 24/57] Make sure the regen confirm dialog result is actually
 used :-/

---
 static/js/app.js | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index 0c91c7c6d..c26e5800d 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2406,8 +2406,10 @@ quayApp.directive('dockerAuthDialog', function (Config) {
 
       $scope.askRegenerate = function() {
         bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) {
-          $scope.regenerating = true;
-          $scope.regenerate({'username': $scope.username, 'token': $scope.token});
+          if (resp) {
+            $scope.regenerating = true;
+            $scope.regenerate({'username': $scope.username, 'token': $scope.token});
+          }
         });
       };
 

From 551539dbc582de9096dca37ff256a7a35be8c715 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 27 Aug 2014 16:41:30 -0400
Subject: [PATCH 25/57] Update the nginx config to allow for request bodies up
 to 20gb.

---
 conf/server-base.conf | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/conf/server-base.conf b/conf/server-base.conf
index 6aeaa689e..a13cf1424 100644
--- a/conf/server-base.conf
+++ b/conf/server-base.conf
@@ -1,4 +1,4 @@
-client_max_body_size 8G;
+client_max_body_size 20G;
 client_body_temp_path /var/log/nginx/client_body 1 2;
 server_name _;
 

From 463a3c55c3ed70220dfd402cea94d4b46ca1945f Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Wed, 27 Aug 2014 19:02:53 -0400
Subject: [PATCH 26/57] Make worker error messages more descriptive

---
 workers/dockerfilebuild.py | 1 +
 workers/worker.py          | 8 ++++----
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/workers/dockerfilebuild.py b/workers/dockerfilebuild.py
index 9d3e92ae7..2b79aa084 100644
--- a/workers/dockerfilebuild.py
+++ b/workers/dockerfilebuild.py
@@ -478,6 +478,7 @@ class DockerfileBuildWorker(Worker):
                          container['Id'], container['Command'])
           docker_cl.kill(container['Id'])
           self._timeout.set()
+
     except ConnectionError as exc:
       raise WorkerUnhealthyException(exc.message)
 
diff --git a/workers/worker.py b/workers/worker.py
index c29d10f41..57d4a02d0 100644
--- a/workers/worker.py
+++ b/workers/worker.py
@@ -102,8 +102,8 @@ class Worker(object):
     logger.debug('Running watchdog.')
     try:
       self.watchdog()
-    except WorkerUnhealthyException:
-      logger.error('The worker has encountered an error and will not take new jobs.')
+    except WorkerUnhealthyException as exc:
+      logger.error('The worker has encountered an error via watchdog and will not take new jobs: %s' % exc.message)
       self.mark_current_incomplete(restore_retry=True)
       self._stop.set()
 
@@ -133,8 +133,8 @@ class Worker(object):
         logger.warning('An error occurred processing request: %s', current_queue_item.body)
         self.mark_current_incomplete(restore_retry=False)
 
-      except WorkerUnhealthyException:
-        logger.error('The worker has encountered an error and will not take new jobs. Job is being requeued.')
+      except WorkerUnhealthyException as exc:
+        logger.error('The worker has encountered an error via the job and will not take new jobs: %s' % exc.message)
         self.mark_current_incomplete(restore_retry=True)
         self._stop.set()
 

From 5744f0f8881d56c645eea49b27910de7396139fb Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 28 Aug 2014 16:07:56 -0400
Subject: [PATCH 27/57] Make the dockerfilebuild error checking less harsh

---
 workers/dockerfilebuild.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/workers/dockerfilebuild.py b/workers/dockerfilebuild.py
index 2b79aa084..b373a00a9 100644
--- a/workers/dockerfilebuild.py
+++ b/workers/dockerfilebuild.py
@@ -41,8 +41,7 @@ def matches_system_error(status_str):
   """ Returns true if the given status string matches a known system error in the
       Docker builder.
   """
-  KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied',
-                   'lxc-start: The container failed']
+  KNOWN_MATCHES = ['lxc-start: invalid', 'lxc-start: failed to', 'lxc-start: Permission denied']
 
   for match in KNOWN_MATCHES:
     # 10 because we might have a Unix control code at the start.

From 5028172c51a4b5ef27b03399c7a4583e6ff9fade Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 28 Aug 2014 16:10:06 -0400
Subject: [PATCH 28/57] Fix Stripe dialog in IE and mobile safari

---
 static/js/app.js | 37 +++++++++++++++++++++++++++++++------
 1 file changed, 31 insertions(+), 6 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index c26e5800d..51434ce39 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1543,7 +1543,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
         });
       };
 
-      planService.changePlan = function($scope, orgname, planId, callbacks) {
+      planService.changePlan = function($scope, orgname, planId, callbacks, opt_async) {
         if (!Features.BILLING) { return; }
 
         if (callbacks['started']) {
@@ -1556,7 +1556,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
           planService.getCardInfo(orgname, function(cardInfo) {
             if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) {
               var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)';
-              planService.showSubscribeDialog($scope, orgname, planId, callbacks, title);
+              planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true);
               return;
             }
         
@@ -1629,9 +1629,34 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
         return email;
       };
 
-      planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title) {
+      planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) {
         if (!Features.BILLING) { return; }
 
+        // If the async parameter is true and this is a browser that does not allow async popup of the
+        // Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead.
+        var isIE = navigator.appName.indexOf("Internet Explorer") != -1;
+        var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/);
+
+        if (opt_async && (isIE || isMobileSafari)) {
+          bootbox.dialog({
+            "message": "Please click 'Subscribe' to continue",
+            "buttons": {
+              "subscribe": {
+                "label": "Subscribe",
+                "className": "btn-primary",
+                "callback": function() {
+                  planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false);
+                }
+              },
+              "close": {
+                "label": "Cancel",
+                "className": "btn-default"
+              }
+            }
+          });          
+          return;
+        }
+
         if (callbacks['opening']) {
           callbacks['opening']();
         }
@@ -3904,7 +3929,7 @@ quayApp.directive('planManager', function () {
         return true;
       };
 
-      $scope.changeSubscription = function(planId) {
+      $scope.changeSubscription = function(planId, opt_async) {
         if ($scope.planChanging) { return; }
 
         var callbacks = {
@@ -3918,7 +3943,7 @@ quayApp.directive('planManager', function () {
           }
         };
 
-        PlanService.changePlan($scope, $scope.organization, planId, callbacks);
+        PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async);
       };
 
       $scope.cancelSubscription = function() {
@@ -3981,7 +4006,7 @@ quayApp.directive('planManager', function () {
           if ($scope.readyForPlan) {
             var planRequested = $scope.readyForPlan();
             if (planRequested && planRequested != PlanService.getFreePlan()) {
-              $scope.changeSubscription(planRequested);
+              $scope.changeSubscription(planRequested, /* async */true);
             }
           }
         });

From 85ab7a8c8dd73ec126ae83ff0550e1099c96cf48 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 28 Aug 2014 18:40:33 -0400
Subject: [PATCH 29/57] Fix migration downgrade for the regenerating robot kind

---
 .../43e943c0639f_add_log_kind_for_regenerating_robot_.py         | 1 +
 1 file changed, 1 insertion(+)

diff --git a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
index 2c91902f0..6ee041e4c 100644
--- a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
+++ b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
@@ -29,6 +29,7 @@ def upgrade():
 def downgrade():
     schema = gen_sqlalchemy_metadata(all_models)
 
+    logentrykind = schema.tables['logentrykind']
     op.execute(
         (logentrykind.delete()
             .where(logentrykind.c.name == op.inline_literal('regenerate_robot_token')))

From ce7e3a8733037a38fbb4e84cca76c98e57768a88 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Fri, 29 Aug 2014 13:16:32 -0400
Subject: [PATCH 30/57] Do not link against layers that are still marked as
 uploading, there is no guarantee that they will ever be completed and their
 ancestry may be incomplete.

---
 data/model/legacy.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/data/model/legacy.py b/data/model/legacy.py
index cc32b8979..52723bd11 100644
--- a/data/model/legacy.py
+++ b/data/model/legacy.py
@@ -1037,7 +1037,8 @@ def find_create_or_link_image(docker_image_id, repository, username, translation
       .join(Repository)
       .join(Visibility)
       .switch(Repository)
-      .join(RepositoryPermission, JOIN_LEFT_OUTER))
+      .join(RepositoryPermission, JOIN_LEFT_OUTER)
+      .where(ImageStorage.uploading == False))
 
     query = (_filter_to_repos_for_user(query, username)
       .where(Image.docker_image_id == docker_image_id))

From 584f6b9635c8d073292747ffcb2a16a8dd00f1df Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 29 Aug 2014 13:59:54 -0400
Subject: [PATCH 31/57] Add a spinner when a tag is being deleted

---
 static/js/controllers.js | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/static/js/controllers.js b/static/js/controllers.js
index 41e1443ea..4d1c8484f 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -523,16 +523,24 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
 
   $scope.deleteTag = function(tagName) {
     if (!$scope.repo.can_admin) { return; }
-    $('#confirmdeleteTagModal').modal('hide');
 
     var params = {
       'repository': namespace + '/' + name,
       'tag': tagName
     };
 
+    var errorHandler = ApiService.errorDisplay('Cannot delete tag', function() {
+      $('#confirmdeleteTagModal').modal('hide');
+      $scope.deletingTag = false;
+    });
+
+    $scope.deletingTag = true;
+
     ApiService.deleteFullTag(null, params).then(function() {
       loadViewInfo();
-    }, ApiService.errorDisplay('Cannot delete tag'));
+      $('#confirmdeleteTagModal').modal('hide');
+      $scope.deletingTag = false;
+    }, errorHandler);
   };
 
   $scope.getImagesForTagBySize = function(tag) {

From d1b2ff588af1beb02910b0f247c2ad30cd9e913b Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 29 Aug 2014 14:00:07 -0400
Subject: [PATCH 32/57] Add a spinner when a tag is being deleted

---
 static/partials/view-repo.html | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html
index 4f588ccf2..e5f2cecc6 100644
--- a/static/partials/view-repo.html
+++ b/static/partials/view-repo.html
@@ -391,7 +391,10 @@
           </span>?
         </h4>
       </div>
-      <div class="modal-body">
+      <div class="modal-body" ng-show="deletingTag">
+        <div class="quay-spinner"></div>
+      </div>
+      <div class="modal-body" ng-show="!deletingTag">
         Are you sure you want to delete tag
         <span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'">
           {{ tagToDelete  }}
@@ -401,7 +404,7 @@
           The following images and any other images not referenced by a tag will be deleted:          
         </div>
       </div>
-      <div class="modal-footer">
+      <div class="modal-footer" ng-show="!deletingTag">
         <button type="button" class="btn btn-primary" ng-click="deleteTag(tagToDelete)">Delete Tag</button>
         <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
       </div>

From 2c20fca37e49720afbec7840023d84a31a47842d Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Fri, 29 Aug 2014 14:30:49 -0400
Subject: [PATCH 33/57] Fix sharing tests and add a test to ensure that
 uploading images are not shared.

---
 test/test_image_sharing.py | 86 ++++++++++++++++++++++----------------
 1 file changed, 49 insertions(+), 37 deletions(-)

diff --git a/test/test_image_sharing.py b/test/test_image_sharing.py
index ab278f9f4..ef2458ccc 100644
--- a/test/test_image_sharing.py
+++ b/test/test_image_sharing.py
@@ -46,25 +46,30 @@ class TestImageSharing(unittest.TestCase):
     preferred = storage.preferred_locations[0]
     image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {},
                                             preferred)
-    return image.storage.id
+    image.storage.uploading = False
+    image.storage.save()
+    return image.storage
 
-  def assertSameStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER):
-    new_storage_id = self.createStorage(docker_image_id, repository, username)
-    self.assertEquals(storage_id, new_storage_id)
+  def assertSameStorage(self, docker_image_id, existing_storage, repository=REPO,
+                        username=ADMIN_ACCESS_USER):
+    new_storage = self.createStorage(docker_image_id, repository, username)
+    self.assertEquals(existing_storage.id, new_storage.id)
 
-  def assertDifferentStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER):
-    new_storage_id = self.createStorage(docker_image_id, repository, username)
-    self.assertNotEquals(storage_id, new_storage_id)
+  def assertDifferentStorage(self, docker_image_id, existing_storage, repository=REPO,
+                             username=ADMIN_ACCESS_USER):
+    new_storage = self.createStorage(docker_image_id, repository, username)
+    self.assertNotEquals(existing_storage.id, new_storage.id)
 
 
   def test_same_user(self):
-    """ The same user creates two images, each which should be shared in the same repo. This is a sanity check. """
+    """ The same user creates two images, each which should be shared in the same repo. This is a
+      sanity check. """
 
     # Create a reference to a new docker ID => new image.
-    first_storage_id = self.createStorage('first-image')
+    first_storage = self.createStorage('first-image')
     
     # Create a reference to the same docker ID => same image.
-    self.assertSameStorage('first-image', first_storage_id)
+    self.assertSameStorage('first-image', first_storage)
 
     # Create a reference to another new docker ID => new image.
     second_storage_id = self.createStorage('second-image')
@@ -73,68 +78,68 @@ class TestImageSharing(unittest.TestCase):
     self.assertSameStorage('second-image', second_storage_id)
 
     # Make sure the images are different.
-    self.assertNotEquals(first_storage_id, second_storage_id)
+    self.assertNotEquals(first_storage, second_storage_id)
 
 
   def test_no_user_private_repo(self):
     """ If no user is specified (token case usually), then no sharing can occur on a private repo. """
     # Create a reference to a new docker ID => new image.
-    first_storage_id = self.createStorage('the-image', username=None, repository=SHARED_REPO)
+    first_storage = self.createStorage('the-image', username=None, repository=SHARED_REPO)
 
     # Create a areference to the same docker ID, but since no username => new image.
-    self.assertDifferentStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO)
+    self.assertDifferentStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
 
 
   def test_no_user_public_repo(self):
     """ If no user is specified (token case usually), then no sharing can occur on a private repo except when the image is first public. """
     # Create a reference to a new docker ID => new image.
-    first_storage_id = self.createStorage('the-image', username=None, repository=PUBLIC_REPO)
+    first_storage = self.createStorage('the-image', username=None, repository=PUBLIC_REPO)
 
     # Create a areference to the same docker ID. Since no username, we'd expect different but the first image is public so => shaed image.
-    self.assertSameStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO)
+    self.assertSameStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
 
 
   def test_different_user_same_repo(self):
     """ Two different users create the same image in the same repo. """
 
     # Create a reference to a new docker ID under the first user => new image.
-    first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO)
+    first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO)
 
     # Create a reference to the *same* docker ID under the second user => same image.
-    self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
+    self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
 
 
   def test_different_repo_no_shared_access(self):
     """ Neither user has access to the other user's repository. """
 
     # Create a reference to a new docker ID under the first user => new image.
-    first_storage_id = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO)
+    first_storage = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO)
 
     # Create a reference to the *same* docker ID under the second user => new image.
     second_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
 
     # Verify that the users do not share storage.
-    self.assertNotEquals(first_storage_id, second_storage_id)
+    self.assertNotEquals(first_storage, second_storage_id)
 
 
   def test_public_than_private(self):
     """ An image is created publicly then used privately, so it should be shared. """
 
     # Create a reference to a new docker ID under the first user => new image.
-    first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO)
+    first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO)
 
     # Create a reference to the *same* docker ID under the second user => same image, since the first was public.
-    self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO)
+    self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
 
 
   def test_private_than_public(self):
     """ An image is created privately then used publicly, so it should *not* be shared. """
 
     # Create a reference to a new docker ID under the first user => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
 
     # Create a reference to the *same* docker ID under the second user => new image, since the first was private.
-    self.assertDifferentStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
+    self.assertDifferentStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
 
 
   def test_different_repo_with_access(self):
@@ -143,64 +148,71 @@ class TestImageSharing(unittest.TestCase):
         be shared since the user has access.
     """
     # Create the image in the shared repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
 
     # Create the image in the other user's repo, but since the user (PUBLIC) still has access to the shared
     # repository, they should reuse the storage.
-    self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
+    self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
 
 
   def test_org_access(self):
     """ An image is accessible by being a member of the organization. """
 
     # Create the new image under the org's repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
 
     # Create an image under the user's repo, but since the user has access to the organization => shared image.
-    self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO)
+    self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
 
     # Ensure that the user's robot does not have access, since it is not on the permissions list for the repo.
-    self.assertDifferentStorage('the-image', first_storage_id, username=ADMIN_ROBOT_USER, repository=SHARED_REPO)
+    self.assertDifferentStorage('the-image', first_storage, username=ADMIN_ROBOT_USER, repository=SHARED_REPO)
 
 
   def test_org_access_different_user(self):
     """ An image is accessible by being a member of the organization. """
 
     # Create the new image under the org's repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
 
     # Create an image under a user's repo, but since the user has access to the organization => shared image.
-    self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO)
+    self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
 
     # Also verify for reader.
-    self.assertSameStorage('the-image', first_storage_id, username=READ_ACCESS_USER, repository=PUBLIC_REPO)
+    self.assertSameStorage('the-image', first_storage, username=READ_ACCESS_USER, repository=PUBLIC_REPO)
 
 
   def test_org_no_access(self):
     """ An image is not accessible if not a member of the organization. """
 
     # Create the new image under the org's repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
 
     # Create an image under a user's repo. Since the user is not a member of the organization => new image.
-    self.assertDifferentStorage('the-image', first_storage_id, username=RANDOM_USER, repository=RANDOM_REPO)
+    self.assertDifferentStorage('the-image', first_storage, username=RANDOM_USER, repository=RANDOM_REPO)
 
 
   def test_org_not_team_member_with_access(self):
     """ An image is accessible to a user specifically listed as having permission on the org repo. """
 
     # Create the new image under the org's repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
 
     # Create an image under a user's repo. Since the user has read access on that repo, they can see the image => shared image.
-    self.assertSameStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
+    self.assertSameStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
 
 
   def test_org_not_team_member_with_no_access(self):
     """ A user that has access to one org repo but not another and is not a team member. """
 
     # Create the new image under the org's repo => new image.
-    first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO)
+    first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO)
 
     # Create an image under a user's repo. The user doesn't have access to the repo (ANOTHER_ORG_REPO) so => new image.
-    self.assertDifferentStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
+    self.assertDifferentStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
+
+  def test_no_link_to_uploading(self):
+    still_uploading = self.createStorage('an-image', repository=PUBLIC_REPO)
+    still_uploading.uploading = True
+    still_uploading.save()
+
+    self.assertDifferentStorage('an-image', still_uploading)

From 417fec0b68617f3c93b224a6ddbcf30a55d2f5da Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 29 Aug 2014 15:46:43 -0400
Subject: [PATCH 34/57] Fix namespace selector bug from the landing page and
 make the namespace selector update the URL if need be

---
 static/js/app.js                    | 8 ++++++--
 static/partials/landing-login.html  | 2 +-
 static/partials/landing-normal.html | 2 +-
 3 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index 51434ce39..dfdb9a879 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1749,7 +1749,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
       when('/repository/:namespace/:name/build', {templateUrl: '/static/partials/repo-build.html', controller:RepoBuildCtrl, reloadOnSearch: false}).
       when('/repository/:namespace/:name/build/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}).
       when('/repository/', {title: 'Repositories', description: 'Public and private docker repositories list',
-                            templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
+                            templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl, reloadOnSearch: false}).
       when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html',
                       reloadOnSearch: false, controller: UserAdminCtrl}).
       when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html',
@@ -4037,7 +4037,7 @@ quayApp.directive('namespaceSelector', function () {
       'namespace': '=namespace',
       'requireCreate': '=requireCreate'
     },
-    controller: function($scope, $element, $routeParams, CookieService) {
+    controller: function($scope, $element, $routeParams, $location, CookieService) {
       $scope.namespaces = {};
 
       $scope.initialize = function(user) {
@@ -4074,6 +4074,10 @@ quayApp.directive('namespaceSelector', function () {
 
         if (newNamespace) {
           CookieService.putPermanent('quay.namespace', newNamespace);
+
+          if ($routeParams['namespace'] && $routeParams['namespace'] != newNamespace) {
+            $location.search({'namespace': newNamespace});
+          }
         }
       };
 
diff --git a/static/partials/landing-login.html b/static/partials/landing-login.html
index e2500815a..0a3046d2a 100644
--- a/static/partials/landing-login.html
+++ b/static/partials/landing-login.html
@@ -24,7 +24,7 @@
                   <a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
                   <div class="markdown-view description" content="repository.description" first-line-only="true"></div>
                 </div>
-                <a href="/repository/?namespace={{ user.username }}">See All Repositories</a>
+                <a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
               </div>
 
               <!-- No Repos -->
diff --git a/static/partials/landing-normal.html b/static/partials/landing-normal.html
index 8a0badad1..6b9b6e42e 100644
--- a/static/partials/landing-normal.html
+++ b/static/partials/landing-normal.html
@@ -34,7 +34,7 @@
                   <a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
                   <div class="markdown-view description" content="repository.description" first-line-only="true"></div>
                 </div>
-                <a href="/repository/?namespace={{ user.username }}">See All Repositories</a>
+                <a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
               </div>
 
               <!-- No Repos -->

From 07c7cdd51d53a3b5009192bae3cd69b927f6f823 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Fri, 29 Aug 2014 16:25:11 -0400
Subject: [PATCH 35/57] Fix PingService when loading results from cache

---
 static/js/app.js | 53 +++++++++++++++++++++++++++---------------------
 1 file changed, 30 insertions(+), 23 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index dfdb9a879..f5c612c5f 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -441,6 +441,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
       var pingService = {};
       var pingCache = {};
 
+      var invokeCallback = function($scope, pings, callback) {
+        if (pings[0] == -1) {
+          setTimeout(function() {
+            $scope.$apply(function() {
+              callback(-1, false, -1);
+            });
+          }, 0);
+          return;
+        }
+
+        var sum = 0;
+        for (var i = 0; i < pings.length; ++i) {
+          sum += pings[i];
+        }
+
+        // Report the average ping.
+        setTimeout(function() {
+          $scope.$apply(function() {
+            callback(Math.floor(sum / pings.length), true, pings.length);
+          });
+        }, 0);
+      };
+
       var reportPingResult = function($scope, url, ping, callback) {
         // Lookup the cached ping data, if any.
         var cached = pingCache[url];
@@ -453,28 +476,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
         // If an error occurred, report it and done.
         if (ping < 0) {
           cached['pings'] = [-1];
-          setTimeout(function() {
-            $scope.$apply(function() {
-              callback(-1, false, -1);
-            });
-          }, 0);
+          invokeCallback($scope, pings, callback);
           return;
         }
 
         // Otherwise, add the current ping and determine the average.
         cached['pings'].push(ping);
 
-        var sum = 0;
-        for (var i = 0; i < cached['pings'].length; ++i) {
-          sum += cached['pings'][i];
-        }
-
-        // Report the average ping.
-        setTimeout(function() {
-          $scope.$apply(function() {
-            callback(Math.floor(sum / cached['pings'].length), true, cached['pings'].length);
-          });
-        }, 0);
+        // Invoke the callback.
+        invokeCallback($scope, cached['pings'], callback);
 
         // Schedule another check if we've done less than three.
         if (cached['pings'].length < 3) {
@@ -510,12 +520,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
 
       pingService.pingUrl = function($scope, url, callback) {
         if (pingCache[url]) {
-          cached = pingCache[url];
-          setTimeout(function() {
-            $scope.$apply(function() {
-              callback(cached.result, cached.success);
-            });
-          }, 0);
+          invokeCallback($scope, pingCache[url]['pings'], callback);          
           return;
         }
 
@@ -5401,7 +5406,9 @@ quayApp.directive('locationView', function () {
 
       $scope.getLocationTooltip = function(location, ping) {
 	var tip = $scope.getLocationTitle(location) + '<br>';
-	if (ping < 0) {
+        if (ping == null) {
+	  tip += '(Loading)';
+        } else if (ping < 0) {
 	  tip += '<br><b>Note: Could not contact server</b>';
 	} else {
 	  tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)');

From 066b3ed8f042701a0a5cbd394021f9f37b4dc7af Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 2 Sep 2014 14:26:35 -0400
Subject: [PATCH 36/57] Add client side handling of user login throttling

---
 static/directives/signin-form.html | 29 +++++++++++++++----------
 static/js/app.js                   | 35 +++++++++++++++++++++++++++---
 2 files changed, 50 insertions(+), 14 deletions(-)

diff --git a/static/directives/signin-form.html b/static/directives/signin-form.html
index f56b8f8db..d8f822968 100644
--- a/static/directives/signin-form.html
+++ b/static/directives/signin-form.html
@@ -4,18 +4,25 @@
            placeholder="Username or E-mail Address" ng-model="user.username" autofocus>
     <input type="password" class="form-control input-lg" name="password"
            placeholder="Password" ng-model="user.password">
-    <button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>  
-    
-    <span class="social-alternate" quay-require="['GITHUB_LOGIN']">
-      <i class="fa fa-circle"></i>
-      <span class="inner-text">OR</span>
-    </span>
 
-    <a id="github-signin-link" class="btn btn-primary btn-lg btn-block" href="javascript:void(0)" ng-click="showGithub()"
-       quay-require="['GITHUB_LOGIN']">
-      <i class="fa fa-github fa-lg"></i> Sign In with GitHub
-    </a>
-  </form> 
+    <div class="alert alert-warning" ng-show="tryAgainSoon > 0">
+      Too many attempts have been made to login. Please try again in {{ tryAgainSoon }} second<span ng-if="tryAgainSoon != 1">s</span>.
+    </div>
+
+    <span ng-show="tryAgainSoon == 0">
+      <button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>  
+      
+      <span class="social-alternate" quay-require="['GITHUB_LOGIN']">
+        <i class="fa fa-circle"></i>
+        <span class="inner-text">OR</span>
+      </span>
+
+      <a id="github-signin-link" class="btn btn-primary btn-lg btn-block" href="javascript:void(0)" ng-click="showGithub()"
+         quay-require="['GITHUB_LOGIN']">
+        <i class="fa fa-github fa-lg"></i> Sign In with GitHub
+      </a>
+    </span>
+  </form>
 
   <div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
   <div class="alert alert-danger" ng-show="needsEmailVerification">
diff --git a/static/js/app.js b/static/js/app.js
index f5c612c5f..5fb21205b 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2245,7 +2245,10 @@ quayApp.directive('signinForm', function () {
       'signInStarted': '&signInStarted',
       'signedIn': '&signedIn'
     },
-    controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) {
+    controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) {
+      $scope.tryAgainSoon = 0;
+      $scope.tryAgainInterval = null;
+
       $scope.showGithub = function() {
         if (!Features.GITHUB_LOGIN) { return; }
 
@@ -2275,7 +2278,15 @@ quayApp.directive('signinForm', function () {
        }
       };
 
+      $scope.$on('$destroy', function() {
+        if ($scope.tryAgainInterval) {
+          $interval.cancel($scope.tryAgainInterval);
+        }
+      });
+
       $scope.signin = function() {
+        if ($scope.tryAgainSoon > 0) { return; }
+
         $scope.markStarted();
 
         ApiService.signinUser($scope.user).then(function() {
@@ -2298,8 +2309,26 @@ quayApp.directive('signinForm', function () {
            $location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
           }, 500);
         }, function(result) {
-          $scope.needsEmailVerification = result.data.needsEmailVerification;
-          $scope.invalidCredentials = result.data.invalidCredentials;
+          if (result.status == 429 /* try again later */) {
+            $scope.tryAgainSoon = result.headers('Retry-After');
+
+            // Cancel any existing interval.
+            if ($scope.tryAgainInterval) {
+              $interval.cancel($scope.tryAgainInterval);
+            }
+
+            // Setup a new interval.
+            $scope.tryAgainInterval = $interval(function() {              
+              $scope.tryAgainSoon--;
+              if ($scope.tryAgainSoon <= 0) {
+                $scope.tryAgainInterval = null;
+                $scope.tryAgainSoon = 0;
+              }
+            }, 1000, $scope.tryAgainSoon);
+          } else {
+            $scope.needsEmailVerification = result.data.needsEmailVerification;
+            $scope.invalidCredentials = result.data.invalidCredentials;
+          }
         });
       };
     }

From 2dcdd7ba5b93d6deece46760c5b219f21be264fe Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Tue, 2 Sep 2014 15:27:05 -0400
Subject: [PATCH 37/57] Add exponential backoff of login attempts.

---
 data/database.py          |   2 ++
 data/model/legacy.py      |  32 +++++++++++++++++++++++++++++++-
 endpoints/api/__init__.py |   8 ++++++++
 test/data/test.db         | Bin 614400 -> 231424 bytes
 util/backoff.py           |   5 +++++
 5 files changed, 46 insertions(+), 1 deletion(-)
 create mode 100644 util/backoff.py

diff --git a/data/database.py b/data/database.py
index 349ad1b58..69932273d 100644
--- a/data/database.py
+++ b/data/database.py
@@ -76,6 +76,8 @@ class User(BaseModel):
   organization = BooleanField(default=False, index=True)
   robot = BooleanField(default=False, index=True)
   invoice_email = BooleanField(default=False)
+  invalid_login_attempts = IntegerField(default=0)
+  last_invalid_login = DateTimeField(default=datetime.utcnow)
 
 
 class TeamRole(BaseModel):
diff --git a/data/model/legacy.py b/data/model/legacy.py
index 52723bd11..2e703b003 100644
--- a/data/model/legacy.py
+++ b/data/model/legacy.py
@@ -1,12 +1,17 @@
 import bcrypt
 import logging
-import datetime
 import dateutil.parser
 import json
 
+from datetime import datetime, timedelta
+
 from data.database import *
 from util.validation import *
 from util.names import format_robot_username
+from util.backoff import exponential_backoff
+
+
+EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1)
 
 
 logger = logging.getLogger(__name__)
@@ -68,6 +73,12 @@ class TooManyUsersException(DataModelException):
   pass
 
 
+class TooManyLoginAttemptsException(Exception):
+  def __init__(self, message, retry_after):
+    super(TooManyLoginAttemptsException, self).__init__(message)
+    self.retry_after = retry_after
+
+
 def is_create_user_allowed():
   return True
 
@@ -551,11 +562,30 @@ def verify_user(username_or_email, password):
   except User.DoesNotExist:
     return None
 
+  now = datetime.utcnow()
+
+  if fetched.invalid_login_attempts > 0:
+    can_retry_at = exponential_backoff(fetched.invalid_login_attempts, EXPONENTIAL_BACKOFF_SCALE,
+                                       fetched.last_invalid_login)
+
+    if can_retry_at > now:
+      retry_after = can_retry_at - now
+      raise TooManyLoginAttemptsException('Too many login attempts.', retry_after.total_seconds())
+
   if (fetched.password_hash and 
       bcrypt.hashpw(password, fetched.password_hash) ==
       fetched.password_hash):
+
+    if fetched.invalid_login_attempts > 0:
+      fetched.invalid_login_attempts = 0
+      fetched.save()
+
     return fetched
 
+  fetched.invalid_login_attempts += 1
+  fetched.last_invalid_login = now
+  fetched.save()
+
   # We weren't able to authorize the user
   return None
 
diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py
index e8dab28dc..854c3cad1 100644
--- a/endpoints/api/__init__.py
+++ b/endpoints/api/__init__.py
@@ -87,6 +87,14 @@ def handle_api_error(error):
   return response
 
 
+@api_bp.app_errorhandler(model.TooManyLoginAttemptsException)
+@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
+def handle_too_many_login_attempts(error):
+  response = make_response('Too many login attempts', 429)
+  response.headers['Retry-After'] = int(error.retry_after)
+  return response
+
+
 def resource(*urls, **kwargs):
   def wrapper(api_resource):
     if not api_resource:
diff --git a/test/data/test.db b/test/data/test.db
index 34882e11701b07d1a0e1ea1631cdc6636489f033..3e631b5d72e5c2c290e95affc3a9794853a6a573 100644
GIT binary patch
delta 19857
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

delta 21587
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

diff --git a/util/backoff.py b/util/backoff.py
new file mode 100644
index 000000000..15429936e
--- /dev/null
+++ b/util/backoff.py
@@ -0,0 +1,5 @@
+def exponential_backoff(attempts, scaling_factor, base):
+  backoff = 5 * (pow(2, attempts) - 1)
+  backoff_time = backoff * scaling_factor
+  retry_at = backoff_time/10 + base
+  return retry_at

From 2cfab6e252cf298caf067145762dba2acf17cb5a Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 2 Sep 2014 15:28:56 -0400
Subject: [PATCH 38/57] Reshow the sign in button when the username is changed

---
 static/js/app.js | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/static/js/app.js b/static/js/app.js
index 5fb21205b..f44e4294e 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2278,6 +2278,14 @@ quayApp.directive('signinForm', function () {
        }
       };
 
+      $scope.$watch('user.username', function() {
+        $scope.tryAgainSoon = 0;
+
+        if ($scope.tryAgainInterval) {
+          $interval.cancel($scope.tryAgainInterval);
+        }
+      });
+
       $scope.$on('$destroy', function() {
         if ($scope.tryAgainInterval) {
           $interval.cancel($scope.tryAgainInterval);
@@ -2325,6 +2333,9 @@ quayApp.directive('signinForm', function () {
                 $scope.tryAgainSoon = 0;
               }
             }, 1000, $scope.tryAgainSoon);
+
+            $scope.needsEmailVerification = false;
+            $scope.invalidCredentials = false;
           } else {
             $scope.needsEmailVerification = result.data.needsEmailVerification;
             $scope.invalidCredentials = result.data.invalidCredentials;

From 53939f596d3c2d6fd68f74b5d3522628c5aa7995 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Tue, 2 Sep 2014 16:45:25 -0400
Subject: [PATCH 39/57] Properly escape the $ in $token for the auth dialog
 command

---
 static/js/app.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/static/js/app.js b/static/js/app.js
index f5c612c5f..3838d561c 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2425,7 +2425,10 @@ quayApp.directive('dockerAuthDialog', function (Config) {
     },
     controller: function($scope, $element) {
       var updateCommand = function() {
-        $scope.command = 'docker login -e="." -u="' + $scope.username +
+        var escape = function(v) {
+          return v.replace('$', '\\$');
+        };
+        $scope.command = 'docker login -e="." -u="' + escape($scope.username) +
           '" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME'];
       };
 

From 232e3cc1dadddb7451df63386681bc237426ca5a Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Wed, 3 Sep 2014 12:10:36 -0400
Subject: [PATCH 40/57] Move cancelInterval into its own method to remove code
 duplication

---
 static/js/app.js | 31 +++++++++++++++----------------
 1 file changed, 15 insertions(+), 16 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index f44e4294e..1f7eb060f 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2278,24 +2278,29 @@ quayApp.directive('signinForm', function () {
        }
       };
 
-      $scope.$watch('user.username', function() {
+      $scope.cancelInterval = function() {
         $scope.tryAgainSoon = 0;
 
         if ($scope.tryAgainInterval) {
           $interval.cancel($scope.tryAgainInterval);
         }
+
+        $scope.tryAgainInterval = null;
+      };
+
+      $scope.$watch('user.username', function() {
+        $scope.cancelInterval();
       });
 
       $scope.$on('$destroy', function() {
-        if ($scope.tryAgainInterval) {
-          $interval.cancel($scope.tryAgainInterval);
-        }
+        $scope.cancelInterval();
       });
 
       $scope.signin = function() {
         if ($scope.tryAgainSoon > 0) { return; }
 
         $scope.markStarted();
+        $scope.cancelInterval();
 
         ApiService.signinUser($scope.user).then(function() {
           $scope.needsEmailVerification = false;
@@ -2318,24 +2323,18 @@ quayApp.directive('signinForm', function () {
           }, 500);
         }, function(result) {
           if (result.status == 429 /* try again later */) {
+            $scope.needsEmailVerification = false;
+            $scope.invalidCredentials = false;
+
+            $scope.cancelInterval();
+
             $scope.tryAgainSoon = result.headers('Retry-After');
-
-            // Cancel any existing interval.
-            if ($scope.tryAgainInterval) {
-              $interval.cancel($scope.tryAgainInterval);
-            }
-
-            // Setup a new interval.
             $scope.tryAgainInterval = $interval(function() {              
               $scope.tryAgainSoon--;
               if ($scope.tryAgainSoon <= 0) {
-                $scope.tryAgainInterval = null;
-                $scope.tryAgainSoon = 0;
+                $scope.cancelInterval();
               }
             }, 1000, $scope.tryAgainSoon);
-
-            $scope.needsEmailVerification = false;
-            $scope.invalidCredentials = false;
           } else {
             $scope.needsEmailVerification = result.data.needsEmailVerification;
             $scope.invalidCredentials = result.data.invalidCredentials;

From 0bd9ba523e1d5604492fe3cc7b49a4c19d227003 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 3 Sep 2014 13:07:53 -0400
Subject: [PATCH 41/57] Add a migration for the brute force prevention fields
 to the user table.

---
 ...add_brute_force_prevention_metadata_to_.py | 28 +++++++++++++++++++
 1 file changed, 28 insertions(+)
 create mode 100644 data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py

diff --git a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
new file mode 100644
index 000000000..55351a50a
--- /dev/null
+++ b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
@@ -0,0 +1,28 @@
+"""Add brute force prevention metadata to the user table.
+
+Revision ID: 4fdb65816b8d
+Revises: 43e943c0639f
+Create Date: 2014-09-03 12:35:33.722435
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '4fdb65816b8d'
+down_revision = '43e943c0639f'
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default=0))
+    op.add_column('user', sa.Column('last_invalid_login', sa.DateTime(), nullable=False, server_default=sa.func.now()))
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('user', 'last_invalid_login')
+    op.drop_column('user', 'invalid_login_attempts')
+    ### end Alembic commands ###

From 18ec0c3e0ace4c699369bcc73cf59c18c8abcf51 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 3 Sep 2014 13:09:17 -0400
Subject: [PATCH 42/57] Update the audit ancestry tool to not affect pushes in
 progress.

---
 tools/auditancestry.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tools/auditancestry.py b/tools/auditancestry.py
index dce445e4e..95e082642 100644
--- a/tools/auditancestry.py
+++ b/tools/auditancestry.py
@@ -20,7 +20,7 @@ query = (Image
   .join(ImageStorage)
   .switch(Image)
   .join(Repository)
-  .where(Repository.name == 'userportal', Repository.namespace == 'crsinc'))
+  .where(ImageStorage.uploading == False))
 
 bad_count = 0
 good_count = 0

From 21f7acf7ca14a1870336664ce7beaa5723b4cecb Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 3 Sep 2014 13:34:36 -0400
Subject: [PATCH 43/57] Fix the default value for the migration to use a string

---
 .../4fdb65816b8d_add_brute_force_prevention_metadata_to_.py     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
index 55351a50a..a1c8c95dd 100644
--- a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
+++ b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
@@ -16,7 +16,7 @@ from sqlalchemy.dialects import mysql
 
 def upgrade():
     ### commands auto generated by Alembic - please adjust! ###
-    op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default=0))
+    op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default="0"))
     op.add_column('user', sa.Column('last_invalid_login', sa.DateTime(), nullable=False, server_default=sa.func.now()))
     ### end Alembic commands ###
 

From 8910c6ff019f859c3f2f72c4f7f8f996b7f76712 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 3 Sep 2014 13:44:05 -0400
Subject: [PATCH 44/57] Add a migration to remove the webhooks table.

---
 ...2b0ea7a4d_remove_the_old_webhooks_table.py | 35 +++++++++++++++++++
 1 file changed, 35 insertions(+)
 create mode 100644 data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py

diff --git a/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py
new file mode 100644
index 000000000..79ea17be0
--- /dev/null
+++ b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py
@@ -0,0 +1,35 @@
+"""Remove the old webhooks table.
+
+Revision ID: f42b0ea7a4d
+Revises: 4fdb65816b8d
+Create Date: 2014-09-03 13:43:23.391464
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'f42b0ea7a4d'
+down_revision = '4fdb65816b8d'
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('webhook')
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('webhook',
+    sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
+    sa.Column('public_id', mysql.VARCHAR(length=255), nullable=False),
+    sa.Column('repository_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+    sa.Column('parameters', mysql.LONGTEXT(), nullable=False),
+    sa.ForeignKeyConstraint(['repository_id'], [u'repository.id'], name=u'fk_webhook_repository_repository_id'),
+    sa.PrimaryKeyConstraint('id'),
+    mysql_default_charset=u'latin1',
+    mysql_engine=u'InnoDB'
+    )
+    ### end Alembic commands ###

From 6c60e078fc207b0fb751f45586845825115bc421 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Wed, 3 Sep 2014 15:35:29 -0400
Subject: [PATCH 45/57] Fix NPE

---
 static/js/app.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/static/js/app.js b/static/js/app.js
index c55cf5eb5..4e51e4708 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2465,6 +2465,7 @@ quayApp.directive('dockerAuthDialog', function (Config) {
     controller: function($scope, $element) {
       var updateCommand = function() {
         var escape = function(v) {
+          if (!v) { return v; }
           return v.replace('$', '\\$');
         };
         $scope.command = 'docker login -e="." -u="' + escape($scope.username) +

From 1e7e012b923b5b4cbf7fc850ecd54c4c487240e6 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Wed, 3 Sep 2014 15:41:25 -0400
Subject: [PATCH 46/57] Add a requirement for the current password to change
 the user's password or email address

---
 endpoints/api/user.py           | 22 +++++++++++++++++++++-
 static/js/app.js                |  2 +-
 static/js/controllers.js        |  2 ++
 static/partials/user-admin.html | 11 ++++++++---
 test/test_api_usage.py          | 26 +++++++++++++++++++++++---
 5 files changed, 55 insertions(+), 8 deletions(-)

diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index e2e6a0ff4..054db7041 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -117,6 +117,10 @@ class User(ApiResource):
       'type': 'object',
       'description': 'Fields which can be updated in a user.',
       'properties': {
+        'current_password': {
+          'type': 'string',
+          'description': 'The user\'s current password',
+        },
         'password': {
           'type': 'string',
           'description': 'The user\'s password',
@@ -152,8 +156,22 @@ class User(ApiResource):
     user = get_authenticated_user()
     user_data = request.get_json()
 
-    try:
+    def verify_current_password(user, user_data):
+      current_password = user_data.get('current_password', '')
+
+      verified = False
+      try:
+        verified = model.verify_user(user.username, current_password)
+      except:
+        pass
+
+      if not verified:
+        raise request_error(message='Current password does not match')      
+
+    try:     
       if 'password' in user_data:
+        verify_current_password(user, user_data)
+
         logger.debug('Changing password for user: %s', user.username)
         log_action('account_change_password', user.username)
         model.change_password(user, user_data['password'])
@@ -163,6 +181,8 @@ class User(ApiResource):
         model.change_invoice_email(user, user_data['invoice_email'])
 
       if 'email' in user_data and user_data['email'] != user.email:
+        verify_current_password(user, user_data)
+
         new_email = user_data['email']
         if model.find_user_by_email(new_email):
           # Email already used.
diff --git a/static/js/app.js b/static/js/app.js
index 4e51e4708..72eabc41a 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -384,7 +384,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
       var uiService = {};
       
       uiService.hidePopover = function(elem) {
-        var popover = $('#signupButton').data('bs.popover');
+        var popover = $(elem).data('bs.popover');
         if (popover) {
           popover.hide();
         }
diff --git a/static/js/controllers.js b/static/js/controllers.js
index 4d1c8484f..485b7f529 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -1763,6 +1763,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
 
       // Reset the form.
       delete $scope.cuser['repeatEmail'];
+      delete $scope.cuser['current_password'];
 
       $scope.changeEmailForm.$setPristine();
     }, function(result) {
@@ -1784,6 +1785,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
       // Reset the form
       delete $scope.cuser['password']
       delete $scope.cuser['repeatPassword']
+      delete $scope.cuser['current_password'];
 
       $scope.changePasswordForm.$setPristine();
 
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html
index 1b2ad7fd1..260aa47e2 100644
--- a/static/partials/user-admin.html
+++ b/static/partials/user-admin.html
@@ -128,6 +128,8 @@
               <div class="panel-body">
                 <form class="form-change col-md-6" id="changeEmailForm" name="changeEmailForm" ng-submit="changeEmail()"
                       ng-show="!awaitingConfirmation && !registering">
+                  <input type="password" class="form-control" placeholder="Your current password" ng-model="cuser.current_password" required
+                         ng-pattern="/^.{8,}$/">
                   <input type="email" class="form-control" placeholder="Your new e-mail address" ng-model="cuser.email" required>
                   <button class="btn btn-primary" ng-disabled="changeEmailForm.$invalid || cuser.email == user.email" type="submit">Change E-mail Address</button>
                 </form>
@@ -138,18 +140,21 @@
 
         <!-- Change password tab -->
         <div id="password" class="tab-pane">
-          <div class="loading" ng-show="updatingUser">
-            <div class="quay-spinner 3x"></div>
-          </div>
           <div class="row">
             <div class="panel">
               <div class="panel-title">Change Password</div>
 
+              <div class="loading" ng-show="updatingUser">
+                <div class="quay-spinner 3x"></div>
+              </div>
+
               <span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
 
               <div ng-show="!updatingUser" class="panel-body">
                 <form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()"
                       ng-show="!awaitingConfirmation && !registering">
+                  <input type="password" class="form-control" placeholder="Your current password" ng-model="cuser.current_password" required
+                         ng-pattern="/^.{8,}$/">
                   <input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required
                          ng-pattern="/^.{8,}$/">
                   <input type="password" class="form-control" placeholder="Verify your new password" ng-model="cuser.repeatPassword"
diff --git a/test/test_api_usage.py b/test/test_api_usage.py
index bd8bb29cd..13cf97434 100644
--- a/test/test_api_usage.py
+++ b/test/test_api_usage.py
@@ -172,14 +172,14 @@ class TestCSRFFailure(ApiTestCase):
 
     # Make sure a simple post call succeeds.
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool'))    
+                         data=dict(password='newpasswordiscool', current_password='password'))    
 
     # Change the session's CSRF token.
     self.setCsrfToken('someinvalidtoken')
     
     # Verify that the call now fails.
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool'),
+                         data=dict(password='newpasswordiscool', current_password='password'),
                          expected_code=403)
 
 
@@ -325,8 +325,28 @@ class TestChangeUserDetails(ApiTestCase):
   def test_changepassword(self):
     self.login(READ_ACCESS_USER)
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool'))
+                         data=dict(password='newpasswordiscool', current_password='password'))
     self.login(READ_ACCESS_USER, password='newpasswordiscool')
+
+  def test_changepassword_invalidpasswor(self):
+    self.login(READ_ACCESS_USER)
+    self.putJsonResponse(User,
+                         data=dict(password='newpasswordiscool', current_password='notcorrect'),
+                         expected_code=400)
+
+  def test_changeeemail(self):
+    self.login(READ_ACCESS_USER)
+
+    self.putJsonResponse(User,
+                         data=dict(email='test+foo@devtable.com', current_password='password'))
+
+  def test_changeeemail_invalidpassword(self):
+    self.login(READ_ACCESS_USER)
+
+    self.putJsonResponse(User,
+                         data=dict(email='test+foo@devtable.com', current_password='notcorrect'),
+                         expected_code=400)
+
     
   def test_changeinvoiceemail(self):
     self.login(READ_ACCESS_USER)

From 25058bc91c1d5ef1a74336c14e9fca9dad916ee1 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Wed, 3 Sep 2014 17:24:52 -0400
Subject: [PATCH 47/57] Up the gunicorn worker count (under protest)

---
 conf/gunicorn_config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/conf/gunicorn_config.py b/conf/gunicorn_config.py
index 4d9d50499..ca8ad5363 100644
--- a/conf/gunicorn_config.py
+++ b/conf/gunicorn_config.py
@@ -1,5 +1,5 @@
 bind = 'unix:/tmp/gunicorn.sock'
-workers = 8
+workers = 16
 worker_class = 'gevent'
 timeout = 2000
 logconfig = 'conf/logging.conf'

From e783df31e0471346d25affaf74172038a17fcba6 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 14:24:20 -0400
Subject: [PATCH 48/57] Add the concept of require_fresh_login to both the
 backend and frontend. Sensitive methods will now be marked with the
 annotation, which requires that the user has performed a login within 10
 minutes or they are asked to do so in the UI before running the operation
 again.

---
 endpoints/api/__init__.py       | 28 ++++++++++-
 endpoints/api/discovery.py      |  5 ++
 endpoints/api/user.py           | 54 ++++++++++++---------
 endpoints/common.py             |  4 +-
 static/js/app.js                | 84 +++++++++++++++++++++++++++++----
 static/js/controllers.js        |  7 ++-
 static/partials/user-admin.html |  4 --
 test/test_api_security.py       | 27 +++++++++--
 test/test_api_usage.py          | 22 ++-------
 9 files changed, 174 insertions(+), 61 deletions(-)

diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py
index 854c3cad1..9f9a8c941 100644
--- a/endpoints/api/__init__.py
+++ b/endpoints/api/__init__.py
@@ -1,7 +1,8 @@
 import logging
 import json
+import datetime
 
-from flask import Blueprint, request, make_response, jsonify
+from flask import Blueprint, request, make_response, jsonify, session
 from flask.ext.restful import Resource, abort, Api, reqparse
 from flask.ext.restful.utils.cors import crossdomain
 from werkzeug.exceptions import HTTPException
@@ -66,6 +67,11 @@ class Unauthorized(ApiException):
       ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload)
 
 
+class FreshLoginRequired(ApiException):
+  def __init__(self, payload=None):
+    ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload)
+
+
 class ExceedsLicenseException(ApiException):
   def __init__(self, payload=None):
     ApiException.__init__(self, None, 402, 'Payment Required', payload)
@@ -264,6 +270,26 @@ def require_user_permission(permission_class, scope=None):
 
 require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER)
 require_user_admin = require_user_permission(UserAdminPermission, None)
+require_fresh_user_admin = require_user_permission(UserAdminPermission, None)
+
+def require_fresh_login(func):
+  @add_method_metadata('requires_fresh_login', True)
+  @wraps(func)
+  def wrapped(*args, **kwargs):
+    user = get_authenticated_user()
+    if not user:
+      raise Unauthorized()
+
+    logger.debug('Checking fresh login for user %s', user.username)
+
+    last_login = session.get('login_time', datetime.datetime.now() - datetime.timedelta(minutes=60))
+    valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10)
+
+    if last_login >= valid_span:
+      return func(*args, **kwargs)
+    
+    raise FreshLoginRequired()
+  return wrapped
 
 
 def require_scope(scope_object):
diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py
index ee8702636..1995c6b42 100644
--- a/endpoints/api/discovery.py
+++ b/endpoints/api/discovery.py
@@ -119,6 +119,11 @@ def swagger_route_data(include_internal=False, compact=False):
           if internal is not None:
             new_operation['internal'] = True
 
+          if include_internal:
+            requires_fresh_login = method_metadata(method, 'requires_fresh_login')
+            if requires_fresh_login is not None:
+              new_operation['requires_fresh_login'] = True
+
           if not internal or (internal and include_internal):
             operations.append(new_operation)
 
diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index 054db7041..eb99ba0fa 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -9,7 +9,7 @@ from app import app, billing as stripe, authentication
 from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
                            log_action, internal_only, NotFound, require_user_admin, parse_args,
                            query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
-                           license_error)
+                           license_error,  require_fresh_login)
 from endpoints.api.subscribe import subscribe
 from endpoints.common import common_login
 from data import model
@@ -117,10 +117,6 @@ class User(ApiResource):
       'type': 'object',
       'description': 'Fields which can be updated in a user.',
       'properties': {
-        'current_password': {
-          'type': 'string',
-          'description': 'The user\'s current password',
-        },
         'password': {
           'type': 'string',
           'description': 'The user\'s password',
@@ -148,6 +144,7 @@ class User(ApiResource):
     return user_view(user)
 
   @require_user_admin
+  @require_fresh_login
   @nickname('changeUserDetails')
   @internal_only
   @validate_json_request('UpdateUser')
@@ -156,22 +153,8 @@ class User(ApiResource):
     user = get_authenticated_user()
     user_data = request.get_json()
 
-    def verify_current_password(user, user_data):
-      current_password = user_data.get('current_password', '')
-
-      verified = False
-      try:
-        verified = model.verify_user(user.username, current_password)
-      except:
-        pass
-
-      if not verified:
-        raise request_error(message='Current password does not match')      
-
     try:     
       if 'password' in user_data:
-        verify_current_password(user, user_data)
-
         logger.debug('Changing password for user: %s', user.username)
         log_action('account_change_password', user.username)
         model.change_password(user, user_data['password'])
@@ -181,8 +164,6 @@ class User(ApiResource):
         model.change_invoice_email(user, user_data['invoice_email'])
 
       if 'email' in user_data and user_data['email'] != user.email:
-        verify_current_password(user, user_data)
-
         new_email = user_data['email']
         if model.find_user_by_email(new_email):
           # Email already used.
@@ -377,6 +358,37 @@ class Signin(ApiResource):
     return conduct_signin(username, password)
 
 
+@resource('/v1/signin/verify')
+@internal_only
+class VerifyUser(ApiResource):
+  """ Operations for verifying the existing user. """
+  schemas = {
+    'VerifyUser': {
+      'id': 'VerifyUser',
+      'type': 'object',
+      'description': 'Information required to verify the signed in user.',
+      'required': [
+        'password',
+      ],
+      'properties': {
+        'password': {
+          'type': 'string',
+          'description': 'The user\'s password',
+        },
+      },
+    },
+  }
+
+  @require_user_admin
+  @nickname('verifyUser')
+  @validate_json_request('VerifyUser')
+  def post(self):
+    """ Verifies the signed in the user with the specified credentials. """
+    signin_data = request.get_json()
+    password = signin_data['password']
+    return conduct_signin(get_authenticated_user().username, password)
+
+
 @resource('/v1/signout')
 @internal_only
 class Signout(ApiResource):
diff --git a/endpoints/common.py b/endpoints/common.py
index fe09104ca..52715a1d1 100644
--- a/endpoints/common.py
+++ b/endpoints/common.py
@@ -2,8 +2,9 @@ import logging
 import urlparse
 import json
 import string
+import datetime
 
-from flask import make_response, render_template, request, abort
+from flask import make_response, render_template, request, abort, session
 from flask.ext.login import login_user, UserMixin
 from flask.ext.principal import identity_changed
 from random import SystemRandom
@@ -112,6 +113,7 @@ def common_login(db_user):
     logger.debug('Successfully signed in as: %s' % db_user.username)
     new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN})
     identity_changed.send(app, identity=new_identity)
+    session['login_time'] = datetime.datetime.now()
     return True
   else:
     logger.debug('User could not be logged in, inactive?.')
diff --git a/static/js/app.js b/static/js/app.js
index 72eabc41a..d23235a66 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -713,7 +713,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
       return config;
     }]);
 
-    $provide.factory('ApiService', ['Restangular', function(Restangular) {
+  $provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) {
       var apiService = {};
 
       var getResource = function(path, opt_background) {
@@ -810,6 +810,65 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
         }
       };
 
+      var freshLoginFailCheck = function(opName, opArgs) {
+        return function(resp) {
+          var deferred = $q.defer();
+          
+          // If the error is a fresh login required, show the dialog.
+          if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
+            bootbox.dialog({
+              "message": 'It has been more than a few minutes since you last logged in, ' +
+                'so please verify your password to perform this sensitive operation:' + 
+                '<form style="margin-top: 10px" action="javascript:void(0)">' +
+                '<input id="freshPassword" class="form-control" type="password" placeholder="Current Password">' + 
+                '</form>',
+              "title": 'Please Verify',
+              "buttons": {
+                "verify": {
+                  "label": "Verify",
+                  "className": "btn-success",
+                  "callback": function() {
+                    var info = {
+                      'password': $('#freshPassword').val()
+                    };
+
+                    $('#freshPassword').val('');
+
+                    // Conduct the sign in of the user.
+                    apiService.verifyUser(info).then(function() {
+                      // On success, retry the operation. if it succeeds, then resolve the
+                      // deferred promise with the result. Otherwise, reject the same.
+                      apiService[opName].apply(apiService, opArgs).then(function(resp) {
+                        deferred.resolve(resp);
+                      }, function(resp) {
+                        deferred.reject(resp);
+                      });
+                    }, function(resp) {
+                      // Reject with the sign in error.
+                      deferred.reject({'data': {'message': 'Invalid verification credentials'}});
+                    });
+                  }
+                },
+                "close": {
+                  "label": "Cancel",
+                  "className": "btn-default",
+                  "callback": function() {
+                    deferred.reject(resp);
+                  }
+                }
+              }
+            });
+
+            // Return a new promise. We'll accept or reject it based on the result
+            // of the login.
+            return deferred.promise;
+          }
+
+          // Otherwise, we just 'raise' the error via the reject method on the promise.
+          return $q.reject(resp);
+        };
+      };
+
       var buildMethodsForOperation = function(operation, resource, resourceMap) {
         var method = operation['method'].toLowerCase();
         var operationName = operation['nickname'];
@@ -823,7 +882,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
               'ignoreLoadingBar': true
             });
           }
-          return one['custom' + method.toUpperCase()](opt_options);
+          
+          var opObj = one['custom' + method.toUpperCase()](opt_options);
+
+          // If the operation requires_fresh_login, then add a specialized error handler that
+          // will defer the operation's result if sudo is requested.
+          if (operation['requires_fresh_login']) {
+            opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
+          }
+          return opObj;
         };
 
         // If the method for the operation is a GET, add an operationAsResource method.
@@ -3923,9 +3990,11 @@ quayApp.directive('billingOptions', function () {
 
       var save = function() {
         $scope.working = true;
+
+        var errorHandler = ApiService.errorDisplay('Could not change user details');
         ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) {
           $scope.working = false;
-        });
+        }, errorHandler);
       };
 
       var checkSave = function() {
@@ -5699,11 +5768,10 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
 
   // Handle session expiration.
   Restangular.setErrorInterceptor(function(response) {
-    if (response.status == 401) {
-      if (response.data['session_required'] == null || response.data['session_required'] === true) {
-        $('#sessionexpiredModal').modal({});
-        return false;
-      }
+    if (response.status == 401 && response.data['error_type'] == 'invalid_token' &&
+        response.data['session_required'] !== false) {
+      $('#sessionexpiredModal').modal({});
+      return false;
     }
 
     if (response.status == 503) {
diff --git a/static/js/controllers.js b/static/js/controllers.js
index 485b7f529..d95a760a9 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -1763,12 +1763,11 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
 
       // Reset the form.
       delete $scope.cuser['repeatEmail'];
-      delete $scope.cuser['current_password'];
 
       $scope.changeEmailForm.$setPristine();
     }, function(result) {
       $scope.updatingUser = false;
-      UIService.showFormError('#changeEmailForm', result);      
+      UIService.showFormError('#changeEmailForm', result);
     });
   };
 
@@ -1778,14 +1777,14 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
     $scope.updatingUser = true;
     $scope.changePasswordSuccess = false;
 
-    ApiService.changeUserDetails($scope.cuser).then(function() {
+    ApiService.changeUserDetails($scope.cuser).then(function(resp) {
+
       $scope.updatingUser = false;
       $scope.changePasswordSuccess = true;
 
       // Reset the form
       delete $scope.cuser['password']
       delete $scope.cuser['repeatPassword']
-      delete $scope.cuser['current_password'];
 
       $scope.changePasswordForm.$setPristine();
 
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html
index 260aa47e2..4349d9df9 100644
--- a/static/partials/user-admin.html
+++ b/static/partials/user-admin.html
@@ -128,8 +128,6 @@
               <div class="panel-body">
                 <form class="form-change col-md-6" id="changeEmailForm" name="changeEmailForm" ng-submit="changeEmail()"
                       ng-show="!awaitingConfirmation && !registering">
-                  <input type="password" class="form-control" placeholder="Your current password" ng-model="cuser.current_password" required
-                         ng-pattern="/^.{8,}$/">
                   <input type="email" class="form-control" placeholder="Your new e-mail address" ng-model="cuser.email" required>
                   <button class="btn btn-primary" ng-disabled="changeEmailForm.$invalid || cuser.email == user.email" type="submit">Change E-mail Address</button>
                 </form>
@@ -153,8 +151,6 @@
               <div ng-show="!updatingUser" class="panel-body">
                 <form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()"
                       ng-show="!awaitingConfirmation && !registering">
-                  <input type="password" class="form-control" placeholder="Your current password" ng-model="cuser.current_password" required
-                         ng-pattern="/^.{8,}$/">
                   <input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required
                          ng-pattern="/^.{8,}$/">
                   <input type="password" class="form-control" placeholder="Verify your new password" ng-model="cuser.repeatPassword"
diff --git a/test/test_api_security.py b/test/test_api_security.py
index 9a3bcfac3..3c33ad712 100644
--- a/test/test_api_security.py
+++ b/test/test_api_security.py
@@ -23,7 +23,8 @@ from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, Bu
 from endpoints.api.repoemail import RepositoryAuthorizedEmail
 from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
 from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
-                                Signin, User, UserAuthorizationList, UserAuthorization, UserNotification)
+                                Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
+                                VerifyUser)
 from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
 from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
 from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@@ -434,6 +435,24 @@ class TestSignin(ApiTestCase):
     self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
 
 
+class TestVerifyUser(ApiTestCase):
+  def setUp(self):
+    ApiTestCase.setUp(self)
+    self._set_url(VerifyUser)
+
+  def test_post_anonymous(self):
+    self._run_test('POST', 401, None, {u'password': 'LQ0N'})
+
+  def test_post_freshuser(self):
+    self._run_test('POST', 403, 'freshuser', {u'password': 'LQ0N'})
+
+  def test_post_reader(self):
+    self._run_test('POST', 403, 'reader', {u'password': 'LQ0N'})
+
+  def test_post_devtable(self):
+    self._run_test('POST', 200, 'devtable', {u'password': 'password'})
+
+
 class TestListPlans(ApiTestCase):
   def setUp(self):
     ApiTestCase.setUp(self)
@@ -473,13 +492,13 @@ class TestUser(ApiTestCase):
     self._run_test('PUT', 401, None, {})
 
   def test_put_freshuser(self):
-    self._run_test('PUT', 200, 'freshuser', {})
+    self._run_test('PUT', 401, 'freshuser', {})
 
   def test_put_reader(self):
-    self._run_test('PUT', 200, 'reader', {})
+    self._run_test('PUT', 401, 'reader', {})
 
   def test_put_devtable(self):
-    self._run_test('PUT', 200, 'devtable', {})
+    self._run_test('PUT', 401, 'devtable', {})
 
   def test_post_anonymous(self):
     self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'})
diff --git a/test/test_api_usage.py b/test/test_api_usage.py
index 13cf97434..004f20651 100644
--- a/test/test_api_usage.py
+++ b/test/test_api_usage.py
@@ -172,14 +172,14 @@ class TestCSRFFailure(ApiTestCase):
 
     # Make sure a simple post call succeeds.
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool', current_password='password'))    
+                         data=dict(password='newpasswordiscool'))    
 
     # Change the session's CSRF token.
     self.setCsrfToken('someinvalidtoken')
     
     # Verify that the call now fails.
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool', current_password='password'),
+                         data=dict(password='newpasswordiscool'),
                          expected_code=403)
 
 
@@ -325,29 +325,15 @@ class TestChangeUserDetails(ApiTestCase):
   def test_changepassword(self):
     self.login(READ_ACCESS_USER)
     self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool', current_password='password'))
+                         data=dict(password='newpasswordiscool'))
     self.login(READ_ACCESS_USER, password='newpasswordiscool')
 
-  def test_changepassword_invalidpasswor(self):
-    self.login(READ_ACCESS_USER)
-    self.putJsonResponse(User,
-                         data=dict(password='newpasswordiscool', current_password='notcorrect'),
-                         expected_code=400)
-
   def test_changeeemail(self):
     self.login(READ_ACCESS_USER)
 
     self.putJsonResponse(User,
-                         data=dict(email='test+foo@devtable.com', current_password='password'))
+                         data=dict(email='test+foo@devtable.com'))
 
-  def test_changeeemail_invalidpassword(self):
-    self.login(READ_ACCESS_USER)
-
-    self.putJsonResponse(User,
-                         data=dict(email='test+foo@devtable.com', current_password='notcorrect'),
-                         expected_code=400)
-
-    
   def test_changeinvoiceemail(self):
     self.login(READ_ACCESS_USER)
 

From 1c2de35f28ed10ba4339e1d393f1f3d3959cd52b Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 17:54:51 -0400
Subject: [PATCH 49/57] Code review fixes

---
 endpoints/api/user.py      |  1 -
 endpoints/callbacks.py     | 43 +++++++++++++++++---------------------
 templates/ologinerror.html |  2 +-
 3 files changed, 20 insertions(+), 26 deletions(-)

diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index d0d089dcd..fb09d012a 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -39,7 +39,6 @@ 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:
diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py
index 49fa1e8a6..1cbd46192 100644
--- a/endpoints/callbacks.py
+++ b/endpoints/callbacks.py
@@ -4,7 +4,7 @@ from flask import request, redirect, url_for, Blueprint
 from flask.ext.login import current_user
 
 from endpoints.common import render_page_template, common_login, route_show_if
-from app import app, analytics
+from app import app, analytics, get_app_url
 from data import model
 from util.names import parse_repository_name
 from util.validation import generate_valid_usernames
@@ -22,6 +22,11 @@ client = app.config['HTTPCLIENT']
 
 callback = Blueprint('callback', __name__)
 
+def render_ologin_error(service_name,
+                        error_message='Could not load user data. The token may have expired.'):
+  return render_page_template('ologinerror.html', service_name=service_name,
+                              error_message=error_message,
+                              service_url=get_app_url())
 
 def exchange_code_for_token(code, service_name='GITHUB', for_login=True, form_encode=False,
                             redirect_suffix=''):
@@ -96,15 +101,12 @@ def conduct_oauth_login(service_name, user_id, username, email, metadata={}):
         analytics.alias(to_login.username, state)
 
     except model.DataModelException, ex:
-      return render_page_template('ologinerror.html', service_name=service_name,
-                                  error_message=ex.message)
+      return render_ologin_error(service_name, 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')
-
+  return render_ologin_error(service_name)
 
 def get_google_username(user_data):
   username = user_data['email']
@@ -120,17 +122,16 @@ def get_google_username(user_data):
 def google_oauth_callback():
   error = request.args.get('error', None)
   if error:
-    return render_page_template('ologinerror.html', service_name='Google', error_message=error)
+    return render_ologin_error('Google', 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')    
-  
+    return render_ologin_error('Google')
+
   username = get_google_username(user_data)
   metadata = {
-    'service_username': username
+    'service_username': user_data['email']
   }
 
   return conduct_oauth_login('Google', user_data['id'], username, user_data['email'],
@@ -142,13 +143,12 @@ def google_oauth_callback():
 def github_oauth_callback():
   error = request.args.get('error', None)
   if error:
-    return render_page_template('ologinerror.html', service_name = 'GitHub', error_message=error)
+    return render_ologin_error('GitHub', error)
 
   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('ologinerror.html', service_name = 'GitHub',
-                                error_message='Could not load user data')    
+    return render_ologin_error('GitHub')
 
   username = user_data['login']
   github_id = user_data['id']
@@ -186,15 +186,14 @@ def google_oauth_attach():
 
   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')    
+    return render_ologin_error('Google')
 
   google_id = user_data['id']
   user_obj = current_user.db_user()
 
   username = get_google_username(user_data)
   metadata = {
-    'service_username': username
+    'service_username': user_data['email']
   }
 
   try:
@@ -202,9 +201,7 @@ def google_oauth_attach():
   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 render_ologin_error('Google', err)
 
   return redirect(url_for('web.user'))
 
@@ -216,8 +213,7 @@ def github_oauth_attach():
   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('ologinerror.html', service_name = 'GitHub',
-                                error_message='Could not load user data')    
+    return render_ologin_error('GitHub')
 
   github_id = user_data['id']
   user_obj = current_user.db_user()
@@ -233,8 +229,7 @@ def github_oauth_attach():
     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 render_ologin_error('GitHub', err)
 
   return redirect(url_for('web.user'))
 
diff --git a/templates/ologinerror.html b/templates/ologinerror.html
index cd921eec2..304b7f554 100644
--- a/templates/ologinerror.html
+++ b/templates/ologinerror.html
@@ -15,7 +15,7 @@
         {% endif %}
 
         <div>
-          Please register using the <a href="/">registration form</a> to continue.
+          Please register using the <a ng-href="{{ service_url }}/signin" target="_self">registration form</a> to continue.
           You will be able to connect your account to your Quay.io account
           in the user settings.
         </div>

From b9a4d2835f89da831abf3ce35ce79808a2cd79b4 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 18:18:19 -0400
Subject: [PATCH 50/57] Add migration for the new DB field

---
 ...a_add_metadata_field_to_external_logins.py | 26 +++++++++++++++++++
 1 file changed, 26 insertions(+)
 create mode 100644 data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py

diff --git a/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
new file mode 100644
index 000000000..c642dcbee
--- /dev/null
+++ b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
@@ -0,0 +1,26 @@
+"""add metadata field to external logins
+
+Revision ID: 1594a74a74ca
+Revises: f42b0ea7a4d
+Create Date: 2014-09-04 18:17:35.205698
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '1594a74a74ca'
+down_revision = 'f42b0ea7a4d'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('federatedlogin', sa.Column('metadata_json', sa.Text(), nullable=False))
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('federatedlogin', 'metadata_json')
+    ### end Alembic commands ###

From 6fa5a365b3f664346d150fd18a7fd0255194034b Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 18:45:23 -0400
Subject: [PATCH 51/57] Add loginservice for Google

---
 ...4ca_add_metadata_field_to_external_logins.py | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
index c642dcbee..a59116c7f 100644
--- a/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
+++ b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
@@ -12,6 +12,9 @@ down_revision = 'f42b0ea7a4d'
 
 from alembic import op
 import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
+from data.database import all_models
 
 
 def upgrade():
@@ -19,8 +22,22 @@ def upgrade():
     op.add_column('federatedlogin', sa.Column('metadata_json', sa.Text(), nullable=False))
     ### end Alembic commands ###
 
+    schema = gen_sqlalchemy_metadata(all_models)
+
+    op.bulk_insert(schema.tables['loginservice'],
+    [
+        {'id':4, 'name':'google'},
+    ])
 
 def downgrade():
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_column('federatedlogin', 'metadata_json')
     ### end Alembic commands ###
+
+    schema = gen_sqlalchemy_metadata(all_models)
+    loginservice = schema.table['loginservice']
+
+    op.execute(
+        (loginservice.delete()
+            .where(loginservice.c.name == op.inline_literal('google')))
+    )

From 1a230f635af589cf75a8a65aae0fcf8ba2a6348e Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Thu, 4 Sep 2014 19:15:06 -0400
Subject: [PATCH 52/57] Use datetime.min instead of a fixed span for the last
 login default time.

---
 endpoints/api/__init__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py
index 9f9a8c941..a9d2ecdb0 100644
--- a/endpoints/api/__init__.py
+++ b/endpoints/api/__init__.py
@@ -282,7 +282,7 @@ def require_fresh_login(func):
 
     logger.debug('Checking fresh login for user %s', user.username)
 
-    last_login = session.get('login_time', datetime.datetime.now() - datetime.timedelta(minutes=60))
+    last_login = session.get('login_time', datetime.datetime.min)
     valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10)
 
     if last_login >= valid_span:

From 987177fd7e45800a549e2fed6e410741abf226de Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 19:47:12 -0400
Subject: [PATCH 53/57] Have require_fresh_login not apply if there is no
 password set for the user

---
 endpoints/api/__init__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py
index a9d2ecdb0..2f5e2045e 100644
--- a/endpoints/api/__init__.py
+++ b/endpoints/api/__init__.py
@@ -285,7 +285,7 @@ def require_fresh_login(func):
     last_login = session.get('login_time', datetime.datetime.min)
     valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10)
 
-    if last_login >= valid_span:
+    if not user.password_hash or last_login >= valid_span:
       return func(*args, **kwargs)
     
     raise FreshLoginRequired()

From f746eb33819fcf1ba66bbd75c02991792cdbf114 Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 20:04:49 -0400
Subject: [PATCH 54/57] Make the fresh login dialog autofocus the input and
 make it handle the enter key properly.

---
 static/js/app.js | 58 +++++++++++++++++++++++++++++-------------------
 1 file changed, 35 insertions(+), 23 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index d23235a66..e0b431d7b 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -816,7 +816,31 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
           
           // If the error is a fresh login required, show the dialog.
           if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
-            bootbox.dialog({
+            var verifyNow = function() {
+              if (!$('#freshPassword').val()) { return; }
+
+              var info = {
+                'password': $('#freshPassword').val()
+              };
+
+              $('#freshPassword').val('');
+
+              // Conduct the sign in of the user.
+              apiService.verifyUser(info).then(function() {
+                // On success, retry the operation. if it succeeds, then resolve the
+                // deferred promise with the result. Otherwise, reject the same.
+                apiService[opName].apply(apiService, opArgs).then(function(resp) {
+                  deferred.resolve(resp);
+                }, function(resp) {
+                  deferred.reject(resp);
+                });
+              }, function(resp) {
+                // Reject with the sign in error.
+                deferred.reject({'data': {'message': 'Invalid verification credentials'}});
+              });
+            };
+           
+            var box = bootbox.dialog({
               "message": 'It has been more than a few minutes since you last logged in, ' +
                 'so please verify your password to perform this sensitive operation:' + 
                 '<form style="margin-top: 10px" action="javascript:void(0)">' +
@@ -827,38 +851,26 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
                 "verify": {
                   "label": "Verify",
                   "className": "btn-success",
-                  "callback": function() {
-                    var info = {
-                      'password': $('#freshPassword').val()
-                    };
-
-                    $('#freshPassword').val('');
-
-                    // Conduct the sign in of the user.
-                    apiService.verifyUser(info).then(function() {
-                      // On success, retry the operation. if it succeeds, then resolve the
-                      // deferred promise with the result. Otherwise, reject the same.
-                      apiService[opName].apply(apiService, opArgs).then(function(resp) {
-                        deferred.resolve(resp);
-                      }, function(resp) {
-                        deferred.reject(resp);
-                      });
-                    }, function(resp) {
-                      // Reject with the sign in error.
-                      deferred.reject({'data': {'message': 'Invalid verification credentials'}});
-                    });
-                  }
+                  "callback": verifyNow
                 },
                 "close": {
                   "label": "Cancel",
                   "className": "btn-default",
                   "callback": function() {
-                    deferred.reject(resp);
+                    deferred.reject({'data': {'message': 'Verification canceled'}});
                   }
                 }
               }
             });
 
+            box.bind('shown.bs.modal', function(){
+              box.find("input").focus();
+              box.find("form").submit(function() {
+                box.modal('hide');
+                verifyNow();
+              });
+            });
+
             // Return a new promise. We'll accept or reject it based on the result
             // of the login.
             return deferred.promise;

From 4e04ad5ca7f4192f23040899641178e46b02d02e Mon Sep 17 00:00:00 2001
From: Joseph Schorr <jschorr@gmail.com>
Date: Thu, 4 Sep 2014 20:05:21 -0400
Subject: [PATCH 55/57] Move the password check before we hide the modal

---
 static/js/app.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index e0b431d7b..97568e7fb 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -817,8 +817,6 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
           // If the error is a fresh login required, show the dialog.
           if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
             var verifyNow = function() {
-              if (!$('#freshPassword').val()) { return; }
-
               var info = {
                 'password': $('#freshPassword').val()
               };
@@ -866,6 +864,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
             box.bind('shown.bs.modal', function(){
               box.find("input").focus();
               box.find("form").submit(function() {
+                if (!$('#freshPassword').val()) { return; }
+                
                 box.modal('hide');
                 verifyNow();
               });

From 19a589ba54d64944a3e6777cd1cb692d9797291c Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Thu, 4 Sep 2014 20:11:42 -0400
Subject: [PATCH 56/57] Update the test db to have the google login service.

---
 test/data/test.db | Bin 231424 -> 231424 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/test/data/test.db b/test/data/test.db
index 3e631b5d72e5c2c290e95affc3a9794853a6a573..f76110cc23548c74a99fe0ce64bfd130c91fc2aa 100644
GIT binary patch
delta 6273
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
Y@t)(ifd+61=D;0bc-CcHAS>4VA9FG*y8r+H

delta 6218
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


From c7e873366d2255fc22dcd4d7d843c1f8a8b00628 Mon Sep 17 00:00:00 2001
From: Jake Moshenko <jake@devtable.com>
Date: Thu, 4 Sep 2014 20:58:29 -0400
Subject: [PATCH 57/57] Inject the tables metadata into the upgrade and
 downgrade functions. Fix a bunch of the downgrades to actually work.

---
 data/billing.py                               |  16 +--
 data/migrations/env.py                        |   6 +-
 data/migrations/script.py.mako                |   4 +-
 ...a_add_metadata_field_to_external_logins.py |  18 +--
 ...49_remove_fields_from_image_table_that_.py |   4 +-
 ...c79d9_prepare_the_database_for_the_new_.py |  56 ++++-----
 ...9f_add_log_kind_for_regenerating_robot_.py |  18 +--
 ...670cbeced_migrate_existing_webhooks_to_.py |   4 +-
 ...2_add_the_maintenance_notification_type.py |  15 +--
 ...add_brute_force_prevention_metadata_to_.py |   4 +-
 .../5a07499ce53f_set_up_initial_database.py   | 108 ++----------------
 .../82297d834ad_add_us_west_location.py       |  17 +--
 ..._add_placements_and_locations_to_the_db.py |  14 +--
 ...2b0ea7a4d_remove_the_old_webhooks_table.py |   4 +-
 util/collections.py                           |  12 ++
 15 files changed, 80 insertions(+), 220 deletions(-)
 create mode 100644 util/collections.py

diff --git a/data/billing.py b/data/billing.py
index 4847dd3f8..8c604aac2 100644
--- a/data/billing.py
+++ b/data/billing.py
@@ -3,6 +3,8 @@ import stripe
 from datetime import datetime, timedelta
 from calendar import timegm
 
+from util.collections import AttrDict
+
 PLANS = [
   # Deprecated Plans
   {
@@ -118,20 +120,6 @@ def get_plan(plan_id):
   return None
 
 
-class AttrDict(dict):
-  def __init__(self, *args, **kwargs):
-    super(AttrDict, self).__init__(*args, **kwargs)
-    self.__dict__ = self
-
-  @classmethod
-  def deep_copy(cls, attr_dict):
-    copy = AttrDict(attr_dict)
-    for key, value in copy.items():
-      if isinstance(value, AttrDict):
-        copy[key] = cls.deep_copy(value)
-    return copy
-
-
 class FakeStripe(object):
   class Customer(AttrDict):
     FAKE_PLAN = AttrDict({
diff --git a/data/migrations/env.py b/data/migrations/env.py
index c267c2f50..863e3d98f 100644
--- a/data/migrations/env.py
+++ b/data/migrations/env.py
@@ -8,6 +8,7 @@ from peewee import SqliteDatabase
 from data.database import all_models, db
 from app import app
 from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
+from util.collections import AttrDict
 
 # this is the Alembic Config object, which provides
 # access to the values within the .ini file in use.
@@ -23,6 +24,7 @@ fileConfig(config.config_file_name)
 # from myapp import mymodel
 # target_metadata = mymodel.Base.metadata
 target_metadata = gen_sqlalchemy_metadata(all_models)
+tables = AttrDict(target_metadata.tables)
 
 # other values from the config, defined by the needs of env.py,
 # can be acquired:
@@ -45,7 +47,7 @@ def run_migrations_offline():
     context.configure(url=url, target_metadata=target_metadata, transactional_ddl=True)
 
     with context.begin_transaction():
-        context.run_migrations()
+        context.run_migrations(tables=tables)
 
 def run_migrations_online():
     """Run migrations in 'online' mode.
@@ -72,7 +74,7 @@ def run_migrations_online():
 
     try:
         with context.begin_transaction():
-            context.run_migrations()
+            context.run_migrations(tables=tables)
     finally:
         connection.close()
 
diff --git a/data/migrations/script.py.mako b/data/migrations/script.py.mako
index 95702017e..1b92f9f48 100644
--- a/data/migrations/script.py.mako
+++ b/data/migrations/script.py.mako
@@ -14,9 +14,9 @@ from alembic import op
 import sqlalchemy as sa
 ${imports if imports else ""}
 
-def upgrade():
+def upgrade(tables):
     ${upgrades if upgrades else "pass"}
 
 
-def downgrade():
+def downgrade(tables):
     ${downgrades if downgrades else "pass"}
diff --git a/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
index a59116c7f..2f6c60706 100644
--- a/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
+++ b/data/migrations/versions/1594a74a74ca_add_metadata_field_to_external_logins.py
@@ -13,31 +13,23 @@ down_revision = 'f42b0ea7a4d'
 from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 
-
-def upgrade():
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.add_column('federatedlogin', sa.Column('metadata_json', sa.Text(), nullable=False))
     ### end Alembic commands ###
 
-    schema = gen_sqlalchemy_metadata(all_models)
-
-    op.bulk_insert(schema.tables['loginservice'],
+    op.bulk_insert(tables.loginservice,
     [
         {'id':4, 'name':'google'},
     ])
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_column('federatedlogin', 'metadata_json')
     ### end Alembic commands ###
 
-    schema = gen_sqlalchemy_metadata(all_models)
-    loginservice = schema.table['loginservice']
-
     op.execute(
-        (loginservice.delete()
-            .where(loginservice.c.name == op.inline_literal('google')))
+        (tables.loginservice.delete()
+            .where(tables.loginservice.c.name == op.inline_literal('google')))
     )
diff --git a/data/migrations/versions/201d55b38649_remove_fields_from_image_table_that_.py b/data/migrations/versions/201d55b38649_remove_fields_from_image_table_that_.py
index ea36e3f57..d50c3a592 100644
--- a/data/migrations/versions/201d55b38649_remove_fields_from_image_table_that_.py
+++ b/data/migrations/versions/201d55b38649_remove_fields_from_image_table_that_.py
@@ -14,7 +14,7 @@ from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
 
-def upgrade():
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_index('buildtriggerservice_name', table_name='buildtriggerservice')
     op.create_index('buildtriggerservice_name', 'buildtriggerservice', ['name'], unique=True)
@@ -34,7 +34,7 @@ def upgrade():
     ### end Alembic commands ###
 
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_index('visibility_name', table_name='visibility')
     op.create_index('visibility_name', 'visibility', ['name'], unique=False)
diff --git a/data/migrations/versions/325a4d7c79d9_prepare_the_database_for_the_new_.py b/data/migrations/versions/325a4d7c79d9_prepare_the_database_for_the_new_.py
index 18c8bf654..e3be811b6 100644
--- a/data/migrations/versions/325a4d7c79d9_prepare_the_database_for_the_new_.py
+++ b/data/migrations/versions/325a4d7c79d9_prepare_the_database_for_the_new_.py
@@ -13,12 +13,8 @@ down_revision = '4b7ef0c7bdb2'
 from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
-
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
 
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.create_table('externalnotificationmethod',
     sa.Column('id', sa.Integer(), nullable=False),
@@ -26,7 +22,7 @@ def upgrade():
     sa.PrimaryKeyConstraint('id')
     )
     op.create_index('externalnotificationmethod_name', 'externalnotificationmethod', ['name'], unique=True)
-    op.bulk_insert(schema.tables['externalnotificationmethod'],
+    op.bulk_insert(tables.externalnotificationmethod,
     [
         {'id':1, 'name':'quay_notification'},
         {'id':2, 'name':'email'},
@@ -38,7 +34,7 @@ def upgrade():
     sa.PrimaryKeyConstraint('id')
     )
     op.create_index('externalnotificationevent_name', 'externalnotificationevent', ['name'], unique=True)
-    op.bulk_insert(schema.tables['externalnotificationevent'],
+    op.bulk_insert(tables.externalnotificationevent,
     [
         {'id':1, 'name':'repo_push'},
         {'id':2, 'name':'build_queued'},
@@ -77,7 +73,7 @@ def upgrade():
     op.add_column(u'notification', sa.Column('dismissed', sa.Boolean(), nullable=False))
     
     # Manually add the new notificationkind types
-    op.bulk_insert(schema.tables['notificationkind'],
+    op.bulk_insert(tables.notificationkind,
     [
         {'id':5, 'name':'repo_push'},
         {'id':6, 'name':'build_queued'},
@@ -87,7 +83,7 @@ def upgrade():
     ])
 
     # Manually add the new logentrykind types
-    op.bulk_insert(schema.tables['logentrykind'],
+    op.bulk_insert(tables.logentrykind,
     [
         {'id':39, 'name':'add_repo_notification'},
         {'id':40, 'name':'delete_repo_notification'},
@@ -97,61 +93,49 @@ def upgrade():
     ### end Alembic commands ###
 
 
-def downgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_column(u'notification', 'dismissed')
-    op.drop_index('repositorynotification_uuid', table_name='repositorynotification')
-    op.drop_index('repositorynotification_repository_id', table_name='repositorynotification')
-    op.drop_index('repositorynotification_method_id', table_name='repositorynotification')
-    op.drop_index('repositorynotification_event_id', table_name='repositorynotification')
     op.drop_table('repositorynotification')
-    op.drop_index('repositoryauthorizedemail_repository_id', table_name='repositoryauthorizedemail')
-    op.drop_index('repositoryauthorizedemail_email_repository_id', table_name='repositoryauthorizedemail')
-    op.drop_index('repositoryauthorizedemail_code', table_name='repositoryauthorizedemail')
     op.drop_table('repositoryauthorizedemail')
-    op.drop_index('externalnotificationevent_name', table_name='externalnotificationevent')
     op.drop_table('externalnotificationevent')
-    op.drop_index('externalnotificationmethod_name', table_name='externalnotificationmethod')
     op.drop_table('externalnotificationmethod')
 
     # Manually remove the notificationkind and logentrykind types
-    notificationkind = schema.tables['notificationkind']
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('repo_push')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('repo_push')))
 
     )
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('build_queued')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('build_queued')))
 
     )
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('build_start')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('build_start')))
 
     )
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('build_success')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('build_success')))
 
     )
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('build_failure')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('build_failure')))
 
     )
 
     op.execute(
-        (logentrykind.delete()
-            .where(logentrykind.c.name == op.inline_literal('add_repo_notification')))
+        (tables.logentrykind.delete()
+            .where(tables.logentrykind.c.name == op.inline_literal('add_repo_notification')))
 
     )
     op.execute(
-        (logentrykind.delete()
-            .where(logentrykind.c.name == op.inline_literal('delete_repo_notification')))
+        (tables.logentrykind.delete()
+            .where(tables.logentrykind.c.name == op.inline_literal('delete_repo_notification')))
 
     )
     ### end Alembic commands ###
diff --git a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
index 6ee041e4c..f676bf972 100644
--- a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
+++ b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py
@@ -13,25 +13,17 @@ down_revision = '82297d834ad'
 from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 
-
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
-    op.bulk_insert(schema.tables['logentrykind'],
+def upgrade(tables):
+    op.bulk_insert(tables.logentrykind,
     [
         {'id': 41, 'name':'regenerate_robot_token'},
     ])
 
 
-def downgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
-    logentrykind = schema.tables['logentrykind']
+def downgrade(tables):
     op.execute(
-        (logentrykind.delete()
-            .where(logentrykind.c.name == op.inline_literal('regenerate_robot_token')))
+        (tables.logentrykind.delete()
+            .where(tables.logentrykind.c.name == op.inline_literal('regenerate_robot_token')))
 
     )
diff --git a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py
index 726145167..eaa687c73 100644
--- a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py
+++ b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py
@@ -18,13 +18,13 @@ def get_id(query):
     conn = op.get_bind()
     return list(conn.execute(query, ()).fetchall())[0][0]
 
-def upgrade():
+def upgrade(tables):
     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')
     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():
+def downgrade(tables):
     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')
diff --git a/data/migrations/versions/4b7ef0c7bdb2_add_the_maintenance_notification_type.py b/data/migrations/versions/4b7ef0c7bdb2_add_the_maintenance_notification_type.py
index 9e5fff425..9f48ca6c6 100644
--- a/data/migrations/versions/4b7ef0c7bdb2_add_the_maintenance_notification_type.py
+++ b/data/migrations/versions/4b7ef0c7bdb2_add_the_maintenance_notification_type.py
@@ -11,23 +11,18 @@ revision = '4b7ef0c7bdb2'
 down_revision = 'bcdde200a1b'
 
 from alembic import op
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 import sqlalchemy as sa
 
-
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-    op.bulk_insert(schema.tables['notificationkind'],
+def upgrade(tables):
+    op.bulk_insert(tables.notificationkind,
     [
         {'id':4, 'name':'maintenance'},
     ])
 
 
-def downgrade():
-    notificationkind = schema.tables['notificationkind']
+def downgrade(tables):
     op.execute(
-        (notificationkind.delete()
-            .where(notificationkind.c.name == op.inline_literal('maintenance')))
+        (tables.notificationkind.delete()
+            .where(tables.notificationkind.c.name == op.inline_literal('maintenance')))
 
     )
diff --git a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
index a1c8c95dd..1ce802eca 100644
--- a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
+++ b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py
@@ -14,14 +14,14 @@ from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
 
-def upgrade():
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default="0"))
     op.add_column('user', sa.Column('last_invalid_login', sa.DateTime(), nullable=False, server_default=sa.func.now()))
     ### end Alembic commands ###
 
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_column('user', 'last_invalid_login')
     op.drop_column('user', 'invalid_login_attempts')
diff --git a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py
index ffc9d28e6..f67224645 100644
--- a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py
+++ b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py
@@ -11,14 +11,9 @@ revision = '5a07499ce53f'
 down_revision = None
 
 from alembic import op
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 import sqlalchemy as sa
 
-
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.create_table('loginservice',
     sa.Column('id', sa.Integer(), nullable=False),
@@ -27,7 +22,7 @@ def upgrade():
     )
     op.create_index('loginservice_name', 'loginservice', ['name'], unique=True)
 
-    op.bulk_insert(schema.tables['loginservice'],
+    op.bulk_insert(tables.loginservice,
     [
         {'id':1, 'name':'github'},
         {'id':2, 'name':'quayrobot'},
@@ -66,7 +61,7 @@ def upgrade():
     )
     op.create_index('role_name', 'role', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['role'],
+    op.bulk_insert(tables.role,
     [
         {'id':1, 'name':'admin'},
         {'id':2, 'name':'write'},
@@ -80,7 +75,7 @@ def upgrade():
     )
     op.create_index('logentrykind_name', 'logentrykind', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['logentrykind'],
+    op.bulk_insert(tables.logentrykind,
     [
         {'id':1, 'name':'account_change_plan'},
         {'id':2, 'name':'account_change_cc'},
@@ -136,7 +131,7 @@ def upgrade():
     )
     op.create_index('notificationkind_name', 'notificationkind', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['notificationkind'],
+    op.bulk_insert(tables.notificationkind,
     [
         {'id':1, 'name':'password_required'},
         {'id':2, 'name':'over_private_usage'},
@@ -150,7 +145,7 @@ def upgrade():
     )
     op.create_index('teamrole_name', 'teamrole', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['teamrole'],
+    op.bulk_insert(tables.teamrole,
     [
         {'id':1, 'name':'admin'},
         {'id':2, 'name':'creator'},
@@ -164,7 +159,7 @@ def upgrade():
     )
     op.create_index('visibility_name', 'visibility', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['visibility'],
+    op.bulk_insert(tables.visibility,
     [
         {'id':1, 'name':'public'},
         {'id':2, 'name':'private'},
@@ -194,7 +189,7 @@ def upgrade():
     )
     op.create_index('buildtriggerservice_name', 'buildtriggerservice', ['name'], unique=False)
 
-    op.bulk_insert(schema.tables['buildtriggerservice'],
+    op.bulk_insert(tables.buildtriggerservice,
     [
         {'id':1, 'name':'github'},
     ])
@@ -490,119 +485,34 @@ def upgrade():
     ### end Alembic commands ###
 
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
-    op.drop_index('repositorybuild_uuid', table_name='repositorybuild')
-    op.drop_index('repositorybuild_trigger_id', table_name='repositorybuild')
-    op.drop_index('repositorybuild_resource_key', table_name='repositorybuild')
-    op.drop_index('repositorybuild_repository_id', table_name='repositorybuild')
-    op.drop_index('repositorybuild_pull_robot_id', table_name='repositorybuild')
-    op.drop_index('repositorybuild_access_token_id', table_name='repositorybuild')
     op.drop_table('repositorybuild')
-    op.drop_index('repositorybuildtrigger_write_token_id', table_name='repositorybuildtrigger')
-    op.drop_index('repositorybuildtrigger_service_id', table_name='repositorybuildtrigger')
-    op.drop_index('repositorybuildtrigger_repository_id', table_name='repositorybuildtrigger')
-    op.drop_index('repositorybuildtrigger_pull_robot_id', table_name='repositorybuildtrigger')
-    op.drop_index('repositorybuildtrigger_connected_user_id', table_name='repositorybuildtrigger')
     op.drop_table('repositorybuildtrigger')
-    op.drop_index('logentry_repository_id', table_name='logentry')
-    op.drop_index('logentry_performer_id', table_name='logentry')
-    op.drop_index('logentry_kind_id', table_name='logentry')
-    op.drop_index('logentry_datetime', table_name='logentry')
-    op.drop_index('logentry_account_id', table_name='logentry')
-    op.drop_index('logentry_access_token_id', table_name='logentry')
     op.drop_table('logentry')
-    op.drop_index('repositorytag_repository_id_name', table_name='repositorytag')
-    op.drop_index('repositorytag_repository_id', table_name='repositorytag')
-    op.drop_index('repositorytag_image_id', table_name='repositorytag')
     op.drop_table('repositorytag')
-    op.drop_index('permissionprototype_role_id', table_name='permissionprototype')
-    op.drop_index('permissionprototype_org_id_activating_user_id', table_name='permissionprototype')
-    op.drop_index('permissionprototype_org_id', table_name='permissionprototype')
-    op.drop_index('permissionprototype_delegate_user_id', table_name='permissionprototype')
-    op.drop_index('permissionprototype_delegate_team_id', table_name='permissionprototype')
-    op.drop_index('permissionprototype_activating_user_id', table_name='permissionprototype')
     op.drop_table('permissionprototype')
-    op.drop_index('image_storage_id', table_name='image')
-    op.drop_index('image_repository_id_docker_image_id', table_name='image')
-    op.drop_index('image_repository_id', table_name='image')
-    op.drop_index('image_ancestors', table_name='image')
     op.drop_table('image')
-    op.drop_index('oauthauthorizationcode_code', table_name='oauthauthorizationcode')
-    op.drop_index('oauthauthorizationcode_application_id', table_name='oauthauthorizationcode')
     op.drop_table('oauthauthorizationcode')
-    op.drop_index('webhook_repository_id', table_name='webhook')
-    op.drop_index('webhook_public_id', table_name='webhook')
     op.drop_table('webhook')
-    op.drop_index('teammember_user_id_team_id', table_name='teammember')
-    op.drop_index('teammember_user_id', table_name='teammember')
-    op.drop_index('teammember_team_id', table_name='teammember')
     op.drop_table('teammember')
-    op.drop_index('oauthaccesstoken_uuid', table_name='oauthaccesstoken')
-    op.drop_index('oauthaccesstoken_refresh_token', table_name='oauthaccesstoken')
-    op.drop_index('oauthaccesstoken_authorized_user_id', table_name='oauthaccesstoken')
-    op.drop_index('oauthaccesstoken_application_id', table_name='oauthaccesstoken')
-    op.drop_index('oauthaccesstoken_access_token', table_name='oauthaccesstoken')
     op.drop_table('oauthaccesstoken')
-    op.drop_index('repositorypermission_user_id_repository_id', table_name='repositorypermission')
-    op.drop_index('repositorypermission_user_id', table_name='repositorypermission')
-    op.drop_index('repositorypermission_team_id_repository_id', table_name='repositorypermission')
-    op.drop_index('repositorypermission_team_id', table_name='repositorypermission')
-    op.drop_index('repositorypermission_role_id', table_name='repositorypermission')
-    op.drop_index('repositorypermission_repository_id', table_name='repositorypermission')
     op.drop_table('repositorypermission')
-    op.drop_index('accesstoken_role_id', table_name='accesstoken')
-    op.drop_index('accesstoken_repository_id', table_name='accesstoken')
-    op.drop_index('accesstoken_code', table_name='accesstoken')
     op.drop_table('accesstoken')
-    op.drop_index('repository_visibility_id', table_name='repository')
-    op.drop_index('repository_namespace_name', table_name='repository')
     op.drop_table('repository')
-    op.drop_index('team_role_id', table_name='team')
-    op.drop_index('team_organization_id', table_name='team')
-    op.drop_index('team_name_organization_id', table_name='team')
-    op.drop_index('team_name', table_name='team')
     op.drop_table('team')
-    op.drop_index('emailconfirmation_user_id', table_name='emailconfirmation')
-    op.drop_index('emailconfirmation_code', table_name='emailconfirmation')
     op.drop_table('emailconfirmation')
-    op.drop_index('notification_uuid', table_name='notification')
-    op.drop_index('notification_target_id', table_name='notification')
-    op.drop_index('notification_kind_id', table_name='notification')
-    op.drop_index('notification_created', table_name='notification')
     op.drop_table('notification')
-    op.drop_index('oauthapplication_organization_id', table_name='oauthapplication')
-    op.drop_index('oauthapplication_client_id', table_name='oauthapplication')
     op.drop_table('oauthapplication')
-    op.drop_index('federatedlogin_user_id', table_name='federatedlogin')
-    op.drop_index('federatedlogin_service_id_user_id', table_name='federatedlogin')
-    op.drop_index('federatedlogin_service_id_service_ident', table_name='federatedlogin')
-    op.drop_index('federatedlogin_service_id', table_name='federatedlogin')
     op.drop_table('federatedlogin')
-    op.drop_index('buildtriggerservice_name', table_name='buildtriggerservice')
     op.drop_table('buildtriggerservice')
-    op.drop_index('user_username', table_name='user')
-    op.drop_index('user_stripe_id', table_name='user')
-    op.drop_index('user_robot', table_name='user')
-    op.drop_index('user_organization', table_name='user')
-    op.drop_index('user_email', table_name='user')
     op.drop_table('user')
-    op.drop_index('visibility_name', table_name='visibility')
     op.drop_table('visibility')
-    op.drop_index('teamrole_name', table_name='teamrole')
     op.drop_table('teamrole')
-    op.drop_index('notificationkind_name', table_name='notificationkind')
     op.drop_table('notificationkind')
-    op.drop_index('logentrykind_name', table_name='logentrykind')
     op.drop_table('logentrykind')
-    op.drop_index('role_name', table_name='role')
     op.drop_table('role')
-    op.drop_index('queueitem_queue_name', table_name='queueitem')
-    op.drop_index('queueitem_processing_expires', table_name='queueitem')
-    op.drop_index('queueitem_available_after', table_name='queueitem')
-    op.drop_index('queueitem_available', table_name='queueitem')
     op.drop_table('queueitem')
     op.drop_table('imagestorage')
-    op.drop_index('loginservice_name', table_name='loginservice')
     op.drop_table('loginservice')
     ### end Alembic commands ###
diff --git a/data/migrations/versions/82297d834ad_add_us_west_location.py b/data/migrations/versions/82297d834ad_add_us_west_location.py
index 59eb1f800..b939a939e 100644
--- a/data/migrations/versions/82297d834ad_add_us_west_location.py
+++ b/data/migrations/versions/82297d834ad_add_us_west_location.py
@@ -13,24 +13,17 @@ down_revision = '47670cbeced'
 from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 
-
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
-    op.bulk_insert(schema.tables['imagestoragelocation'],
+def upgrade(tables):
+    op.bulk_insert(tables.imagestoragelocation,
     [
         {'id':8, 'name':'s3_us_west_1'},
     ])
 
 
-def downgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
+def downgrade(tables):
     op.execute(
-        (imagestoragelocation.delete()
-            .where(imagestoragelocation.c.name == op.inline_literal('s3_us_west_1')))
+        (tables.imagestoragelocation.delete()
+            .where(tables.imagestoragelocation.c.name == op.inline_literal('s3_us_west_1')))
 
     )
diff --git a/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py b/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py
index eda4b2840..9fc433126 100644
--- a/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py
+++ b/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py
@@ -11,14 +11,10 @@ revision = 'bcdde200a1b'
 down_revision = '201d55b38649'
 
 from alembic import op
-from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
-from data.database import all_models
 import sqlalchemy as sa
 
 
-def upgrade():
-    schema = gen_sqlalchemy_metadata(all_models)
-
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.create_table('imagestoragelocation',
     sa.Column('id', sa.Integer(), nullable=False),
@@ -27,7 +23,7 @@ def upgrade():
     )
     op.create_index('imagestoragelocation_name', 'imagestoragelocation', ['name'], unique=True)
 
-    op.bulk_insert(schema.tables['imagestoragelocation'],
+    op.bulk_insert(tables.imagestoragelocation,
     [
         {'id':1, 'name':'s3_us_east_1'},
         {'id':2, 'name':'s3_eu_west_1'},
@@ -52,12 +48,8 @@ def upgrade():
     ### end Alembic commands ###
 
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
-    op.drop_index('imagestorageplacement_storage_id_location_id', table_name='imagestorageplacement')
-    op.drop_index('imagestorageplacement_storage_id', table_name='imagestorageplacement')
-    op.drop_index('imagestorageplacement_location_id', table_name='imagestorageplacement')
     op.drop_table('imagestorageplacement')
-    op.drop_index('imagestoragelocation_name', table_name='imagestoragelocation')
     op.drop_table('imagestoragelocation')
     ### end Alembic commands ###
diff --git a/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py
index 79ea17be0..9ceab4218 100644
--- a/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py
+++ b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py
@@ -14,13 +14,13 @@ from alembic import op
 import sqlalchemy as sa
 from sqlalchemy.dialects import mysql
 
-def upgrade():
+def upgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.drop_table('webhook')
     ### end Alembic commands ###
 
 
-def downgrade():
+def downgrade(tables):
     ### commands auto generated by Alembic - please adjust! ###
     op.create_table('webhook',
     sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
diff --git a/util/collections.py b/util/collections.py
new file mode 100644
index 000000000..b34dc00ed
--- /dev/null
+++ b/util/collections.py
@@ -0,0 +1,12 @@
+class AttrDict(dict):
+  def __init__(self, *args, **kwargs):
+    super(AttrDict, self).__init__(*args, **kwargs)
+    self.__dict__ = self
+
+  @classmethod
+  def deep_copy(cls, attr_dict):
+    copy = AttrDict(attr_dict)
+    for key, value in copy.items():
+      if isinstance(value, AttrDict):
+        copy[key] = cls.deep_copy(value)
+    return copy
\ No newline at end of file