diff --git a/app.py b/app.py index e13a82a0d..ab6853098 100644 --- a/app.py +++ b/app.py @@ -28,8 +28,6 @@ else: app.config.from_object(config) -logger = logging.getLogger(__name__) - Principal(app, use_sessions=True) login_manager = LoginManager() diff --git a/config.py b/config.py index 03f58bb4f..ca5e02a16 100644 --- a/config.py +++ b/config.py @@ -72,18 +72,33 @@ class MixpanelProdConfig(object): MIXPANEL_KEY = '50ff2b2569faa3a51c8f5724922ffb7e' +class GitHubTestConfig(object): + GITHUB_CLIENT_ID = 'cfbc4aca88e5c1b40679' + GITHUB_CLIENT_SECRET = '7d1cc21e17e10cd8168410e2cd1e4561cb854ff9' + GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token' + GITHUB_USER_URL = 'https://api.github.com/user' + GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails' + + +class GitHubProdConfig(GitHubTestConfig): + GITHUB_CLIENT_ID = '5a8c08b06c48d89d4d1e' + GITHUB_CLIENT_SECRET = 'f89d8bb28ea3bd4e1c68808500d185a816be53b1' + + class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB, - StripeTestConfig, MixpanelTestConfig): + StripeTestConfig, MixpanelTestConfig, GitHubTestConfig): REGISTRY_SERVER = 'localhost:5000' LOGGING_CONFIG = { 'level': logging.DEBUG, 'format': LOG_FORMAT } SEND_FILE_MAX_AGE_DEFAULT = 0 + POPULATE_DB_TEST_DATA = True class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, - StripeLiveConfig, MixpanelTestConfig): + StripeLiveConfig, MixpanelTestConfig, + GitHubProdConfig): REGISTRY_SERVER = 'localhost:5000' LOGGING_CONFIG = { 'level': logging.DEBUG, @@ -93,7 +108,8 @@ class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, class ProductionConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, - StripeLiveConfig, MixpanelProdConfig): + StripeLiveConfig, MixpanelProdConfig, + GitHubProdConfig): REGISTRY_SERVER = 'quay.io' LOGGING_CONFIG = { 'stream': sys.stderr, diff --git a/data/database.py b/data/database.py index c5b5e7e88..d82ecbae1 100644 --- a/data/database.py +++ b/data/database.py @@ -36,12 +36,32 @@ class BaseModel(Model): class User(BaseModel): username = CharField(unique=True, index=True) - password_hash = CharField() + password_hash = CharField(null=True) email = CharField(unique=True, index=True) verified = BooleanField(default=False) stripe_id = CharField(index=True, null=True) +class LoginService(BaseModel): + name = CharField(unique=True, index=True) + + +class FederatedLogin(BaseModel): + user = ForeignKeyField(User, index=True) + service = ForeignKeyField(LoginService, index=True) + service_ident = CharField() + + class Meta: + database = db + indexes = ( + # create a unique index on service and the local service id + (('service', 'service_ident'), True), + + # a user may only have one federated login per service + (('service', 'user'), True), + ) + + class Visibility(BaseModel): name = CharField(index=True) @@ -136,9 +156,10 @@ class RepositoryTag(BaseModel): def initialize_db(): create_model_tables([User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility, RepositoryTag, - EmailConfirmation]) + EmailConfirmation, FederatedLogin, LoginService]) Role.create(name='admin') Role.create(name='write') Role.create(name='read') Visibility.create(name='public') Visibility.create(name='private') + LoginService.create(name='github') diff --git a/data/model.py b/data/model.py index c6115c40a..d6d3c9154 100644 --- a/data/model.py +++ b/data/model.py @@ -34,6 +34,34 @@ def create_user(username, password, email): raise DataModelException(ex.message) +def create_federated_user(username, email, service_name, service_id): + try: + new_user = User.create(username=username, email=email, verified=True) + service = LoginService.get(LoginService.name == service_name) + federated_user = FederatedLogin.create(user=new_user, service=service, + service_ident=service_id) + + return new_user + + except Exception as ex: + raise DataModelException(ex.message) + + +def verify_federated_login(service_name, service_id): + selected = FederatedLogin.select(FederatedLogin, User) + with_service = selected.join(LoginService) + with_user = with_service.switch(FederatedLogin).join(User) + found = with_user.where(FederatedLogin.service_ident == service_id, + LoginService.name == service_name) + + found_list = list(found) + + if found_list: + return found_list[0].user + + return None + + def create_confirm_email_code(user): code = EmailConfirmation.create(user=user, email_confirm=True) return code diff --git a/endpoints/api.py b/endpoints/api.py index b4a2d07d1..0e5a9cdd5 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -4,6 +4,7 @@ import stripe from flask import request, make_response, jsonify, abort from flask.ext.login import login_required, current_user from functools import wraps +from collections import defaultdict from data import model from app import app @@ -263,6 +264,31 @@ def role_view(repo_perm_obj): } +@app.route('/api/repository//image/', methods=['GET']) +@parse_repository_name +def list_repository_images(namespace, repository): + permission = ReadRepositoryPermission(namespace, repository) + if permission.can() or model.repository_is_public(namespace, repository): + all_images = model.get_repository_images(namespace, repository) + all_tags = model.list_repository_tags(namespace, repository) + + tags_by_image_id = defaultdict(list) + for tag in all_tags: + tags_by_image_id[tag.image.docker_image_id].append(tag.name) + + + def add_tags(image_json): + image_json['tags'] = tags_by_image_id[image_json['id']] + return image_json + + + return jsonify({ + 'images': [add_tags(image_view(image)) for image in all_images] + }) + + abort(403) + + @app.route('/api/repository//tag//images', methods=['GET']) @parse_repository_name diff --git a/endpoints/web.py b/endpoints/web.py index aaa3e560e..f253264b5 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -1,4 +1,5 @@ import logging +import requests from flask import (abort, send_file, redirect, request, url_for, render_template, make_response) @@ -66,7 +67,8 @@ def common_login(db_user): @app.route('/signin', methods=['GET']) def render_signin_page(): - return render_template('signin.html') + return render_template('signin.html', + github_client_id=app.config['GITHUB_CLIENT_ID']) @app.route('/signin', methods=['POST']) @@ -81,12 +83,66 @@ def signin(): return redirect(request.args.get('next') or url_for('index')) else: return render_template('signin.html', - needs_email_verification=True) + needs_email_verification=True, + github_client_id=app.config['GITHUB_CLIENT_ID']) else: return render_template('signin.html', username=username, - invalid_credentials=True) + invalid_credentials=True, + github_client_id=app.config['GITHUB_CLIENT_ID']) + + +@app.route('/oauth2/github/callback', methods=['GET']) +def github_oauth_callback(): + code = request.args.get('code') + payload = { + 'client_id': app.config['GITHUB_CLIENT_ID'], + 'client_secret': app.config['GITHUB_CLIENT_SECRET'], + 'code': code, + } + headers = { + 'Accept': 'application/json' + } + + get_access_token = requests.post(app.config['GITHUB_TOKEN_URL'], + params=payload, headers=headers) + + token = get_access_token.json()['access_token'] + + token_param = { + 'access_token': token, + } + get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param) + + user_data = get_user.json() + username = user_data['login'] + github_id = user_data['id'] + + v3_media_type = { + 'Accept': 'application/vnd.github.v3' + } + get_email = requests.get(app.config['GITHUB_USER_EMAILS'], + params=token_param, headers=v3_media_type) + + # We will accept any email, but we prefer the primary + found_email = None + for user_email in get_email.json(): + found_email = user_email['email'] + if user_email['primary']: + break + + to_login = model.verify_federated_login('github', github_id) + if not to_login: + # try to create the user + to_login = model.create_federated_user(username, found_email, 'github', + github_id) + + if common_login(to_login): + return redirect(url_for('index')) + + # TODO something bad happened, we need to tell the user somehow + return redirect(url_for('signin')) @app.route('/confirm', methods=['GET']) diff --git a/initdb.py b/initdb.py index a35737ffb..cf795a0a0 100644 --- a/initdb.py +++ b/initdb.py @@ -1,4 +1,97 @@ +import logging +import string + +from random import SystemRandom +from datetime import datetime + from data.database import initialize_db +from data import model +from app import app + + +logger = logging.getLogger(__name__) +logging.basicConfig(**app.config['LOGGING_CONFIG']) + + +def __gen_hex_id(length=64): + random = SystemRandom() + return ''.join([random.choice('abcdef' + string.digits) + for x in range(length)]) + + +def __gen_checksum(): + return 'tarsum+sha256:' + __gen_hex_id(64) + + +def create_subtree(repo, structure, parent): + num_nodes, subtrees, last_node_tags = structure + + # create the nodes + for i in range(num_nodes): + docker_image_id = __gen_hex_id() + checksum = __gen_checksum() + + new_image = model.create_image(docker_image_id, repo) + model.set_image_checksum(docker_image_id, repo, checksum) + + new_image = model.set_image_metadata(docker_image_id, repo.namespace, + repo.name, str(datetime.now()), + 'no comment', parent) + + parent = new_image + + if last_node_tags: + if not isinstance(last_node_tags, list): + last_node_tags = [last_node_tags] + + for tag_name in last_node_tags: + model.create_or_update_tag(repo.namespace, repo.name, tag_name, + new_image.docker_image_id) + + for subtree in subtrees: + create_subtree(repo, subtree, new_image) + + +def __generate_repository(user, name, is_public, permissions, structure): + repo = model.create_repository(user.username, name, user) + + if is_public: + model.set_repository_visibility(repo, 'public') + + for delegate, role in permissions: + model.set_user_repo_permission(delegate.username, user.username, name, + role) + + create_subtree(repo, structure, None) + if __name__ == '__main__': initialize_db() + + if app.config.get('POPULATE_DB_TEST_DATA', False): + logger.debug('Populating the DB with test data.') + + new_user_1 = model.create_user('devtable', 'password', + 'jake@devtable.com') + new_user_1.verified = True + new_user_1.save() + + new_user_2 = model.create_user('public', 'password', + 'jacob.moshenko@gmail.com') + new_user_2.verified = True + new_user_2.save() + + __generate_repository(new_user_1, 'simple', False, [], (4, [], + ['latest', 'prod'])) + + __generate_repository(new_user_1, 'complex', False, [], + (2, [(3, [], 'v2.0'), + (1, [(1, [(1, [], ['latest', 'prod'])], + 'staging'), + (1, [], None)], None)], None)) + + __generate_repository(new_user_2, 'publicrepo', True, [], + (10, [], 'latest')) + + __generate_repository(new_user_1, 'shared', False, + [(new_user_2, 'write')], (5, [], 'latest')) diff --git a/static/css/signin.css b/static/css/signin.css index 637ffdfb5..f67dc78dd 100644 --- a/static/css/signin.css +++ b/static/css/signin.css @@ -8,6 +8,7 @@ body { max-width: 330px; padding: 15px; margin: 0 auto; + text-align: center; } .form-signin .form-signin-heading, .form-signin .checkbox { @@ -42,4 +43,20 @@ body { .alert { max-width: 300px; margin: 0 auto; +} + +.social-alternate { + color: #777; + font-size: 3em; + margin-left: 43px; +} + +.social-alternate .inner-text { + text-align: center; + position: relative; + color: white; + left: -43px; + top: -9px; + font-weight: bold; + font-size: .4em; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index d604e2cef..310d3dd74 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -123,8 +123,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', when('/repository/', {title: 'Repositories', templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}). when('/user', {title: 'User Admin', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}). when('/guide/', {title: 'Getting Started Guide', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}). - when('/plans/', {title: 'Quay Plans', templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). - when('/', {title: 'Quay: Private docker repository hosting', templateUrl: '/static/partials/landing.html', controller: LandingCtrl}). + when('/plans/', {title: 'Plans and Pricing', templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). + when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl}). otherwise({redirectTo: '/'}); }]). config(function(RestangularProvider) { diff --git a/templates/index.html b/templates/index.html index b51acd913..fd892e009 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,6 +4,7 @@ Quay - Private Docker Repository + diff --git a/templates/signin.html b/templates/signin.html index f6f180253..85c63940c 100644 --- a/templates/signin.html +++ b/templates/signin.html @@ -4,6 +4,7 @@ Sign In - Quay + @@ -13,6 +14,13 @@ + +