diff --git a/README.md b/README.md index 35ea35a87..ba4eb11e8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ to prepare a new host: sudo apt-get install software-properties-common sudo apt-add-repository -y ppa:nginx/stable sudo apt-get update -sudo apt-get install -y git python-virtualenv python-dev phantomjs +sudo apt-get install -y git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev sudo apt-get install -y nginx-full ``` @@ -21,12 +21,9 @@ running: ``` sudo nginx -c `pwd`/nginx.conf -STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 2000 application:application +STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class gevent -t 2000 application:application ``` -set up the snapshot script: -(instructions in the seo-snapshots directory)[seo-snapshots/README.md] - start the workers: ``` diff --git a/endpoints/api.py b/endpoints/api.py index b245d131f..07c3fd7f8 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1296,7 +1296,7 @@ def subscription_view(stripe_subscription, used_repos): @api_login_required def get_user_card_api(): user = current_user.db_user() - return jsonify(get_card(user)) + return get_card(user) @app.route('/api/organization//card', methods=['GET']) @@ -1305,7 +1305,7 @@ def get_org_card_api(orgname): permission = AdministerOrganizationPermission(orgname) if permission.can(): organization = model.get_organization(orgname) - return jsonify(get_card(organization)) + return get_card(organization) abort(403) @@ -1315,7 +1315,7 @@ def get_org_card_api(orgname): def set_user_card_api(): user = current_user.db_user() token = request.get_json()['token'] - return jsonify(set_card(user, token)) + return set_card(user, token) @app.route('/api/organization//card', methods=['POST']) @@ -1331,13 +1331,14 @@ def set_org_card_api(orgname): def set_card(user, token): - print token - if user.stripe_id: cus = stripe.Customer.retrieve(user.stripe_id) if cus: - cus.card = token - cus.save() + try: + cus.card = token + cus.save() + except stripe.CardError as e: + return carderror_response(e) return get_card(user) @@ -1364,7 +1365,7 @@ def get_card(user): 'last4': card.last4 } - return {'card': card_info} + return jsonify({'card': card_info}) @app.route('/api/user/plan', methods=['PUT']) @api_login_required @@ -1376,6 +1377,14 @@ def subscribe_api(): return subscribe(user, plan, token, USER_PLANS) +def carderror_response(e): + resp = jsonify({ + 'carderror': e.message, + }) + resp.status_code = 402 + return resp + + def subscribe(user, plan, token, accepted_plans): plan_found = None for plan_obj in accepted_plans: @@ -1400,9 +1409,13 @@ def subscribe(user, plan, token, accepted_plans): # 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) - user.stripe_id = cus.id - user.save() + + try: + cus = stripe.Customer.create(email=user.email, plan=plan, card=card) + user.stripe_id = cus.id + user.save() + except stripe.CardError as e: + return carderror_response(e) response_json = subscription_view(cus.subscription, private_repos) status_code = 201 @@ -1418,12 +1431,17 @@ def subscribe(user, plan, token, accepted_plans): cus.save() else: - cus.plan = plan # User may have been a previous customer who is resubscribing if token: cus.card = token - cus.save() + cus.plan = plan + + try: + cus.save() + except stripe.CardError as e: + return carderror_response(e) + response_json = subscription_view(cus.subscription, private_repos) resp = jsonify(response_json) diff --git a/endpoints/web.py b/endpoints/web.py index 9f8712629..347969268 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -1,14 +1,19 @@ 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 + make_response, Response) +from flask.ext.login import login_user, UserMixin +from flask.ext.principal import identity_changed +from urlparse import urlparse from data import model from app import app, login_manager, mixpanel -from auth.permissions import QuayDeferredPermissionUser +from auth.permissions import (QuayDeferredPermissionUser, + AdministerOrganizationPermission) +from util.invoice import renderInvoiceToPdf +from util.seo import render_snapshot logger = logging.getLogger(__name__) @@ -48,6 +53,19 @@ def index(path): return render_template('index.html') +@app.route('/snapshot', methods=['GET']) +@app.route('/snapshot/', methods=['GET']) +@app.route('/snapshot/', methods=['GET']) +def snapshot(path = ''): + parsed = urlparse(request.url) + final_url = '%s://%s/%s' % (parsed.scheme, 'localhost', path) + result = render_snapshot(final_url) + if result: + return result + + abort(404) + + @app.route('/plans/') def plans(): return index('') @@ -82,11 +100,18 @@ def new(): def repository(): return index('') + +@app.route('/security/') +def security(): + return index('') + + @app.route('/v1') @app.route('/v1/') def v1(): return index('') + @app.route('/status', methods=['GET']) def status(): return make_response('Healthy') @@ -97,11 +122,33 @@ def tos(): return render_template('tos.html') +@app.route('/disclaimer', methods=['GET']) +def disclaimer(): + return render_template('disclaimer.html') + + @app.route('/privacy', methods=['GET']) def privacy(): return render_template('privacy.html') +@app.route('/receipt', methods=['GET']) +def receipt(): + 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) + def common_login(db_user): if login_user(_LoginWrappedDBUser(db_user.username, db_user)): logger.debug('Successfully signed in as: %s' % db_user.username) diff --git a/nginx.conf b/nginx.conf index 7b1b3074f..cc97cf299 100644 --- a/nginx.conf +++ b/nginx.conf @@ -45,7 +45,7 @@ http { ssl_prefer_server_ciphers on; if ($args ~ "_escaped_fragment_") { - rewrite ^ /static/snapshots$uri/index.html; + rewrite ^ /snapshot$uri; } location /static/ { diff --git a/requirements-nover.txt b/requirements-nover.txt index 8d95e3e48..f42a8a90d 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -9,11 +9,12 @@ boto pymysql stripe gunicorn -eventlet +gevent mixpanel-py beautifulsoup4 marisa-trie apscheduler python-daemon paramiko -python-digitalocean \ No newline at end of file +python-digitalocean +xhtml2pdf \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 494ed9198..712c818c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,29 +5,34 @@ Flask-Mail==0.9.0 Flask-Principal==0.4.0 Jinja2==2.7.1 MarkupSafe==0.18 +Pillow==2.2.1 PyMySQL==0.6.1 Werkzeug==0.9.4 argparse==1.2.1 beautifulsoup4==4.3.2 blinker==1.3 -boto==2.15.0 +boto==2.17.0 distribute==0.6.34 ecdsa==0.10 -eventlet==0.14.0 +gevent==0.13.8 greenlet==0.4.1 gunicorn==18.0 +html5lib==1.0b3 itsdangerous==0.23 lockfile==0.9.1 marisa-trie==0.5.1 mixpanel-py==3.0.0 paramiko==1.12.0 -peewee==2.1.4 +peewee==2.1.5 py-bcrypt==0.4 +pyPdf==1.13 pycrypto==2.6.1 python-daemon==1.6 -python-dateutil==2.1 -python-digitalocean==0.5 -requests==2.0.0 +python-dateutil==2.2 +python-digitalocean==0.5.1 +reportlab==2.7 +requests==2.0.1 six==1.4.1 stripe==1.9.8 wsgiref==0.1.2 +xhtml2pdf==0.0.5 diff --git a/seo-snapshots/README.md b/seo-snapshots/README.md deleted file mode 100644 index 1340fc7fe..000000000 --- a/seo-snapshots/README.md +++ /dev/null @@ -1,13 +0,0 @@ -Follow the instructions to set up a host of the whole project before attempting to run. - -to run once: - -``` -python make_snapshot.py -``` - -cron line to update every 30 minutes: - -``` -0,30 * * * * cd /home/ubuntu/quay/seo-snapshots && ../venv/bin/python make_snapshot.py -``` \ No newline at end of file diff --git a/seo-snapshots/crawl.py b/seo-snapshots/crawl.py deleted file mode 100644 index 79adcef57..000000000 --- a/seo-snapshots/crawl.py +++ /dev/null @@ -1,60 +0,0 @@ -import subprocess -import urllib -import os -import logging -import codecs - -from bs4 import BeautifulSoup -from Queue import Queue - - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - - -BASE_URL = 'http://localhost:5000' -OUTPUT_PATH = 'snapshots/' - -aware_of = set() -crawl_queue = Queue() - -def crawl_url(url): - final_url = BASE_URL + url - to_write = OUTPUT_PATH + url + 'index.html' - - logger.info('Snapshotting url: %s -> %s' % (final_url, to_write)) - - out_html = subprocess.check_output(['phantomjs', '--ignore-ssl-errors=yes', - 'phantomjs-runner.js', final_url]) - - # Remove script tags - soup = BeautifulSoup(out_html) - to_extract = soup.findAll('script') - for item in to_extract: - item.extract() - - # Find all links and add them to the crawl queue - for link in soup.findAll('a'): - to_add = link.get('href') - - if to_add not in aware_of and to_add.startswith('/'): - logger.info('Adding link to be crawled: %s' % to_add) - crawl_queue.put(to_add) - aware_of.add(to_add) - - to_write_dir = os.path.dirname(to_write) - - if not os.path.exists(to_write_dir): - os.makedirs(to_write_dir) - - with codecs.open(to_write, 'w', 'utf-8') as output_file: - output_file.write(soup.prettify()) - -# Seed the crawler -crawl_queue.put('/') -aware_of.add('/') - -# Crawl -while not crawl_queue.empty(): - to_crawl = crawl_queue.get() - crawl_url(to_crawl) diff --git a/seo-snapshots/make_snapshot.py b/seo-snapshots/make_snapshot.py deleted file mode 100644 index 37c5738f0..000000000 --- a/seo-snapshots/make_snapshot.py +++ /dev/null @@ -1,46 +0,0 @@ -import subprocess -import urllib -import os -import logging -import codecs - -from bs4 import BeautifulSoup - - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - - -BASE_URL = 'https://localhost/' -OUTPUT_PATH = '../static/snapshots/' - -URLS = [ - '', - 'guide/', - 'plans/', - 'repository/', - 'signin/', -] - -for url in URLS: - final_url = BASE_URL + url - to_write = OUTPUT_PATH + url + 'index.html' - - logger.info('Snapshotting url: %s -> %s' % (final_url, to_write)) - - out_html = subprocess.check_output(['phantomjs', '--ignore-ssl-errors=yes', - 'phantomjs-runner.js', final_url]) - - # Remove script tags - soup = BeautifulSoup(out_html) - to_extract = soup.findAll('script') - for item in to_extract: - item.extract() - - to_write_dir = os.path.dirname(to_write) - - if not os.path.exists(to_write_dir): - os.makedirs(to_write_dir) - - with codecs.open(to_write, 'w', 'utf-8') as output_file: - output_file.write(soup.prettify()) diff --git a/seo-snapshots/phantomjs-runner.js b/seo-snapshots/phantomjs-runner.js deleted file mode 100644 index e001b73c8..000000000 --- a/seo-snapshots/phantomjs-runner.js +++ /dev/null @@ -1,23 +0,0 @@ -var system = require('system'); -var url = system.args[1] || ''; -if(url.length > 0) { - var page = require('webpage').create(); - page.open(url, function (status) { - if (status == 'success') { - var delay, checker = (function() { - var html = page.evaluate(function () { - var ready = document.getElementsByClassName('ready-indicator')[0]; - if(ready.getAttribute('data-status') == 'ready') { - return document.getElementsByTagName('html')[0].outerHTML; - } - }); - if(html) { - clearTimeout(delay); - console.log(html); - phantom.exit(); - } - }); - delay = setInterval(checker, 100); - } - }); -} diff --git a/static/css/quay.css b/static/css/quay.css index 8ff8c4880..b7d63acc3 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1,5 +1,35 @@ * { font-family: 'Droid Sans', sans-serif; + margin: 0; +} + +html, body { + height: 100%; +} + +.tooltip { + word-break: normal !important; + word-wrap: normal !important; +} + +.code-info { + border-bottom: 1px dashed #aaa; +} + +.content-container { + padding-bottom: 70px; +} + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -136px; +} + +.footer-container, .push { + height: 110px; + overflow: hidden; } .button-hidden { @@ -512,6 +542,15 @@ padding: 20px; } +.jumbotron .disclaimer-link { + font-size: .3em; + vertical-align: 23px; +} + +.jumbotron .disclaimer-link:hover { + text-decoration: none; +} + .landing .popover { font-size: 14px; } @@ -644,57 +683,61 @@ form input.ng-valid.ng-dirty, } } -.landing-footer { +.page-footer { background-color: white; background-image: none; padding: 10px; padding-bottom: 40px; - margin-top: 10px; + margin-top: 52px; border-top: 1px solid #eee; } -.landing-footer .row { +.page-footer .row { max-width: 100%; } -.landing-footer .copyright-container { +.page-footer .logo-container { font-size: 11px; color: black; text-align: right; padding-right: 30px; - padding-top: 20px; } -.landing-footer .copyright-container a { +.page-footer .logo-container a { display: block; + margin-top: 4px; + margin-right: 10px; } -.landing-footer .dt-logo { +.page-footer .dt-logo { vertical-align: center; max-width: 150px; } -.landing-footer .copyright { - font-size: 11px; +.page-footer .copyright { + font-size: 12px; color: black; } -.landing-footer ul { +.page-footer ul { list-style-type: none; margin: 0px; padding-left: 0px; + margin-top: 10px; } -.landing-footer li { +.page-footer li { margin: 0px; + display: inline-block; + padding: 10px; } .user-welcome { text-align: center; } -#repoSearch { - width: 300px; +.repo-search-box { + width: 240px; } .repo-mini-listing { @@ -718,7 +761,6 @@ form input.ng-valid.ng-dirty, text-overflow: ellipsis; } - .entity-mini-listing { margin: 2px; } @@ -827,7 +869,13 @@ p.editable:hover i { .repo .empty-message { padding: 6px; font-size: 1.8em; - color: #ccc; + color: #666; + margin-bottom: 10px; +} + +.repo .empty-description { + max-width: 600px; + padding: 6px; } .repo dl.dl-horizontal dt { @@ -1067,6 +1115,12 @@ p.editable:hover i { padding-left: 44px; } +.repo-admin .right-info { + font-size: 11px; + margin-top: 10px; + text-align: right; +} + .repo-admin .entity-search input { width: 300px; } diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html index 1543e5239..598586bcb 100644 --- a/static/directives/billing-options.html +++ b/static/directives/billing-options.html @@ -36,4 +36,22 @@ + + + diff --git a/static/partials/header.html b/static/directives/header-bar.html similarity index 81% rename from static/partials/header.html rename to static/directives/header-bar.html index 34e2d2231..169c9bf06 100644 --- a/static/partials/header.html +++ b/static/directives/header-bar.html @@ -22,15 +22,17 @@ - -
-

Signing into Quay Optional

+
+

Using push web hooks Requires Admin Access

- If you have never pushed a repository to Quay and wish to pull a private repository, you can sign into Quay by running the following command: -

-
docker login quay.io
+ A repository can have one or more push web hooks setup, which will be invoked whenever a successful push occurs. Web hooks can be managed from the repository's admin interface. +

A web hook will be invoked + as an HTTP POST to the specified URL, with a JSON body describing the push:

+
+{
+  "pushed_image_count": 2,
+  "name": "ubuntu",
+  "repository":"devtable/ubuntu",
+  "docker_url": "quay.io/devtable/ubuntu",
+  "updated_tags": {
+    "latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
+  },
+  "namespace": "devtable",
+  "visibility": "private",
+  "homepage": "https://quay.io/repository/devtable/ubuntu"
+}
+
diff --git a/static/partials/landing.html b/static/partials/landing.html index d182bc7d2..d805f94bb 100644 --- a/static/partials/landing.html +++ b/static/partials/landing.html @@ -1,11 +1,11 @@ -
+
-

Secure hosting for private docker repositories

-

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

- +

Secure hosting for private Docker* repositories

+

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

+
@@ -72,17 +72,16 @@ Secure - Store your private docker containers where only you and your team - can access it, with communication secured by SSL at all times - + Your data is transferred using SSL at all times and encrypted when at rest. More information available in our security guide +
- + Shareable Have to share a repository? No problem! Share with anyone you choose - +
@@ -90,7 +89,7 @@ Cloud Hosted Accessible from anywhere, anytime - +
@@ -147,36 +146,3 @@
- - diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index 3fead806a..6b110099e 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -55,12 +55,13 @@ Billing Date/Time Amount Due Status + - - {{ invoice.date * 1000 | date:'medium' }} - {{ invoice.amount_due / 100 }} + + {{ invoice.date * 1000 | date:'medium' }} + {{ invoice.amount_due / 100 }} Paid - Thank you! @@ -69,6 +70,11 @@ Payment pending + + + + + diff --git a/static/partials/organizations.html b/static/partials/organizations.html index b6ff064cc..1d9c2a96d 100644 --- a/static/partials/organizations.html +++ b/static/partials/organizations.html @@ -1,4 +1,4 @@ -
+
diff --git a/static/partials/plans.html b/static/partials/plans.html index 24ac8b1e4..178169b79 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -1,4 +1,4 @@ -
+
Plans & Pricing
diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index 3d169340f..34f378cb5 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -1,6 +1,3 @@ - - -
@@ -23,7 +20,7 @@
@@ -152,19 +149,19 @@
-
Push Webhooks - +
Push Web Hooks +
- Loading webhooks: + Loading web hooks:
- + @@ -175,13 +172,13 @@
Webhook URLWeb Hook URL
- +
- + @@ -189,6 +186,10 @@
+ +
+ Quay will POST to these web hooks whenever a push occurs. See the User Guide for more information. +
diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index 536701d94..c8175e2ef 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -2,7 +2,7 @@
-
+
diff --git a/static/partials/security.html b/static/partials/security.html new file mode 100644 index 000000000..73e599aff --- /dev/null +++ b/static/partials/security.html @@ -0,0 +1,44 @@ +
+
+
+

Quay.io Security

+

We understand that when you upload one of your repositories to Quay.io that you are trusting us with some potentially very sensitive data. On this page we will lay out our security features and practices to help you make an informed decision about whether you can trust us with your data.

+
+
+
+
+

SSL Everwhere

+

We expressly forbid connections to Quay.io using unencrypted HTTP traffic. This helps keep your data and account information safe on the wire. Our SSL traffic is decrypted on our application servers, so your traffic is encrypted even within the datacenter. We use a 4096-bit RSA key, and after the key exchange is complete, traffic is transferred using 256-bit AES, for the maximum encryption strength.

+
+
+
+
+

Encryption

+

Our binary data is currently stored in Amazon's S3 service. We use HTTPS when transferring your data internally between our application servers and S3, so your data is never exposed in plain text on any wire. We use their server side encryption to protect your data while stored at rest in their data centers.

+
+
+
+
+

Passwords

+

There have been a number of high profile leaks recently where companies have been storing their customers' passwords in plain text, an unsalted hash, or a salted hash where every salt is the same. At Quay.io we use the bcrypt algorithm to generate a salted hash from your password, using a unique salt for each password. This method of storage is safe against rainbow attacks and is obviously superior to plain-text storage. Your credentials are also never written in plain text to our application logs, a leak that is commonly overlooked.

+
+
+
+
+

Access Controls

+

Repositories will only ever be shared with people to whom you delegate access. Repositories created from the Docker command line are private by default and repositories must subsequently made public with an explicit action in the Quay.io UI. We have a test suite which is run before every code push which tests all methods which expose private data with all levels of access to ensure nothing is accidentally leaked.

+
+
+
+
+

Firewalls

+

Our application servers and database servers are all protected with firewall settings that only allow communication with known hosts and host groups on sensitive ports (e.g. SSH). None of our servers have SSH password authentication enabled, preventing brute force password attacks.

+
+
+
+
+

Data Resilience

+

While not related directly to security, many of you are probably worried about whether you can depend on the data you store in Quay.io. All binary data that we store is stored in Amazon S3 at the highest redundancy level, which Amazon claims provides 11-nines of durability. Our service metadata (e.g. logins, tags, teams) is stored in a database which is backed up nightly, and backups are preserved for 7 days.

+
+
+
\ No newline at end of file diff --git a/static/partials/signin.html b/static/partials/signin.html index b4b5f7685..8753ee0c1 100644 --- a/static/partials/signin.html +++ b/static/partials/signin.html @@ -1,4 +1,4 @@ -