diff --git a/data/model.py b/data/model.py index ddfa74417..9182950be 100644 --- a/data/model.py +++ b/data/model.py @@ -386,7 +386,6 @@ def get_matching_teams(team_prefix, organization): def get_matching_users(username_prefix, robot_namespace=None, organization=None): - Org = User.alias() direct_user_query = (User.username ** (username_prefix + '%') & (User.organization == False) & (User.robot == False)) @@ -396,14 +395,16 @@ def get_matching_users(username_prefix, robot_namespace=None, (User.username ** (robot_prefix + '%') & (User.robot == True))) - query = User.select(User.username, Org.username, User.robot).where(direct_user_query) + query = (User + .select(User.username, fn.Sum(Team.id), User.robot) + .group_by(User.username) + .where(direct_user_query)) if organization: - with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team, - JOIN_LEFT_OUTER) - with_org = with_team.join(Org, JOIN_LEFT_OUTER, - on=(Org.id == Team.organization)) - query = with_org.where((Org.id == organization) | (Org.id >> None)) + query = (query + .join(TeamMember, JOIN_LEFT_OUTER) + .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) & + (Team.organization == organization)))) class MatchingUserResult(object): @@ -411,7 +412,7 @@ def get_matching_users(username_prefix, robot_namespace=None, self.username = args[0] self.is_robot = args[2] if organization: - self.is_org_member = (args[1] == organization.username) + self.is_org_member = (args[1] != None) else: self.is_org_member = None diff --git a/data/plans.py b/data/plans.py index aa17ed4b7..2b8b6af2b 100644 --- a/data/plans.py +++ b/data/plans.py @@ -1,20 +1,13 @@ -import json -import itertools - -USER_PLANS = [ - { - 'title': 'Open Source', - 'price': 0, - 'privateRepos': 0, - 'stripeId': 'free', - 'audience': 'Share with the world', - }, +PLANS = [ + # Deprecated Plans { 'title': 'Micro', 'price': 700, 'privateRepos': 5, 'stripeId': 'micro', 'audience': 'For smaller teams', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Basic', @@ -22,6 +15,8 @@ USER_PLANS = [ 'privateRepos': 10, 'stripeId': 'small', 'audience': 'For your basic team', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Medium', @@ -29,6 +24,8 @@ USER_PLANS = [ 'privateRepos': 20, 'stripeId': 'medium', 'audience': 'For medium teams', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Large', @@ -36,16 +33,28 @@ USER_PLANS = [ 'privateRepos': 50, 'stripeId': 'large', 'audience': 'For larger teams', + 'bus_features': False, + 'deprecated': True, }, -] -BUSINESS_PLANS = [ + # Active plans { 'title': 'Open Source', 'price': 0, 'privateRepos': 0, - 'stripeId': 'bus-free', + 'stripeId': 'free', 'audience': 'Committment to FOSS', + 'bus_features': False, + 'deprecated': False, + }, + { + 'title': 'Personal', + 'price': 1200, + 'privateRepos': 5, + 'stripeId': 'personal', + 'audience': 'Individuals', + 'bus_features': False, + 'deprecated': False, }, { 'title': 'Skiff', @@ -53,6 +62,8 @@ BUSINESS_PLANS = [ 'privateRepos': 10, 'stripeId': 'bus-micro', 'audience': 'For startups', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Yacht', @@ -60,6 +71,8 @@ BUSINESS_PLANS = [ 'privateRepos': 20, 'stripeId': 'bus-small', 'audience': 'For small businesses', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Freighter', @@ -67,6 +80,8 @@ BUSINESS_PLANS = [ 'privateRepos': 50, 'stripeId': 'bus-medium', 'audience': 'For normal businesses', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Tanker', @@ -74,14 +89,16 @@ BUSINESS_PLANS = [ 'privateRepos': 125, 'stripeId': 'bus-large', 'audience': 'For large businesses', + 'bus_features': True, + 'deprecated': False, }, ] -def get_plan(id): +def get_plan(plan_id): """ Returns the plan with the given ID or None if none. """ - for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS): - if plan['stripeId'] == id: + for plan in PLANS: + if plan['stripeId'] == plan_id: return plan return None diff --git a/endpoints/api.py b/endpoints/api.py index eb55a38ae..b954befb1 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -12,7 +12,7 @@ from collections import defaultdict from data import model from data.queue import dockerfile_build_queue -from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan +from data.plans import PLANS, get_plan from app import app from util.email import send_confirmation_email, send_recovery_email from util.names import parse_repository_name, format_robot_username @@ -26,19 +26,53 @@ from auth.permissions import (ReadRepositoryPermission, OrganizationMemberPermission, ViewTeamPermission) from endpoints import registry -from endpoints.web import common_login +from endpoints.common import common_login from util.cache import cache_control from datetime import datetime, timedelta + store = app.config['STORAGE'] user_files = app.config['USERFILES'] logger = logging.getLogger(__name__) +route_data = None + +def get_route_data(): + global route_data + if route_data: + return route_data + + routes = [] + for rule in app.url_map.iter_rules(): + if rule.rule.startswith('/api/'): + endpoint_method = globals()[rule.endpoint] + is_internal = '__internal_call' in dir(endpoint_method) + is_org_api = '__user_call' in dir(endpoint_method) + methods = list(rule.methods.difference(['HEAD', 'OPTIONS'])) + + route = { + 'name': rule.endpoint, + 'methods': methods, + 'path': rule.rule, + 'parameters': list(rule.arguments) + } + + if is_org_api: + route['user_method'] = endpoint_method.__user_call + + routes.append(route) + + route_data = { + 'endpoints': routes + } + return route_data + def log_action(kind, user_or_orgname, metadata={}, repo=None): performer = current_user.db_user() - model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr, - metadata=metadata, repository=repo) + model.log_action(kind, user_or_orgname, performer=performer, + ip=request.remote_addr, metadata=metadata, repository=repo) + def api_login_required(f): @wraps(f) @@ -58,26 +92,51 @@ def api_login_required(f): return decorated_view +def internal_api_call(f): + @wraps(f) + def decorated_view(*args, **kwargs): + return f(*args, **kwargs) + + decorated_view.__internal_call = True + return decorated_view + + +def org_api_call(user_call_name): + def internal_decorator(f): + @wraps(f) + def decorated_view(*args, **kwargs): + return f(*args, **kwargs) + + decorated_view.__user_call = user_call_name + return decorated_view + + return internal_decorator + @app.errorhandler(model.DataModelException) def handle_dme(ex): return make_response(ex.message, 400) @app.errorhandler(KeyError) -def handle_dme(ex): +def handle_dme_key_error(ex): return make_response(ex.message, 400) +@app.route('/api/discovery') +def discovery(): + return jsonify(get_route_data()) + + @app.route('/api/') +@internal_api_call def welcome(): return make_response('welcome', 200) @app.route('/api/plans/') -def plans_list(): +def list_plans(): return jsonify({ - 'user': USER_PLANS, - 'business': BUSINESS_PLANS, + 'plans': PLANS, }) @@ -107,6 +166,7 @@ def user_view(user): @app.route('/api/user/', methods=['GET']) +@internal_api_call def get_logged_in_user(): if current_user.is_anonymous(): return jsonify({'anonymous': True}) @@ -120,6 +180,7 @@ def get_logged_in_user(): @app.route('/api/user/private', methods=['GET']) @api_login_required +@internal_api_call def get_user_private_count(): user = current_user.db_user() private_repos = model.get_private_repo_count(user.username) @@ -140,6 +201,7 @@ def get_user_private_count(): @app.route('/api/user/convert', methods=['POST']) @api_login_required +@internal_api_call def convert_user_to_organization(): user = current_user.db_user() convert_data = request.get_json() @@ -164,7 +226,7 @@ def convert_user_to_organization(): # Subscribe the organization to the new plan. plan = convert_data['plan'] - subscribe(user, plan, None, BUSINESS_PLANS) + subscribe(user, plan, None, True) # Require business plans # Convert the user to an organization. model.convert_user_to_organization(user, model.get_user(admin_username)) @@ -172,11 +234,11 @@ def convert_user_to_organization(): # And finally login with the admin credentials. return conduct_signin(admin_username, admin_password) - @app.route('/api/user/', methods=['PUT']) @api_login_required +@internal_api_call def change_user_details(): user = current_user.db_user() @@ -203,7 +265,8 @@ def change_user_details(): @app.route('/api/user/', methods=['POST']) -def create_user_api(): +@internal_api_call +def create_new_user(): user_data = request.get_json() existing_user = model.get_user(user_data['username']) @@ -229,7 +292,8 @@ def create_user_api(): @app.route('/api/signin', methods=['POST']) -def signin_api(): +@internal_api_call +def signin_user(): signin_data = request.get_json() username = signin_data['username'] @@ -263,6 +327,7 @@ def conduct_signin(username, password): @app.route("/api/signout", methods=['POST']) @api_login_required +@internal_api_call def logout(): logout_user() identity_changed.send(app, identity=AnonymousIdentity()) @@ -270,7 +335,8 @@ def logout(): @app.route("/api/recovery", methods=['POST']) -def send_recovery(): +@internal_api_call +def request_recovery_email(): email = request.get_json()['email'] code = model.create_reset_password_email_code(email) send_recovery_email(email, code.code) @@ -355,7 +421,8 @@ def team_view(orgname, team): @app.route('/api/organization/', methods=['POST']) @api_login_required -def create_organization_api(): +@internal_api_call +def create_organization(): org_data = request.get_json() existing = None @@ -419,6 +486,7 @@ def get_organization(orgname): @app.route('/api/organization/', methods=['PUT']) @api_login_required +@org_api_call('change_user_details') def change_organization_details(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): @@ -427,7 +495,7 @@ def change_organization_details(orgname): except model.InvalidOrganizationException: abort(404) - org_data = request.get_json(); + org_data = request.get_json() if 'invoice_email' in org_data: logger.debug('Changing invoice_email for organization: %s', org.username) model.change_invoice_email(org, org_data['invoice_email']) @@ -477,7 +545,7 @@ def get_organization_member(orgname, membername): abort(404) member_dict = None - member_teams = model.get_organization_members_with_teams(org, membername = membername) + member_teams = model.get_organization_members_with_teams(org, membername=membername) for member in member_teams: if not member_dict: member_dict = {'username': member.user.username, @@ -496,6 +564,7 @@ def get_organization_member(orgname, membername): @app.route('/api/organization//private', methods=['GET']) @api_login_required +@internal_api_call def get_organization_private_allowed(orgname): permission = CreateRepositoryPermission(orgname) if permission.can(): @@ -551,17 +620,20 @@ def update_organization_team(orgname, teamname): log_action('org_create_team', orgname, {'team': teamname}) if is_existing: - if 'description' in details and team.description != details['description']: + if ('description' in details and + team.description != details['description']): team.description = details['description'] team.save() - log_action('org_set_team_description', orgname, {'team': teamname, 'description': team.description}) + log_action('org_set_team_description', orgname, + {'team': teamname, 'description': team.description}) if 'role' in details: role = model.get_team_org_role(team).name if role != details['role']: team = model.set_team_org_permission(team, details['role'], current_user.db_user().username) - log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']}) + log_action('org_set_team_role', orgname, + {'team': teamname, 'role': details['role']}) resp = jsonify(team_view(orgname, team)) if not is_existing: @@ -629,7 +701,8 @@ def update_organization_team_member(orgname, teamname, membername): # Add the user to the team. model.add_user_to_team(user, team) - log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) + log_action('org_add_team_member', orgname, + {'member': membername, 'team': teamname}) return jsonify(member_view(user)) abort(403) @@ -644,7 +717,8 @@ def delete_organization_team_member(orgname, teamname, membername): # Remote the user from the team. invoking_user = current_user.db_user().username model.remove_user_from_team(orgname, teamname, membername, invoking_user) - log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname}) + log_action('org_remove_team_member', orgname, + {'member': membername, 'team': teamname}) return make_response('Deleted', 204) abort(403) @@ -652,7 +726,7 @@ def delete_organization_team_member(orgname, teamname, membername): @app.route('/api/repository', methods=['POST']) @api_login_required -def create_repo_api(): +def create_repo(): owner = current_user.db_user() req = request.get_json() namespace_name = req['namespace'] if 'namespace' in req else owner.username @@ -673,7 +747,9 @@ def create_repo_api(): repo.description = req['description'] repo.save() - log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo) + log_action('create_repo', namespace_name, + {'repo': repository_name, 'namespace': namespace_name}, + repo=repo) return jsonify({ 'namespace': namespace_name, 'name': repository_name @@ -683,7 +759,7 @@ def create_repo_api(): @app.route('/api/find/repository', methods=['GET']) -def match_repos_api(): +def find_repos(): prefix = request.args.get('query', '') def repo_view(repo): @@ -706,7 +782,7 @@ def match_repos_api(): @app.route('/api/repository/', methods=['GET']) -def list_repos_api(): +def list_repos(): def repo_view(repo_obj): return { 'namespace': repo_obj.namespace, @@ -749,7 +825,7 @@ def list_repos_api(): @app.route('/api/repository/', methods=['PUT']) @api_login_required @parse_repository_name -def update_repo_api(namespace, repository): +def update_repo(namespace, repository): permission = ModifyRepositoryPermission(namespace, repository) if permission.can(): repo = model.get_repository(namespace, repository) @@ -758,7 +834,8 @@ def update_repo_api(namespace, repository): repo.description = values['description'] repo.save() - log_action('set_repo_description', namespace, {'repo': repository, 'description': values['description']}, + log_action('set_repo_description', namespace, + {'repo': repository, 'description': values['description']}, repo=repo) return jsonify({ 'success': True @@ -771,14 +848,15 @@ def update_repo_api(namespace, repository): methods=['POST']) @api_login_required @parse_repository_name -def change_repo_visibility_api(namespace, repository): +def change_repo_visibility(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): repo = model.get_repository(namespace, repository) if repo: values = request.get_json() model.set_repository_visibility(repo, values['visibility']) - log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']}, + log_action('change_repo_visibility', namespace, + {'repo': repository, 'visibility': values['visibility']}, repo=repo) return jsonify({ 'success': True @@ -795,7 +873,8 @@ def delete_repository(namespace, repository): if permission.can(): model.purge_repository(namespace, repository) registry.delete_repository_storage(namespace, repository) - log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace}) + log_action('delete_repo', namespace, + {'repo': repository, 'namespace': namespace}) return make_response('Deleted', 204) abort(403) @@ -813,7 +892,7 @@ def image_view(image): @app.route('/api/repository/', methods=['GET']) @parse_repository_name -def get_repo_api(namespace, repository): +def get_repo(namespace, repository): logger.debug('Get repo: %s/%s' % (namespace, repository)) def tag_view(tag): @@ -912,7 +991,8 @@ def request_repo_build(namespace, repository): dockerfile_build_queue.put(json.dumps({'build_id': build_request.id})) log_action('build_dockerfile', namespace, - {'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo) + {'repo': repository, 'namespace': namespace, + 'fileid': dockerfile_id}, repo=repo) resp = jsonify({ 'started': True @@ -943,7 +1023,8 @@ def create_webhook(namespace, repository): resp.headers['Location'] = url_for('get_webhook', repository=repo_string, public_id=webhook.public_id) log_action('add_repo_webhook', namespace, - {'repo': repository, 'webhook_id': webhook.public_id}, repo=repo) + {'repo': repository, 'webhook_id': webhook.public_id}, + repo=repo) return resp abort(403) # Permissions denied @@ -994,6 +1075,7 @@ def delete_webhook(namespace, repository, public_id): @app.route('/api/filedrop/', methods=['POST']) @api_login_required +@internal_api_call def get_filedrop_url(): mime_type = request.get_json()['mimeType'] (url, file_id) = user_files.prepare_for_drop(mime_type) @@ -1143,7 +1225,8 @@ def list_repo_user_permissions(namespace, repository): current_func = role_view_func def wrapped_role_org_view(repo_perm): - return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members) + return wrap_role_view_org(current_func(repo_perm), repo_perm.user, + org_members) role_view_func = wrapped_role_org_view @@ -1228,7 +1311,8 @@ def change_user_permissions(namespace, repository, username): return error_resp log_action('change_repo_permission', namespace, - {'username': username, 'repo': repository, 'role': new_permission['role']}, + {'username': username, 'repo': repository, + 'role': new_permission['role']}, repo=model.get_repository(namespace, repository)) resp = jsonify(perm_view) @@ -1255,7 +1339,8 @@ def change_team_permissions(namespace, repository, teamname): new_permission['role']) log_action('change_repo_permission', namespace, - {'team': teamname, 'repo': repository, 'role': new_permission['role']}, + {'team': teamname, 'repo': repository, + 'role': new_permission['role']}, repo=model.get_repository(namespace, repository)) resp = jsonify(role_view(perm)) @@ -1282,7 +1367,8 @@ def delete_user_permissions(namespace, repository, username): error_resp.status_code = 400 return error_resp - log_action('delete_repo_permission', namespace, {'username': username, 'repo': repository}, + log_action('delete_repo_permission', namespace, + {'username': username, 'repo': repository}, repo=model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1299,7 +1385,8 @@ def delete_team_permissions(namespace, repository, teamname): if permission.can(): model.delete_team_permission(teamname, namespace, repository) - log_action('delete_repo_permission', namespace, {'team': teamname, 'repo': repository}, + log_action('delete_repo_permission', namespace, + {'team': teamname, 'repo': repository}, repo=model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1353,7 +1440,8 @@ def create_token(namespace, repository): token = model.create_delegate_token(namespace, repository, token_params['friendlyName']) - log_action('add_repo_accesstoken', namespace, {'repo': repository, 'token': token_params['friendlyName']}, + log_action('add_repo_accesstoken', namespace, + {'repo': repository, 'token': token_params['friendlyName']}, repo = model.get_repository(namespace, repository)) resp = jsonify(token_view(token)) @@ -1378,7 +1466,8 @@ def change_token(namespace, repository, code): new_permission['role']) log_action('change_repo_permission', namespace, - {'repo': repository, 'token': token.friendly_name, 'code': code, 'role': new_permission['role']}, + {'repo': repository, 'token': token.friendly_name, 'code': code, + 'role': new_permission['role']}, repo = model.get_repository(namespace, repository)) resp = jsonify(token_view(token)) @@ -1397,7 +1486,8 @@ def delete_token(namespace, repository, code): token = model.delete_delegate_token(namespace, repository, code) log_action('delete_repo_accesstoken', namespace, - {'repo': repository, 'token': token.friendly_name, 'code': code}, + {'repo': repository, 'token': token.friendly_name, + 'code': code}, repo = model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1416,14 +1506,17 @@ def subscription_view(stripe_subscription, used_repos): @app.route('/api/user/card', methods=['GET']) @api_login_required -def get_user_card_api(): +@internal_api_call +def get_user_card(): user = current_user.db_user() return get_card(user) @app.route('/api/organization//card', methods=['GET']) @api_login_required -def get_org_card_api(orgname): +@internal_api_call +@org_api_call('get_user_card') +def get_org_card(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): organization = model.get_organization(orgname) @@ -1434,7 +1527,8 @@ def get_org_card_api(orgname): @app.route('/api/user/card', methods=['POST']) @api_login_required -def set_user_card_api(): +@internal_api_call +def set_user_card(): user = current_user.db_user() token = request.get_json()['token'] response = set_card(user, token) @@ -1444,7 +1538,8 @@ def set_user_card_api(): @app.route('/api/organization//card', methods=['POST']) @api_login_required -def set_org_card_api(orgname): +@org_api_call('set_user_card') +def set_org_card(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): organization = model.get_organization(orgname) @@ -1486,21 +1581,22 @@ def get_card(user): if default_card: card_info = { - 'owner': card.name, - 'type': card.type, - 'last4': card.last4 + 'owner': default_card.name, + 'type': default_card.type, + 'last4': default_card.last4 } return jsonify({'card': card_info}) @app.route('/api/user/plan', methods=['PUT']) @api_login_required -def subscribe_api(): +@internal_api_call +def update_user_subscription(): request_data = request.get_json() plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None user = current_user.db_user() - return subscribe(user, plan, token, USER_PLANS) + return subscribe(user, plan, token, False) # Business features not required def carderror_response(e): @@ -1511,15 +1607,22 @@ def carderror_response(e): return resp -def subscribe(user, plan, token, accepted_plans): +def subscribe(user, plan, token, require_business_plan): plan_found = None - for plan_obj in accepted_plans: + for plan_obj in PLANS: if plan_obj['stripeId'] == plan: plan_found = plan_obj - if not plan_found: + if not plan_found or plan_found['deprecated']: + logger.warning('Plan not found or deprecated: %s', plan) abort(404) + if (require_business_plan and not plan_found['bus_features'] and not + plan_found['price'] == 0): + logger.warning('Business attempting to subscribe to personal plan: %s', + user.username) + abort(400) + private_repos = model.get_private_repo_count(user.username) # This is the default response @@ -1578,9 +1681,32 @@ def subscribe(user, plan, token, accepted_plans): return resp +@app.route('/api/user/invoices', methods=['GET']) +@api_login_required +def list_user_invoices(): + user = current_user.db_user() + if not user.stripe_id: + abort(404) + + return get_invoices(user.stripe_id) + + @app.route('/api/organization//invoices', methods=['GET']) @api_login_required -def org_invoices_api(orgname): +@org_api_call('list_user_invoices') +def list_org_invoices(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + abort(404) + + return get_invoices(organization.stripe_id) + + abort(403) + + +def get_invoices(customer_id): def invoice_view(i): return { 'id': i.id, @@ -1596,37 +1722,32 @@ def org_invoices_api(orgname): 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None } - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - if not organization.stripe_id: - abort(404) - - invoices = stripe.Invoice.all(customer=organization.stripe_id, count=12) - return jsonify({ - 'invoices': [invoice_view(i) for i in invoices.data] - }) - - abort(403) + invoices = stripe.Invoice.all(customer=customer_id, count=12) + return jsonify({ + 'invoices': [invoice_view(i) for i in invoices.data] + }) @app.route('/api/organization//plan', methods=['PUT']) @api_login_required -def subscribe_org_api(orgname): +@internal_api_call +@org_api_call('update_user_subscription') +def update_org_subscription(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): request_data = request.get_json() plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None organization = model.get_organization(orgname) - return subscribe(organization, plan, token, BUSINESS_PLANS) + return subscribe(organization, plan, token, True) # Business plan required abort(403) @app.route('/api/user/plan', methods=['GET']) @api_login_required -def get_subscription(): +@internal_api_call +def get_user_subscription(): user = current_user.db_user() private_repos = model.get_private_repo_count(user.username) @@ -1644,6 +1765,8 @@ def get_subscription(): @app.route('/api/organization//plan', methods=['GET']) @api_login_required +@internal_api_call +@org_api_call('get_user_subscription') def get_org_subscription(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): @@ -1656,7 +1779,7 @@ def get_org_subscription(orgname): return jsonify(subscription_view(cus.subscription, private_repos)) return jsonify({ - 'plan': 'bus-free', + 'plan': 'free', 'usedPrivateRepos': private_repos, }) @@ -1682,6 +1805,7 @@ def get_user_robots(): @app.route('/api/organization//robots', methods=['GET']) @api_login_required +@org_api_call('get_user_robots') def get_org_robots(orgname): permission = OrganizationMemberPermission(orgname) if permission.can(): @@ -1695,7 +1819,7 @@ def get_org_robots(orgname): @app.route('/api/user/robots/', methods=['PUT']) @api_login_required -def create_robot(robot_shortname): +def create_user_robot(robot_shortname): parent = current_user.db_user() robot, password = model.create_robot(robot_shortname, parent) resp = jsonify(robot_view(robot.username, password)) @@ -1707,6 +1831,7 @@ def create_robot(robot_shortname): @app.route('/api/organization//robots/', methods=['PUT']) @api_login_required +@org_api_call('create_user_robot') def create_org_robot(orgname, robot_shortname): permission = AdministerOrganizationPermission(orgname) if permission.can(): @@ -1722,7 +1847,7 @@ def create_org_robot(orgname, robot_shortname): @app.route('/api/user/robots/', methods=['DELETE']) @api_login_required -def delete_robot(robot_shortname): +def delete_user_robot(robot_shortname): parent = current_user.db_user() model.delete_robot(format_robot_username(parent.username, robot_shortname)) log_action('delete_robot', parent.username, {'robot': robot_shortname}) @@ -1732,6 +1857,7 @@ def delete_robot(robot_shortname): @app.route('/api/organization//robots/', methods=['DELETE']) @api_login_required +@org_api_call('delete_user_robot') def delete_org_robot(orgname, robot_shortname): permission = AdministerOrganizationPermission(orgname) if permission.can(): @@ -1743,27 +1869,27 @@ def delete_org_robot(orgname, robot_shortname): def log_view(log): - view = { - 'kind': log.kind.name, - 'metadata': json.loads(log.metadata_json), - 'ip': log.ip, - 'datetime': log.datetime, + view = { + 'kind': log.kind.name, + 'metadata': json.loads(log.metadata_json), + 'ip': log.ip, + 'datetime': log.datetime, + } + + if log.performer: + view['performer'] = { + 'username': log.performer.username, + 'is_robot': log.performer.robot, } - if log.performer: - view['performer'] = { - 'username': log.performer.username, - 'is_robot': log.performer.robot, - } - - return view + return view @app.route('/api/repository//logs', methods=['GET']) @api_login_required @parse_repository_name -def repo_logs_api(namespace, repository): +def list_repo_logs(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): repo = model.get_repository(namespace, repository) @@ -1779,19 +1905,33 @@ def repo_logs_api(namespace, repository): @app.route('/api/organization//logs', methods=['GET']) @api_login_required -def org_logs_api(orgname): +@org_api_call('list_user_logs') +def list_org_logs(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): performer_name = request.args.get('performer', None) start_time = request.args.get('starttime', None) end_time = request.args.get('endtime', None) - return get_logs(orgname, start_time, end_time, performer_name=performer_name) + return get_logs(orgname, start_time, end_time, + performer_name=performer_name) abort(403) -def get_logs(namespace, start_time, end_time, performer_name=None, repository=None): +@app.route('/api/user/logs', methods=['GET']) +@api_login_required +def list_user_logs(): + performer_name = request.args.get('performer', None) + start_time = request.args.get('starttime', None) + end_time = request.args.get('endtime', None) + + return get_logs(current_user.db_user().username, start_time, end_time, + performer_name=performer_name) + + +def get_logs(namespace, start_time, end_time, performer_name=None, + repository=None): performer = None if performer_name: performer = model.get_user(performer_name) @@ -1815,7 +1955,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, repository=No if not end_time: end_time = datetime.today() - logs = model.list_logs(namespace, start_time, end_time, performer = performer, repository=repository) + logs = model.list_logs(namespace, start_time, end_time, performer=performer, + repository=repository) return jsonify({ 'start_time': start_time, 'end_time': end_time, diff --git a/endpoints/common.py b/endpoints/common.py new file mode 100644 index 000000000..d6bd0125a --- /dev/null +++ b/endpoints/common.py @@ -0,0 +1,48 @@ +import logging + +from flask.ext.login import login_user, UserMixin +from flask.ext.principal import identity_changed + +from data import model +from app import app, login_manager +from auth.permissions import QuayDeferredPermissionUser + + +logger = logging.getLogger(__name__) + + +@login_manager.user_loader +def load_user(username): + logger.debug('Loading user: %s' % username) + return _LoginWrappedDBUser(username) + +class _LoginWrappedDBUser(UserMixin): + def __init__(self, db_username, db_user=None): + + self._db_username = db_username + self._db_user = db_user + + def db_user(self): + if not self._db_user: + self._db_user = model.get_user(self._db_username) + return self._db_user + + def is_authenticated(self): + return self.db_user() is not None + + def is_active(self): + return self.db_user().verified + + def get_id(self): + return unicode(self._db_username) + + +def common_login(db_user): + if login_user(_LoginWrappedDBUser(db_user.username, db_user)): + logger.debug('Successfully signed in as: %s' % db_user.username) + new_identity = QuayDeferredPermissionUser(db_user.username, 'username') + identity_changed.send(app, identity=new_identity) + return True + else: + logger.debug('User could not be logged in, inactive?.') + return False diff --git a/endpoints/web.py b/endpoints/web.py index 22fb279a1..82ee2bc50 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -4,53 +4,30 @@ import stripe from flask import (abort, redirect, request, url_for, render_template, make_response, Response) -from flask.ext.login import login_user, UserMixin -from flask.ext.principal import identity_changed +from flask.ext.login import current_user from urlparse import urlparse from data import model -from app import app, login_manager, mixpanel -from auth.permissions import (QuayDeferredPermissionUser, - AdministerOrganizationPermission) +from app import app, mixpanel +from auth.permissions import AdministerOrganizationPermission from util.invoice import renderInvoiceToPdf from util.seo import render_snapshot +from endpoints.api import get_route_data +from endpoints.common import common_login logger = logging.getLogger(__name__) -class _LoginWrappedDBUser(UserMixin): - def __init__(self, db_username, db_user=None): - - self._db_username = db_username - self._db_user = db_user - - def db_user(self): - if not self._db_user: - self._db_user = model.get_user(self._db_username) - return self._db_user - - def is_authenticated(self): - return self.db_user() is not None - - def is_active(self): - return self.db_user().verified - - def get_id(self): - return unicode(self._db_username) - - -@login_manager.user_loader -def load_user(username): - logger.debug('Loading user: %s' % username) - return _LoginWrappedDBUser(username) +def render_page_template(name): + return render_template(name, route_data = get_route_data()) @app.route('/', methods=['GET'], defaults={'path': ''}) @app.route('/repository/', methods=['GET']) @app.route('/organization/', methods=['GET']) def index(path): - return render_template('index.html') + return render_page_template('index.html') @app.route('/snapshot', methods=['GET']) @@ -81,6 +58,7 @@ def guide(): def organizations(): return index('') + @app.route('/user/') def user(): return index('') @@ -119,45 +97,47 @@ def status(): @app.route('/tos', methods=['GET']) def tos(): - return render_template('tos.html') + return render_page_template('tos.html') @app.route('/disclaimer', methods=['GET']) def disclaimer(): - return render_template('disclaimer.html') + return render_page_template('disclaimer.html') @app.route('/privacy', methods=['GET']) def privacy(): - return render_template('privacy.html') + return render_page_template('privacy.html') @app.route('/receipt', methods=['GET']) def receipt(): + if not current_user.is_authenticated(): + abort(401) + return + id = request.args.get('id') if id: invoice = stripe.Invoice.retrieve(id) if invoice: - org = model.get_user_or_org_by_customer_id(invoice.customer) - if org and org.organization: - admin_org = AdministerOrganizationPermission(org.username) - if admin_org.can(): - file_data = renderInvoiceToPdf(invoice, org) - return Response(file_data, - mimetype="application/pdf", - headers={"Content-Disposition": - "attachment;filename=receipt.pdf"}) - abort(404) + user_or_org = model.get_user_or_org_by_customer_id(invoice.customer) + + if user_or_org: + if user_or_org.organization: + admin_org = AdministerOrganizationPermission(user_or_org.username) + if not admin_org.can(): + abort(404) + return + else: + if not user_or_org.username == current_user.db_user().username: + abort(404) + return -def common_login(db_user): - if login_user(_LoginWrappedDBUser(db_user.username, db_user)): - logger.debug('Successfully signed in as: %s' % db_user.username) - new_identity = QuayDeferredPermissionUser(db_user.username, 'username') - identity_changed.send(app, identity=new_identity) - return True - else: - logger.debug('User could not be logged in, inactive?.') - return False + file_data = renderInvoiceToPdf(invoice, user_or_org) + return Response(file_data, + mimetype="application/pdf", + headers={"Content-Disposition": "attachment;filename=receipt.pdf"}) + abort(404) @app.route('/oauth2/github/callback', methods=['GET']) @@ -215,12 +195,12 @@ def github_oauth_callback(): mixpanel.alias(to_login.username, state) except model.DataModelException, ex: - return render_template('githuberror.html', error_message=ex.message) + return render_page_template('githuberror.html', error_message=ex.message) if common_login(to_login): return redirect(url_for('index')) - return render_template('githuberror.html') + return render_page_template('githuberror.html') @app.route('/confirm', methods=['GET']) diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py index 9675fe000..f93ef7a70 100644 --- a/endpoints/webhooks.py +++ b/endpoints/webhooks.py @@ -1,19 +1,14 @@ import logging -import requests import stripe -from flask import (abort, redirect, request, url_for, render_template, - make_response) -from flask.ext.login import login_user, UserMixin, login_required -from flask.ext.principal import identity_changed, Identity, AnonymousIdentity +from flask import request, make_response from data import model -from app import app, login_manager, mixpanel -from auth.permissions import QuayDeferredPermissionUser -from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan +from app import app from util.invoice import renderInvoiceToHtml from util.email import send_invoice_email + logger = logging.getLogger(__name__) @@ -33,10 +28,10 @@ def stripe_webhook(): # Find the user associated with the customer ID. user = model.get_user_or_org_by_customer_id(customer_id) if user and user.invoice_email: - # Lookup the invoice. - invoice = stripe.Invoice.retrieve(invoice_id) - if invoice: - invoice_html = renderInvoiceToHtml(invoice, user) - send_invoice_email(user.email, invoice_html) + # Lookup the invoice. + invoice = stripe.Invoice.retrieve(invoice_id) + if invoice: + invoice_html = renderInvoiceToHtml(invoice, user) + send_invoice_email(user.email, invoice_html) return make_response('Okay') diff --git a/static/css/quay.css b/static/css/quay.css index f9dbb870f..8cbb3bb25 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -3,6 +3,12 @@ margin: 0; } +@media (max-width: 410px) { + .olrk-normal { + display: none; + } +} + .resource-view-element { position: relative; } @@ -104,10 +110,6 @@ html, body { word-wrap: normal !important; } -.code-info { - border-bottom: 1px dashed #aaa; -} - .toggle-icon { font-size: 22px; padding: 6px; @@ -662,49 +664,165 @@ i.toggle-icon:hover { .plans-list .plan { vertical-align: top; - - padding: 10px; - border: 1px solid #eee; - border-top: 4px solid #94C9F7; font-size: 1.4em; - margin-top: 5px; -} - -.plans-list .plan.small { - border: 1px solid #ddd; - border-top: 4px solid #428bca; - margin-top: 0px; - font-size: 1.6em; -} - -.plans-list .plan.business-plan { - border: 1px solid #eee; - border-top: 4px solid #94F794; } .plans-list .plan.bus-small { - border: 1px solid #ddd; - border-top: 4px solid #47A447; - margin-top: 0px; - font-size: 1.6em; + border-top: 6px solid #46ac39; + margin-top: -10px; +} + +.plans-list .plan.bus-small .plan-box { + background: black !important; } .plans-list .plan:last-child { margin-right: 0px; } +.plans-list .plan .plan-box { + background: #444; + padding: 10px; + color: white; +} + .plans-list .plan .plan-title { + text-transform: uppercase; + padding-top: 25px; + padding-bottom: 20px; margin-bottom: 10px; - display: block; font-weight: bold; + border-bottom: 1px solid #eee; +} + +.visible-sm-inline { + display: none; +} + +.hidden-sm-inline { + display: inline; +} + +@media (max-width: 991px) and (min-width: 768px) { + .visible-sm-inline { + display: inline; + } + + .hidden-sm-inline { + display: none; + } +} + +.plans-list .plan-box .description { + color: white; + margin-top: 6px; + font-size: 12px !important; +} + +.plans-list .plan button { + margin-top: 6px; + margin-bottom: 6px; +} + +.plans-list .plan.bus-small button { + font-size: 1em; +} + +.plans-list .features-bar { + padding-top: 248px; +} + +.plans-list .features-bar .feature .count { + padding: 10px; +} + +.plans-list .features-bar .feature { + height: 43px; + text-align: right; + white-space: nowrap; +} + +.context-tooltip { + border-bottom: 1px dotted black; + cursor: default; +} + +.plans-list .features-bar .feature i { + margin-left: 16px; + float: right; + width: 16px; + font-size: 16px; + text-align: center; + margin-top: 2px; +} + +.plans-list .plan .features { + padding: 6px; + background: #eee; + padding-bottom: 0px; +} + +.plans-list .plan .feature { + text-align: center; + padding: 10px; + border-bottom: 1px solid #ddd; + font-size: 14px; +} + +.plans-list .plan .feature:after { + content: ""; + border-radius: 50%; + display: inline-block; + width: 16px; + height: 16px; +} + +.plans-list .plan .visible-xs .feature { + text-align: left; +} + +.plans-list .plan .visible-xs .feature:after { + float: left; + margin-right: 10px; +} + +.plans-list .plan .feature.notpresent { + color: #ccc; +} + +.plans-list .plan .feature.present:after { + background: #428bca; +} + +.plans-list .plan.business-plan .feature.present:after { + background: #46ac39; +} + +.plans-list .plan .count, .plans-list .features-bar .count { + background: white; + border-bottom: 0px; + text-align: center !important; + font-size: 14px; + padding: 10px; +} + +.plans-list .plan .count b, .plans-list .features-bar .count b { + font-size: 1.5em; + display: block; +} + +.plans-list .plan .feature:last-child { + border-bottom: 0px; +} + +.plans-list .plan-price { + margin-bottom: 10px; } .plan-price { - margin-bottom: 10px; display: block; font-weight: bold; font-size: 1.8em; - position: relative; } @@ -732,7 +850,8 @@ i.toggle-icon:hover { .plans-list .plan .description { font-size: 1em; font-size: 16px; - margin-bottom: 10px; + height: 34px; + } .plans-list .plan .smaller { @@ -1926,28 +2045,28 @@ p.editable:hover i { display: inline-block; } -.org-admin .invoice-title { +.billing-invoices-element .invoice-title { padding: 6px; cursor: pointer; } -.org-admin .invoice-status .success { +.billing-invoices-element .invoice-status .success { color: green; } -.org-admin .invoice-status .pending { +.billing-invoices-element .invoice-status .pending { color: steelblue; } -.org-admin .invoice-status .danger { +.billing-invoices-element .invoice-status .danger { color: red; } -.org-admin .invoice-amount:before { +.billing-invoices-element .invoice-amount:before { content: '$'; } -.org-admin .invoice-details { +.billing-invoices-element .invoice-details { margin-left: 10px; margin-bottom: 10px; @@ -1956,21 +2075,21 @@ p.editable:hover i { border-left: 2px solid #eee !important; } -.org-admin .invoice-details td { +.billing-invoices-element .invoice-details td { border: 0px solid transparent !important; } -.org-admin .invoice-details dl { +.billing-invoices-element .invoice-details dl { margin: 0px; } -.org-admin .invoice-details dd { +.billing-invoices-element .invoice-details dd { margin-left: 10px; padding: 6px; margin-bottom: 10px; } -.org-admin .invoice-title:hover { +.billing-invoices-element .invoice-title:hover { color: steelblue; } @@ -2096,6 +2215,14 @@ p.editable:hover i { margin-bottom: 0px; } +.plan-manager-element .plans-list-table .deprecated-plan { + color: #aaa; +} + +.plan-manager-element .plans-list-table .deprecated-plan-label { + font-size: 0.7em; +} + .plans-table-element table { margin: 20px; border: 1px solid #eee; diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html new file mode 100644 index 000000000..fc64b1abf --- /dev/null +++ b/static/directives/billing-invoices.html @@ -0,0 +1,53 @@ +
+
+
+
+ +
+ No invoices have been created +
+ +
+ + + + + + + + + + + + + + + + + + + + +
Billing Date/TimeAmount DueStatus
{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }} + + Paid - Thank you! + Payment failed + Payment failed - Will retry soon + Payment pending + + + + + +
+
+
Billing Period
+
+ {{ invoice.period_start * 1000 | date:'mediumDate' }} - + {{ invoice.period_end * 1000 | date:'mediumDate' }} +
+
+
+
+ +
diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html index 3b43e6805..8ae5115d5 100644 --- a/static/directives/billing-options.html +++ b/static/directives/billing-options.html @@ -7,7 +7,7 @@
- + ****-****-****-{{ currentCard.last4 }} diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 6df3e1fe8..32ee88d23 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -38,7 +38,10 @@ {{ user.username }} - + {{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }} diff --git a/static/directives/plan-manager.html b/static/directives/plan-manager.html index 8ee324d20..dac8fd430 100644 --- a/static/directives/plan-manager.html +++ b/static/directives/plan-manager.html @@ -32,14 +32,23 @@ - - {{ plan.title }} + + + {{ plan.title }} +
+ Discontinued Plan +
+ {{ plan.privateRepos }}
${{ plan.price / 100 }}
-
-
- +
+
+
diff --git a/static/partials/landing.html b/static/partials/landing.html index 00594c00e..e945a7b88 100644 --- a/static/partials/landing.html +++ b/static/partials/landing.html @@ -5,7 +5,7 @@

Secure hosting for private Docker* repositories

Use the Docker images your team needs with the safety of private repositories

- +
diff --git a/static/partials/new-organization.html b/static/partials/new-organization.html index 3033a20b7..79d29e3f9 100644 --- a/static/partials/new-organization.html +++ b/static/partials/new-organization.html @@ -72,7 +72,8 @@
-
diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index bafc2dbb1..3f61d9c7e 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -74,7 +74,11 @@
- In order to make this repository private, you’ll need to upgrade your plan to {{ planRequired.title }}. This will cost ${{ planRequired.price / 100 }}/month. + In order to make this repository private, you’ll need to upgrade your plan to + + {{ planRequired.title }} + . + This will cost ${{ planRequired.price / 100 }}/month.
Upgrade now
diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index a4843005e..5c506cc6d 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -40,60 +40,7 @@
-
-
-
- -
- No invoices have been created -
- -
- - - - - - - - - - - - - - - - - - - - -
Billing Date/TimeAmount DueStatus
{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }} - - Paid - Thank you! - Payment failed - Payment failed - Will retry soon - Payment pending - - - - - -
-
-
Billing Period
-
- {{ invoice.period_start * 1000 | date:'mediumDate' }} - - {{ invoice.period_end * 1000 | date:'mediumDate' }} -
-
Plan
-
- {{ invoice.plan ? plan_map[invoice.plan].title : '(N/A)' }} -
-
-
-
+
diff --git a/static/partials/plans.html b/static/partials/plans.html index 4b8e5a70e..20c551e82 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -1,44 +1,107 @@
-
- Plans & Pricing -
- -
- All plans include unlimited public repositories and unlimited sharing. All paid plans have a 14-day free trial. -
-
-
-
-
-
{{ plan.title }}
-
${{ plan.price/100 }}
-
{{ plan.privateRepos }} private repositories
-
{{ plan.audience }}
-
SSL secured connections
- -
+
+ +
-
+
+
+
+
{{ plan.title }}
+
${{ plan.price/100 }}
-
- Business Plan Pricing -
+
{{ plan.audience }}
+
-
- All business plans include all of the personal plan features, plus: organizations and teams with delegated access to the organization. All business plans have a 14-day free trial. -
+ + +
+
{{ plan.privateRepos }} private repositories
+
Unlimited Public Repositories
+
SSL Encryption
+
Robot accounts
+
Dockerfile Build
+
Teams
+
Logging
+
Invoice History
+
14-Day Free Trial
+
+ + -
-
-
-
-
{{ plan.title }}
-
${{ plan.price/100 }}
-
{{ plan.privateRepos }} private repositories
-
{{ plan.audience }}
-
SSL secured connections
-
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 746af084d..1e5f0ab82 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -27,16 +27,23 @@
-
+
+ +
+
+
+
@@ -53,9 +60,9 @@
- - + +
@@ -69,10 +76,15 @@
-
+
+ +
+
+
+
@@ -86,11 +98,11 @@
-
- Converting a user account into an organization cannot be undone.
Here be many fire-breathing dragons! +
+ Note: Converting a user account into an organization cannot be undone
- +
@@ -113,7 +125,7 @@ ng-model="org.adminUser" required autofocus> - The username and password for an existing account that will become administrator of the organization + The username and password for the account that will become administrator of the organization
@@ -123,7 +135,8 @@
-
diff --git a/templates/base.html b/templates/base.html index 8940e9d33..af71c9bde 100644 --- a/templates/base.html +++ b/templates/base.html @@ -65,6 +65,10 @@ {% endblock %} + + @@ -122,6 +126,22 @@ var isProd = document.location.hostname === 'quay.io';
+ + + {% if request.host == 'quay.io' %}