Merge pull request #492 from coreos-inc/nofreelunch
UI and API fixes for disallowing private repo count abuse
This commit is contained in:
commit
c801965626
12 changed files with 267 additions and 112 deletions
|
@ -12,12 +12,42 @@ from auth.permissions import AdministerOrganizationPermission
|
|||
from auth.auth_context import get_authenticated_user
|
||||
from auth import scopes
|
||||
from data import model
|
||||
from data.billing import PLANS
|
||||
from data.billing import PLANS, get_plan
|
||||
|
||||
import features
|
||||
import uuid
|
||||
import json
|
||||
|
||||
def lookup_allowed_private_repos(namespace):
|
||||
""" Returns false if the given namespace has used its allotment of private repositories. """
|
||||
# Lookup the namespace and verify it has a subscription.
|
||||
namespace_user = model.user.get_namespace_user(namespace)
|
||||
if namespace_user is None:
|
||||
return False
|
||||
|
||||
if not namespace_user.stripe_id:
|
||||
return False
|
||||
|
||||
# Ask Stripe for the subscribed plan.
|
||||
# TODO: Can we cache this or make it faster somehow?
|
||||
try:
|
||||
cus = billing.Customer.retrieve(namespace_user.stripe_id)
|
||||
except stripe.APIConnectionError:
|
||||
abort(503, message='Cannot contact Stripe')
|
||||
|
||||
if not cus.subscription:
|
||||
return False
|
||||
|
||||
# Find the number of private repositories used by the namespace and compare it to the
|
||||
# plan subscribed.
|
||||
private_repos = model.user.get_private_repo_count(namespace)
|
||||
current_plan = get_plan(cus.subscription.plan.id)
|
||||
if current_plan is None:
|
||||
return False
|
||||
|
||||
return private_repos < current_plan['privateRepos']
|
||||
|
||||
|
||||
def carderror_response(e):
|
||||
return {'carderror': e.message}, 402
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import logging
|
||||
import datetime
|
||||
import features
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
|
@ -15,7 +16,8 @@ from endpoints.api import (truthy_bool, format_date, nickname, log_action, valid
|
|||
require_repo_read, require_repo_write, require_repo_admin,
|
||||
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
||||
request_error, require_scope, Unauthorized, NotFound, InvalidRequest,
|
||||
path_param)
|
||||
path_param, ExceedsLicenseException)
|
||||
from endpoints.api.billing import lookup_allowed_private_repos
|
||||
|
||||
from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission,
|
||||
CreateRepositoryPermission)
|
||||
|
@ -26,6 +28,18 @@ from auth import scopes
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_allowed_private_repos(namespace):
|
||||
""" Checks to see if the given namespace has reached its private repository limit. If so,
|
||||
raises a ExceedsLicenseException.
|
||||
"""
|
||||
# Not enabled if billing is disabled.
|
||||
if not features.BILLING:
|
||||
return
|
||||
|
||||
if not lookup_allowed_private_repos(namespace):
|
||||
raise ExceedsLicenseException()
|
||||
|
||||
|
||||
@resource('/v1/repository')
|
||||
class RepositoryList(ApiResource):
|
||||
"""Operations for creating and listing repositories."""
|
||||
|
@ -87,6 +101,8 @@ class RepositoryList(ApiResource):
|
|||
raise request_error(message='Repository already exists')
|
||||
|
||||
visibility = req['visibility']
|
||||
if visibility == 'private':
|
||||
check_allowed_private_repos(namespace_name)
|
||||
|
||||
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility)
|
||||
repo.description = req['description']
|
||||
|
@ -339,7 +355,11 @@ class RepositoryVisibility(RepositoryParamResource):
|
|||
repo = model.repository.get_repository(namespace, repository)
|
||||
if repo:
|
||||
values = request.get_json()
|
||||
model.repository.set_repository_visibility(repo, values['visibility'])
|
||||
visibility = values['visibility']
|
||||
if visibility == 'private':
|
||||
check_allowed_private_repos(namespace)
|
||||
|
||||
model.repository.set_repository_visibility(repo, visibility)
|
||||
log_action('change_repo_visibility', namespace,
|
||||
{'repo': repository, 'visibility': values['visibility']},
|
||||
repo=repo)
|
||||
|
|
|
@ -29,6 +29,18 @@
|
|||
margin-top: -7px !important;
|
||||
}
|
||||
|
||||
.repo-panel-settings-element .repo-count-checker {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.repo-panel-settings-element .co-alert {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.repo-panel-settings-element .panel-body {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.repo-panel-settings-element .delete-btn {
|
||||
float: none;
|
||||
|
|
26
static/css/directives/ui/repo-count-checker.css
Normal file
26
static/css/directives/ui/repo-count-checker.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.repo-count-checker .btn {
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
.repo-count-checker .co-alert {
|
||||
margin-bottom: 6px !important;
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
||||
.repo-count-checker .co-alert .btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.repo-count-checker .co-alert {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.repo-count-checker .co-alert .btn {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
24
static/directives/repo-count-checker.html
Normal file
24
static/directives/repo-count-checker.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
<div class="repo-count-checker-element">
|
||||
<div class="required-plan" ng-show="isEnabled && planRequired && planRequired.title">
|
||||
<div class="co-alert co-alert-info">
|
||||
In order to make this repository private under
|
||||
<strong ng-if="isUserNamespace">your personal namespace</strong>
|
||||
<strong ng-if="!isUserNamespace">organization <b>{{ repo.namespace }}</b></strong>, you will need to upgrade your plan to
|
||||
<b style="border-bottom: 1px dotted black;" data-html="true"
|
||||
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
|
||||
{{ planRequired.title }}
|
||||
</b>.
|
||||
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
||||
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
||||
</div>
|
||||
<span ng-if="isUserNamespace && user.organizations.length == 1" style="margin-left: 6px; display: inline-block;">or did you mean to have this repository under the <b>{{ user.organizations[0].name }}</b> namespace?</span>
|
||||
<div class="cor-loader-inline" ng-show="planChanging"></div>
|
||||
</div>
|
||||
<div class="cor-loader-inline" ng-show="isEnabled && checkingPlan"></div>
|
||||
<div class="required-plan" ng-show="isEnabled && planRequired && !isUserNamespace && !planRequired.title">
|
||||
<div class="co-alert co-alert-warning">
|
||||
This organization has reached its private repository limit. Please contact your administrator.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -22,12 +22,10 @@
|
|||
<div class="repository-events-table" repository="repository"
|
||||
is-enabled="isEnabled"></div>
|
||||
|
||||
<!-- Other settings -->
|
||||
<!-- Visibility settings -->
|
||||
<div class="co-panel">
|
||||
<div class="co-panel-heading"><i class="fa fa-gears"></i> Repository Settings</div>
|
||||
|
||||
<div class="co-panel-heading"><i class="fa fa-unlock-alt"></i> Repository Visibility</div>
|
||||
<div class="cor-loader" ng-show="!repository"></div>
|
||||
|
||||
<div ng-show="repository">
|
||||
<!-- Public/Private -->
|
||||
<div class="panel-body panel-section lock-section" ng-if="!repository.is_public">
|
||||
|
@ -44,12 +42,23 @@
|
|||
|
||||
<div>This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.</div>
|
||||
|
||||
<button class="btn btn-default" ng-click="askChangeAccess('private')">
|
||||
<button class="btn btn-default" ng-click="askChangeAccess('private')" ng-show="!planRequired">
|
||||
<i class="fa fa-lock"></i>Make Private
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Repository -->
|
||||
<!-- Payment -->
|
||||
<div class="repo-count-checker" namespace="repository.namespace" plan-required="planRequired"
|
||||
is-enabled="repository.is_public">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete repository -->
|
||||
<div class="co-panel">
|
||||
<div class="co-panel-heading"><i class="fa fa-trash"></i> Delete Repository</div>
|
||||
<div class="cor-loader" ng-show="!repository"></div>
|
||||
<div ng-show="repository">
|
||||
<div class="panel-body panel-section">
|
||||
<div class="co-alert co-alert-danger">
|
||||
<button class="btn btn-danger delete-btn" ng-click="askDelete()">
|
||||
|
@ -60,10 +69,16 @@
|
|||
Deleting a repository <b>cannot be undone</b>. Here be dragons!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Status Badge -->
|
||||
<div class="panel-body panel-section hidden-xs">
|
||||
|
||||
<!-- Build Status Badge -->
|
||||
<div class="co-panel hidden-xs">
|
||||
<div class="co-panel-heading"><i class="fa fa-tasks"></i> Build Status Badge</div>
|
||||
<div class="cor-loader" ng-show="!repository"></div>
|
||||
<div ng-show="repository">
|
||||
<div class="panel-body panel-section">
|
||||
<!-- Token Info Banner -->
|
||||
<div class="co-alert co-alert-info" ng-if="!repository.is_public">
|
||||
Note: This badge contains a token so the badge can be seen by external users. The token does not grant any other access and is safe to share!
|
||||
|
|
81
static/js/directives/ui/repo-count-checker.js
Normal file
81
static/js/directives/ui/repo-count-checker.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* An element which displays a message when the maximum number of private repositories has been
|
||||
* reached.
|
||||
*/
|
||||
angular.module('quay').directive('repoCountChecker', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-count-checker.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'namespace': '=namespace',
|
||||
'planRequired': '=planRequired',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, UserService, PlanService, Features) {
|
||||
var refresh = function() {
|
||||
$scope.planRequired = null;
|
||||
|
||||
if (!$scope.isEnabled || !$scope.namespace || !Features.BILLING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.checkingPlan = true;
|
||||
$scope.isUserNamespace = UserService.isUserNamespace($scope.namespace);
|
||||
|
||||
ApiService.getPrivateAllowed($scope.isUserNamespace ? null : $scope.namespace).then(function(resp) {
|
||||
$scope.checkingPlan = false;
|
||||
|
||||
if (resp['privateAllowed']) {
|
||||
$scope.planRequired = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp['privateCount'] == null) {
|
||||
// Organization where we are not the admin.
|
||||
$scope.planRequired = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, lookup the matching plan.
|
||||
PlanService.getMinimumPlan(resp['privateCount'] + 1, !$scope.isUserNamespace, function(minimum) {
|
||||
$scope.planRequired = minimum;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var subscribedToPlan = function(sub) {
|
||||
$scope.planChanging = false;
|
||||
$scope.subscription = sub;
|
||||
|
||||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||
$scope.subscribedPlan = subscribedPlan;
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('namespace', refresh);
|
||||
$scope.$watch('isEnabled', refresh);
|
||||
|
||||
$scope.upgradePlan = function() {
|
||||
var callbacks = {
|
||||
'started': function() { $scope.planChanging = true; },
|
||||
'opened': function() { $scope.planChanging = true; },
|
||||
'closed': function() { $scope.planChanging = false; },
|
||||
'success': subscribedToPlan,
|
||||
'failure': function(resp) {
|
||||
$('#couldnotsubscribeModal').modal();
|
||||
$scope.planChanging = false;
|
||||
}
|
||||
};
|
||||
|
||||
var isUserNamespace = UserService.isUserNamespace($scope.namespace);
|
||||
var namespace = isUserNamespace ? null : $scope.namespace;
|
||||
PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -22,22 +22,6 @@
|
|||
'initialize': ''
|
||||
};
|
||||
|
||||
// Watch the namespace on the repo. If it changes, we update the plan and the public/private
|
||||
// accordingly.
|
||||
$scope.isUserNamespace = true;
|
||||
$scope.$watch('repo.namespace', function(namespace) {
|
||||
// Note: Can initially be undefined.
|
||||
if (!namespace) { return; }
|
||||
|
||||
var isUserNamespace = (namespace == $scope.user.username);
|
||||
|
||||
$scope.planRequired = null;
|
||||
$scope.isUserNamespace = isUserNamespace;
|
||||
|
||||
// Determine whether private repositories are allowed for the namespace.
|
||||
checkPrivateAllowed();
|
||||
});
|
||||
|
||||
$scope.changeNamespace = function(namespace) {
|
||||
$scope.repo.namespace = namespace;
|
||||
};
|
||||
|
@ -108,65 +92,5 @@
|
|||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.upgradePlan = function() {
|
||||
var callbacks = {
|
||||
'started': function() { $scope.planChanging = true; },
|
||||
'opened': function() { $scope.planChanging = true; },
|
||||
'closed': function() { $scope.planChanging = false; },
|
||||
'success': subscribedToPlan,
|
||||
'failure': function(resp) {
|
||||
$('#couldnotsubscribeModal').modal();
|
||||
$scope.planChanging = false;
|
||||
}
|
||||
};
|
||||
|
||||
var namespace = $scope.isUserNamespace ? null : $scope.repo.namespace;
|
||||
PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks);
|
||||
};
|
||||
|
||||
var checkPrivateAllowed = function() {
|
||||
if (!$scope.repo || !$scope.repo.namespace) { return; }
|
||||
|
||||
if (!Features.BILLING) {
|
||||
$scope.checkingPlan = false;
|
||||
$scope.planRequired = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.checkingPlan = true;
|
||||
|
||||
var isUserNamespace = $scope.isUserNamespace;
|
||||
ApiService.getPrivateAllowed(isUserNamespace ? null : $scope.repo.namespace).then(function(resp) {
|
||||
$scope.checkingPlan = false;
|
||||
|
||||
if (resp['privateAllowed']) {
|
||||
$scope.planRequired = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp['privateCount'] == null) {
|
||||
// Organization where we are not the admin.
|
||||
$scope.planRequired = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, lookup the matching plan.
|
||||
PlanService.getMinimumPlan(resp['privateCount'] + 1, !isUserNamespace, function(minimum) {
|
||||
$scope.planRequired = minimum;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var subscribedToPlan = function(sub) {
|
||||
$scope.planChanging = false;
|
||||
$scope.subscription = sub;
|
||||
|
||||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||
$scope.subscribedPlan = subscribedPlan;
|
||||
$scope.planRequired = null;
|
||||
checkPrivateAllowed();
|
||||
});
|
||||
};
|
||||
}
|
||||
})();
|
|
@ -126,6 +126,10 @@ function(ApiService, CookieService, $rootScope, Config) {
|
|||
return userResponse;
|
||||
};
|
||||
|
||||
userService.isUserNamespace = function(namespace) {
|
||||
return namespace == userResponse.username;
|
||||
};
|
||||
|
||||
// Update the user in the root scope.
|
||||
userService.updateUserIn($rootScope);
|
||||
|
||||
|
|
|
@ -88,31 +88,9 @@
|
|||
</div>
|
||||
|
||||
<!-- Payment -->
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && planRequired.title">
|
||||
<div class="co-alert co-alert-warning">
|
||||
In order to make this repository private under
|
||||
<strong ng-if="isUserNamespace">your personal namespace</strong>
|
||||
<strong ng-if="!isUserNamespace">organization <b>{{ repo.namespace }}</b></strong>, you will need to upgrade your plan to
|
||||
<b style="border-bottom: 1px dotted black;" data-html="true"
|
||||
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
|
||||
{{ planRequired.title }}
|
||||
</b>.
|
||||
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
||||
</div>
|
||||
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
||||
<span ng-if="isUserNamespace && user.organizations.length == 1" style="margin-left: 6px; display: inline-block;">or did you mean to create this repository
|
||||
under <a href="javascript:void(0)" ng-click="changeNamespace(user.organizations[0].name)"><b>{{ user.organizations[0].name }}</b></a>?</span>
|
||||
<div class="cor-loader-inline" ng-show="planChanging"></div>
|
||||
<div class="repo-count-checker" namespace="repo.namespace" plan-required="planRequired"
|
||||
is-enabled="repo.is_public == '0'">
|
||||
</div>
|
||||
|
||||
<div class="cor-loader-inline" ng-show="repo.is_public == '0' && checkingPlan"></div>
|
||||
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace && !planRequired.title">
|
||||
<div class="co-alert co-alert-warning">
|
||||
This organization has reached its private repository limit. Please contact your administrator.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -317,8 +317,18 @@ class TestGetUserPrivateAllowed(ApiTestCase):
|
|||
|
||||
def test_allowed(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
# Change the subscription of the namespace.
|
||||
self.putJsonResponse(UserPlan, data=dict(plan='personal-30'))
|
||||
|
||||
json = self.getJsonResponse(PrivateRepositories)
|
||||
assert json['privateCount'] >= 6
|
||||
assert not json['privateAllowed']
|
||||
|
||||
# Change the subscription of the namespace.
|
||||
self.putJsonResponse(UserPlan, data=dict(plan='bus-large-30'))
|
||||
|
||||
json = self.getJsonResponse(PrivateRepositories)
|
||||
assert json['privateAllowed']
|
||||
|
||||
|
||||
|
@ -1437,6 +1447,36 @@ class TestUpdateRepo(ApiTestCase):
|
|||
|
||||
class TestChangeRepoVisibility(ApiTestCase):
|
||||
SIMPLE_REPO = ADMIN_ACCESS_USER + '/simple'
|
||||
|
||||
def test_trychangevisibility(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
# Make public.
|
||||
self.postJsonResponse(RepositoryVisibility,
|
||||
params=dict(repository=self.SIMPLE_REPO),
|
||||
data=dict(visibility='public'))
|
||||
|
||||
# Verify the visibility.
|
||||
json = self.getJsonResponse(Repository,
|
||||
params=dict(repository=self.SIMPLE_REPO))
|
||||
|
||||
self.assertEquals(True, json['is_public'])
|
||||
|
||||
# Change the subscription of the namespace.
|
||||
self.putJsonResponse(UserPlan, data=dict(plan='personal-30'))
|
||||
|
||||
# Try to make private.
|
||||
self.postJsonResponse(RepositoryVisibility,
|
||||
params=dict(repository=self.SIMPLE_REPO),
|
||||
data=dict(visibility='private'),
|
||||
expected_code=402)
|
||||
|
||||
# Verify the visibility.
|
||||
json = self.getJsonResponse(Repository,
|
||||
params=dict(repository=self.SIMPLE_REPO))
|
||||
|
||||
self.assertEquals(True, json['is_public'])
|
||||
|
||||
def test_changevisibility(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ TEST_DB_FILE = NamedTemporaryFile(delete=True)
|
|||
class TestConfig(DefaultConfig):
|
||||
TESTING = True
|
||||
SECRET_KEY = 'a36c9d7d-25a9-4d3f-a586-3d2f8dc40a83'
|
||||
BILLING_TYPE = 'FakeStripe'
|
||||
|
||||
TEST_DB_FILE = TEST_DB_FILE
|
||||
DB_URI = os.environ.get('TEST_DATABASE_URI', 'sqlite:///{0}'.format(TEST_DB_FILE.name))
|
||||
|
|
Reference in a new issue