diff --git a/README.md b/README.md index 818fc5024..5a43f8325 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ running: ``` sudo nginx -c `pwd`/nginx.conf -STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 500 application:application +STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 2000 application:application ``` set up the snapshot script: diff --git a/application.py b/application.py index d3feefcb0..706c64a8c 100644 --- a/application.py +++ b/application.py @@ -11,6 +11,7 @@ import endpoints.api import endpoints.web import endpoints.tags import endpoints.registry +import endpoints.webhooks logger = logging.getLogger(__name__) diff --git a/data/database.py b/data/database.py index 18b6b8bad..674aeebe8 100644 --- a/data/database.py +++ b/data/database.py @@ -34,6 +34,7 @@ class User(BaseModel): verified = BooleanField(default=False) stripe_id = CharField(index=True, null=True) organization = BooleanField(default=False, index=True) + invoice_email = BooleanField(default=False) class TeamRole(BaseModel): diff --git a/data/model.py b/data/model.py index 47af6c181..9d10b19b3 100644 --- a/data/model.py +++ b/data/model.py @@ -301,6 +301,12 @@ def get_user(username): return None +def get_user_or_org_by_customer_id(customer_id): + try: + return User.get(User.stripe_id == customer_id) + except User.DoesNotExist: + return None + def get_matching_teams(team_prefix, organization): query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) @@ -496,6 +502,11 @@ def change_password(user, new_password): user.save() +def change_invoice_email(user, invoice_email): + user.invoice_email = invoice_email + user.save() + + def update_email(user, new_email): user.email = new_email user.verified = False diff --git a/endpoints/api.py b/endpoints/api.py index d9a06e768..1d95bacc4 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -71,8 +71,7 @@ def plans_list(): }) -@app.route('/api/user/', methods=['GET']) -def get_logged_in_user(): +def user_view(user): def org_view(o): admin_org = AdministerOrganizationPermission(o.username) return { @@ -82,16 +81,9 @@ def get_logged_in_user(): 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can() } - if current_user.is_anonymous(): - return jsonify({'anonymous': True}) - - user = current_user.db_user() - if not user or user.organization: - return jsonify({'anonymous': True}) - organizations = model.get_user_organizations(user.username) - return jsonify({ + return { 'verified': user.verified, 'anonymous': False, 'username': user.username, @@ -99,8 +91,21 @@ def get_logged_in_user(): 'gravatar': compute_hash(user.email), 'askForPassword': user.password_hash is None, 'organizations': [org_view(o) for o in organizations], - 'can_create_repo': True - }) + 'can_create_repo': True, + 'invoice_email': user.invoice_email + } + + +@app.route('/api/user/', methods=['GET']) +def get_logged_in_user(): + if current_user.is_anonymous(): + return jsonify({'anonymous': True}) + + user = current_user.db_user() + if not user or user.organization: + return jsonify({'anonymous': True}) + + return jsonify(user_view(user)) @app.route('/api/user/convert', methods=['POST']) @@ -150,6 +155,11 @@ def change_user_details(): if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) model.change_password(user, user_data['password']) + + if 'invoice_email' in user_data: + logger.debug('Changing invoice_email for user: %s', user.username) + model.change_invoice_email(user, user_data['invoice_email']) + except model.InvalidPasswordException, ex: error_resp = jsonify({ 'message': ex.message, @@ -157,14 +167,7 @@ def change_user_details(): error_resp.status_code = 400 return error_resp - return jsonify({ - 'verified': user.verified, - 'anonymous': False, - 'username': user.username, - 'email': user.email, - 'gravatar': compute_hash(user.email), - 'askForPassword': user.password_hash is None, - }) + return jsonify(user_view(user)) @app.route('/api/user/', methods=['POST']) @@ -341,22 +344,28 @@ def create_organization_api(): return error_resp +def org_view(o, teams): + admin_org = AdministerOrganizationPermission(o.username) + is_admin = admin_org.can() + view = { + 'name': o.username, + 'email': o.email if is_admin else '', + 'gravatar': compute_hash(o.email), + 'teams': {t.name : team_view(o.username, t) for t in teams}, + 'is_admin': is_admin + } + + if is_admin: + view['invoice_email'] = o.invoice_email + + return view + + @app.route('/api/organization/', methods=['GET']) @api_login_required def get_organization(orgname): permission = OrganizationMemberPermission(orgname) if permission.can(): - def org_view(org, teams): - admin_org = AdministerOrganizationPermission(orgname) - is_admin = admin_org.can() - return { - 'name': org.username, - 'email': org.email if is_admin else '', - 'gravatar': compute_hash(org.email), - 'teams': {t.name : team_view(orgname, t) for t in teams}, - 'is_admin': is_admin - } - try: org = model.get_organization(orgname) except model.InvalidOrganizationException: @@ -367,6 +376,28 @@ def get_organization(orgname): abort(403) + +@app.route('/api/organization/', methods=['PUT']) +@api_login_required +def change_organization_details(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + abort(404) + + 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']) + + teams = model.get_teams_within_org(org) + return jsonify(org_view(org, teams)) + + abort(403) + + @app.route('/api/organization//members', methods=['GET']) @api_login_required def get_organization_members(orgname): @@ -1265,7 +1296,7 @@ def subscribe(user, plan, token, accepted_plans): if not user.stripe_id: # Check if a non-paying user is trying to subscribe to a free plan if not plan_found['price'] == 0: - # They want a real paying plan, create the customerand plan + # They want a real paying plan, create the customer and plan # simultaneously card = token cus = stripe.Customer.create(email=user.email, plan=plan, card=card) diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py new file mode 100644 index 000000000..9675fe000 --- /dev/null +++ b/endpoints/webhooks.py @@ -0,0 +1,42 @@ +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 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 util.invoice import renderInvoiceToHtml +from util.email import send_invoice_email + +logger = logging.getLogger(__name__) + + +@app.route('/webhooks/stripe', methods=['POST']) +def stripe_webhook(): + request_data = request.get_json() + logger.debug('Stripe webhook call: %s' % request_data) + + event_type = request_data['type'] if 'type' in request_data else None + if event_type == 'charge.succeeded': + data = request_data['data'] if 'data' in request_data else {} + obj = data['object'] if 'object' in data else {} + invoice_id = obj['invoice'] if 'invoice' in obj else None + customer_id = obj['customer'] if 'customer' in obj else None + + if invoice_id and customer_id: + # 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) + + return make_response('Okay') diff --git a/nginx.conf b/nginx.conf index 10d316c44..7b1b3074f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -31,7 +31,7 @@ http { server { listen 443 default; - client_max_body_size 4G; + client_max_body_size 8G; server_name _; keepalive_timeout 5; @@ -61,6 +61,7 @@ http { proxy_buffering off; proxy_pass http://app_server; + proxy_read_timeout 2000; } } } diff --git a/static/css/quay.css b/static/css/quay.css index 72d6f41d5..e466e43d6 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -6,6 +6,20 @@ visibility: hidden; } +.settings-option { + padding: 4px; + font-size: 18px; +} + +.settings-option label { + margin-left: 6px; +} + +.settings-option .settings-description { + font-size: 12px; + color: #aaa; +} + .organization-header-element { padding: 20px; margin-bottom: 20px; diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html new file mode 100644 index 000000000..777a56519 --- /dev/null +++ b/static/directives/billing-options.html @@ -0,0 +1,17 @@ +
+
+
+ Billing Options + +
+
+
+ + +
+ If checked, a receipt email will be sent to {{ obj.email }} on every successful billing +
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js index 2a0d08236..087e299e3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -240,7 +240,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', if (callbacks['started']) { callbacks['started'](); } - planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']); + planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token); }); }; @@ -621,6 +621,52 @@ quayApp.directive('roleGroup', function () { }); +quayApp.directive('billingOptions', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-options.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'organization': '=organization' + }, + controller: function($scope, $element, Restangular) { + $scope.invoice_email = false; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + $scope.obj = $scope.user ? $scope.user : $scope.organization; + $scope.invoice_email = $scope.obj.invoice_email; + }; + + var save = function() { + $scope.working = true; + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name) : 'user/'; + var conductSave = Restangular.one(url); + conductSave.customPUT($scope.obj).then(function(resp) { + $scope.working = false; + }); + }; + + var checkSave = function() { + if (!$scope.obj) { return; } + if ($scope.obj.invoice_email != $scope.invoice_email) { + $scope.obj.invoice_email = $scope.invoice_email; + save(); + } + }; + + $scope.$watch('invoice_email', checkSave); + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('planManager', function () { var directiveDefinitionObject = { priority: 0, diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index 1ed36b0c5..7df7b3fd9 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -29,8 +29,10 @@
+
+
- + Loading billing history:
diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index d0a0e0d4f..94eb1a207 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -11,164 +11,189 @@
-
+

{{repo.namespace}} / {{repo.name}}

-
+
- -
-
User and Team Access Permissions - - +
+ + -
- - - - - - - - - - - - - - - - - - - - - - - +
+ +
User/TeamPermissions
- - {{name}} - - - - - - - -
- - {{name}} - - -
- + + +
+ +
+ +
+ +
+
User and Team Access Permissions + +
-
- - - - -
+ + + + + + + + + + + + + + + + + + + + + - - - -
User/TeamPermissions
+ + {{name}} + + + + + + + +
+ + {{name}} + + +
+ +
+
+ + + + +
- -
-
-
+ + + + + + +
+
- -
-
Access Token Permissions + +
+
Access Token Permissions - -
-
-
- - - - - - - - - - - - - - - - - - -
Token DescriptionPermissions
- - {{ token.friendlyName }} - -
- - + +
+
+ + + + + + + + + + + + + + + + + + + + +
Token DescriptionPermissions
+ + {{ token.friendlyName }} + +
+ + +
+
+ + + + +
+ + + +
+ +
+ + + + + +
+ +
+
+
+
+ + This repository is currently private. Only users on the permissions list may view and interact with it. + +
+ +
-
- - - - -
- - - -
- -
-
+
+
- -
-
Repository Settings
-
-
-
+ This repository is currently public and is visible to all users, and may be pulled by all users. - This repository is currently private. Only users on the above access list may view and interact with it. - -
- +
+ +
+
+
+
-
-
-
- - This repository is currently public and is visible to all users, and may be pulled by all users. - -
- + +
+ +
+
+
+
Deleting a repository cannot be undone. Here be dragons!
+ +
+
+
-
- -
-
Delete Repository
-
-
-
Deleting a repository cannot be undone. Here be dragons!
- -
-
-
- + - + diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 0cfb27e2c..db81cb629 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -28,6 +28,7 @@
@@ -57,6 +58,11 @@
+ + +
+
+
diff --git a/test/data/test.db b/test/data/test.db index c300b0c1c..d35f34afe 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/email.py b/util/email.py index 2698ccbed..79cd2dd0f 100644 --- a/util/email.py +++ b/util/email.py @@ -39,3 +39,11 @@ def send_recovery_email(email, token): recipients=[email]) msg.html = RECOVERY_MESSAGE % (token, token) mail.send(msg) + + +def send_invoice_email(email, contents): + msg = Message('Quay.io payment received - Thank you!', + sender='support@quay.io', # Why do I need this? + recipients=[email]) + msg.html = contents + mail.send(msg) diff --git a/util/invoice.py b/util/invoice.py new file mode 100644 index 000000000..773c428f1 --- /dev/null +++ b/util/invoice.py @@ -0,0 +1,36 @@ +from datetime import datetime +from jinja2 import Environment, FileSystemLoader + +jinja_options = { + "loader": FileSystemLoader('util'), +} + +env = Environment(**jinja_options) + +def renderInvoiceToHtml(invoice, user): + """ Renders a nice HTML display for the given invoice. """ + def get_price(price): + if not price: + return '$0' + + return '$' + '{0:.2f}'.format(float(price) / 100) + + def get_range(line): + if line.period and line.period.start and line.period.end: + return ': ' + format_date(line.period.start) + ' - ' + format_date(line.period.end) + return '' + + def format_date(timestamp): + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + + data = { + 'user': user.username, + 'invoice': invoice, + 'invoice_date': format_date(invoice.date), + 'getPrice': get_price, + 'getRange': get_range + } + + template = env.get_template('invoice.tmpl') + rendered = template.render(data) + return rendered diff --git a/util/invoice.tmpl b/util/invoice.tmpl new file mode 100644 index 000000000..95cb5a23a --- /dev/null +++ b/util/invoice.tmpl @@ -0,0 +1,63 @@ + + + + + + + + +
+ Quay.io + +

Quay.io

+

+ DevTable, LLC
+ https://devtable.com
+ PO Box 48
+ New York, NY 10009 +

+
+

RECEIPT

+ + + +
Date:{{ invoice_date }}
Invoice #:{{ invoice.id }}
+
+ +
+ + + + + + + +{%- for line in invoice.lines.data -%} + + + + +{%- endfor -%} + + + + + + + +
DescriptionLine Total
{{ line.description or ('Plan Subscription' + getRange(line)) }}{{ getPrice(line.amount) }}
+ + + + + + +
Subtotal: {{ getPrice(invoice.subtotal) }}
Total: {{ getPrice(invoice.total) }}
Paid: {{ getPrice(invoice.total) if invoice.paid else 0 }}
Total Due:{{ getPrice(invoice.ending_balance) }}
+
+ +
+ We thank you for your continued business! +
+ + + diff --git a/util/validation.py b/util/validation.py index bbe02e017..895767d98 100644 --- a/util/validation.py +++ b/util/validation.py @@ -1,5 +1,4 @@ import re -import urllib INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \ '8 characters and contain no whitespace.' @@ -12,9 +11,9 @@ def validate_email(email_address): def validate_username(username): # Minimum length of 2, maximum length of 255, no url unsafe characters - return (urllib.quote(username, safe='') == username and - len(username) > 1 and - len(username) < 256) + return (re.search(r'[^a-z0-9_]', username) is None and + len(username) >= 4 and + len(username) <= 30) def validate_password(password):