From 99341f7d537ff93f77f6a1577d320f7b1f2b4989 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 19:29:01 -0400 Subject: [PATCH 1/7] Send a confirmation email when an account is created. Links don't do anything yet. --- app.py | 10 +++++++--- config.py | 15 +++++++++++++++ data/database.py | 8 ++++++++ data/model.py | 7 ++++++- endpoints/index.py | 7 +++++-- endpoints/web.py | 10 ++++++++++ requirements.txt | 3 ++- util/email.py | 20 ++++++++++++++++++++ 8 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 config.py create mode 100644 util/email.py diff --git a/app.py b/app.py index 3f72d3cd0..ea52822ee 100644 --- a/app.py +++ b/app.py @@ -3,15 +3,19 @@ import logging from flask import Flask from flask.ext.principal import Principal from flask.ext.login import LoginManager - +from flask.ext.mail import Mail +from config import ProductionConfig app = Flask(__name__) +app.config.from_object(ProductionConfig()) + logger = logging.getLogger(__name__) Principal(app, use_sessions=True) -app.secret_key = '1cb18882-6d12-440d-a4cc-b7430fb5f884' - login_manager = LoginManager() login_manager.init_app(app) login_manager.login_view = 'signin' + +mail = Mail() +mail.init_app(app) diff --git a/config.py b/config.py new file mode 100644 index 000000000..fdb96fdf9 --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +class FlaskConfig(object): + SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884' + +class MailConfig(object): + MAIL_SERVER = 'email-smtp.us-east-1.amazonaws.com' + MAIL_USE_TLS = True + MAIL_PORT = 587 + MAIL_USERNAME = 'AKIAIXV5SDGCPVMU3N4Q' + MAIL_PASSWORD = 'AhmX/vWE91uQ2RtcEKTkfNrzZehEjPNXOXeOXgQNfLao' + DEFAULT_MAIL_SENDER = 'support@fluxmonkey.io' + MAIL_FAIL_SILENTLY = False + TESTING = False + +class ProductionConfig(FlaskConfig, MailConfig): + pass \ No newline at end of file diff --git a/data/database.py b/data/database.py index c8c5aab5a..50ca6055a 100644 --- a/data/database.py +++ b/data/database.py @@ -66,6 +66,14 @@ class AccessToken(BaseModel): created = DateTimeField(default=datetime.now) +class EmailConfirmation(BaseModel): + code = CharField(default=random_string_generator()) + user = ForeignKeyField(User) + pw_reset = BooleanField(default=False) + email_confirm = BooleanField(default=False) + created = DateTimeField(default=datetime.now) + + class Image(BaseModel): # This class is intentionally denormalized. Even though images are supposed # to be globally unique we can't treat them as such for permissions and diff --git a/data/model.py b/data/model.py index ec9d85bb8..24f1279b7 100644 --- a/data/model.py +++ b/data/model.py @@ -28,9 +28,14 @@ def create_user(username, password, email): try: new_user = User.create(username=username, password_hash=pw_hash, email=email) + return new_user except Exception as ex: raise DataModelException(ex.message) - return new_user + + +def create_confirm_email_code(user): + code = EmailConfirmation(user=user, email_confirm=True) + return code def get_user(username): diff --git a/endpoints/index.py b/endpoints/index.py index 8a02c70bd..4fb52cb7f 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -11,6 +11,7 @@ from app import app from auth.auth import (process_auth, get_authenticated_user, get_validated_token) from util.names import parse_namespace_repository, parse_repository_name +from util.email import send_confirmation_email from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, UserPermission) @@ -46,8 +47,10 @@ def generate_headers(f): @app.route('/v1/users/', methods=['POST']) def create_user(): user_data = request.get_json() - model.create_user(user_data['username'], user_data['password'], - user_data['email']) + new_user = model.create_user(user_data['username'], user_data['password'], + user_data['email']) + code = model.create_confirm_email_code(new_user) + send_confirmation_email(new_user.username, new_user.email, code.code) return make_response('Created', 201) diff --git a/endpoints/web.py b/endpoints/web.py index c7564bb1f..c2a549b5d 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -56,6 +56,16 @@ def signin(): abort(403) +@app.route('/confirm', methods=['GET']) +def confirm_email(): + pass + + +@app.route('/reset', methods=['GET']) +def password_reset(): + pass + + @app.route('/signin', methods=['GET']) def render_signin_page(): return send_file('templates/signin.html') diff --git a/requirements.txt b/requirements.txt index 9b80bab6d..560d03df5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ peewee flask py-bcrypt Flask-Principal -Flask-Login \ No newline at end of file +Flask-Login +Flask-Mail \ No newline at end of file diff --git a/util/email.py b/util/email.py new file mode 100644 index 000000000..2d7dc950e --- /dev/null +++ b/util/email.py @@ -0,0 +1,20 @@ +from flask.ext.mail import Message + +from app import mail, app + + +CONFIRM_MESSAGE = """ +This email address was recently used to register the username '%s' +at Quay.io.
+
+To confirm this email address, please click the following link:
+http://quay.io/confirm?token=%s +""" + + +def send_confirmation_email(username, email, token): + msg = Message('Welcome to Quay! Please confirm your email.', + sender='support@fluxmonkey.io', # Why do I need this? + recipients=[email]) + msg.html = CONFIRM_MESSAGE % (username, token, token) + mail.send(msg) From 32581c0621eb06a60a949528fa9ce558ad4281a6 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 19:55:04 -0400 Subject: [PATCH 2/7] Handle the confirmation codes to validate user emails. --- data/database.py | 9 ++++----- data/model.py | 15 ++++++++++++++- endpoints/web.py | 20 +++++++++++++------- util/email.py | 2 +- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/data/database.py b/data/database.py index 50ca6055a..e4c276c03 100644 --- a/data/database.py +++ b/data/database.py @@ -18,9 +18,7 @@ class User(BaseModel): username = CharField(unique=True) password_hash = CharField() email = CharField(unique=True) - - # TODO move this to False and require email verification - verified = BooleanField(default=True) + verified = BooleanField(default=False) class Visibility(BaseModel): @@ -67,7 +65,7 @@ class AccessToken(BaseModel): class EmailConfirmation(BaseModel): - code = CharField(default=random_string_generator()) + code = CharField(default=random_string_generator(), unique=True) user = ForeignKeyField(User) pw_reset = BooleanField(default=False) email_confirm = BooleanField(default=False) @@ -101,7 +99,8 @@ class RepositoryTag(BaseModel): def initialize_db(): create_model_tables([User, Repository, Image, AccessToken, Role, - RepositoryPermission, Visibility, RepositoryTag]) + RepositoryPermission, Visibility, RepositoryTag, + EmailConfirmation]) Role.create(name='admin') Role.create(name='write') Role.create(name='read') diff --git a/data/model.py b/data/model.py index 4c64699be..69cc79dbc 100644 --- a/data/model.py +++ b/data/model.py @@ -34,10 +34,23 @@ def create_user(username, password, email): def create_confirm_email_code(user): - code = EmailConfirmation(user=user, email_confirm=True) + code = EmailConfirmation.create(user=user, email_confirm=True) return code +def confirm_user_email(code): + code = EmailConfirmation.get(EmailConfirmation.code == code, + EmailConfirmation.email_confirm == True) + + user = code.user + user.verified = True + user.save() + + code.delete_instance() + + return user + + def get_user(username): try: return User.get(User.username == username) diff --git a/endpoints/web.py b/endpoints/web.py index c2a549b5d..ca4f07c91 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -36,6 +36,12 @@ def index(): return send_file('templates/index.html') +def common_login(db_user): + logger.debug('Successfully signed in as: %s' % db_user.username) + login_user(_LoginWrappedDBUser(db_user)) + identity_changed.send(app, identity=Identity(db_user.username, 'username')) + + @app.route('/signin', methods=['POST']) def signin(): username = request.form['username'] @@ -44,12 +50,7 @@ def signin(): #TODO Allow email login verified = model.verify_user(username, password) if verified: - logger.debug('Successfully signed in as: %s' % username) - - login_user(_LoginWrappedDBUser(verified)) - - identity_changed.send(app, identity=Identity(verified.username, - 'username')) + common_login(verified) return redirect(request.args.get('next') or url_for('index')) @@ -58,7 +59,12 @@ def signin(): @app.route('/confirm', methods=['GET']) def confirm_email(): - pass + code = request.values['code'] + user = model.confirm_user_email(code) + + common_login(user) + + return redirect(url_for('index')) @app.route('/reset', methods=['GET']) diff --git a/util/email.py b/util/email.py index 2d7dc950e..fcc069409 100644 --- a/util/email.py +++ b/util/email.py @@ -8,7 +8,7 @@ This email address was recently used to register the username '%s' at Quay.io.

To confirm this email address, please click the following link:
-http://quay.io/confirm?token=%s +http://quay.io/confirm?code=%s """ From a0adffdc8e3551e1ab5560c6d01ab451f2c44158 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 20:03:07 -0400 Subject: [PATCH 3/7] PEP8 fixes. --- config.py | 4 +++- data/model.py | 14 +++++++++++--- endpoints/api.py | 21 ++++++++++++--------- util/validation.py | 1 + 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/config.py b/config.py index fdb96fdf9..373dfaea2 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ class FlaskConfig(object): SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884' + class MailConfig(object): MAIL_SERVER = 'email-smtp.us-east-1.amazonaws.com' MAIL_USE_TLS = True @@ -11,5 +12,6 @@ class MailConfig(object): MAIL_FAIL_SILENTLY = False TESTING = False + class ProductionConfig(FlaskConfig, MailConfig): - pass \ No newline at end of file + pass diff --git a/data/model.py b/data/model.py index 69cc79dbc..6e5dd0c17 100644 --- a/data/model.py +++ b/data/model.py @@ -58,8 +58,10 @@ def get_user(username): return None -def get_matching_users(username_prefix): - return list(User.select().where(User.username ** (username_prefix + '%')).limit(10)) +def get_matching_users(username_prefix): + query = User.select().where(User.username ** (username_prefix + '%')) + return list(query.limit(10)) + def verify_user(username, password): try: @@ -94,7 +96,10 @@ def get_token(code): def get_matching_repositories(repo_term): - return list(Repository.select().where(Repository.name ** ('%' + repo_term + '%') | Repository.namespace ** ('%' + repo_term + '%') | Repository.description ** ('%' + repo_term + '%')).limit(10)) + clauses = (Repository.name ** ('%' + repo_term + '%') | + Repository.namespace ** ('%' + repo_term + '%') | + Repository.description ** ('%' + repo_term + '%')) + return list(Repository.select().where(clauses).limit(10)) def change_password(user, new_password): @@ -187,6 +192,7 @@ def get_repository_images(namespace_name, repository_name): return joined.where(Repository.name == repository_name, Repository.namespace == namespace_name) + def get_tag_images(namespace_name, repository_name, tag_name): joined = Image.select().join(RepositoryTag).join(Repository) fetched = list(joined.where(Repository.name == repository_name, @@ -195,6 +201,7 @@ def get_tag_images(namespace_name, repository_name, tag_name): return fetched + def list_repository_tags(namespace_name, repository_name): select = RepositoryTag.select(RepositoryTag, Image) with_repo = select.join(Repository) @@ -290,6 +297,7 @@ def set_user_repo_permission(username, namespace_name, repository_name, role=new_role) return new_perm + def delete_user_permission(username, namespace_name, repository_name): if username == namespace_name: raise DataModelException('Namespace owner must always be admin.') diff --git a/endpoints/api.py b/endpoints/api.py index 49e2778ee..fc07f3a5e 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -64,8 +64,9 @@ def match_repos_api(prefix): 'description': repo.description, } - repos = [repo_view(repo) for repo in model.get_matching_repositories(prefix) if - ReadRepositoryPermission(repo.namespace, repo.name).can()] + matching = model.get_matching_repositories(prefix) + repos = [repo_view(repo) for repo in matching + if ReadRepositoryPermission(repo.namespace, repo.name).can()] response = { 'repositories': repos } @@ -98,7 +99,7 @@ def list_repos_api(): @parse_repository_name def update_repo_api(namespace, repository): permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can(): repo = model.get_repository(namespace, repository) if repo: values = request.get_json() @@ -107,7 +108,7 @@ def update_repo_api(namespace, repository): return jsonify({ 'success': True }) - + abort(404) @@ -124,7 +125,7 @@ def image_view(image): @parse_repository_name def get_repo_api(namespace, repository): logger.debug('Get repo: %s/%s' % (namespace, repository)) - + def tag_view(tag): image = model.get_tag_image(namespace, repository, tag.name) if not image: @@ -136,7 +137,7 @@ def get_repo_api(namespace, repository): } permission = ReadRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can(): repo = model.get_repository(namespace, repository) if repo: tags = model.list_repository_tags(namespace, repository) @@ -162,14 +163,15 @@ def role_view(repo_perm_obj): } -@app.route('/api/repository//tag//images', methods=['GET']) +@app.route('/api/repository//tag//images', + methods=['GET']) @login_required @parse_repository_name def list_tag_images(namespace, repository, tag): permission = ReadRepositoryPermission(namespace, repository) if permission.can(): images = model.get_tag_images(namespace, repository, tag) - + return jsonify({ 'images': [image_view(image) for image in images] }) @@ -186,7 +188,7 @@ def list_repo_permissions(namespace, repository): repo_perms = model.get_all_repo_users(namespace, repository) return jsonify({ - 'permissions': {repo_perm.user.username: role_view(repo_perm) + 'permissions': {repo_perm.user.username: role_view(repo_perm) for repo_perm in repo_perms} }) @@ -234,6 +236,7 @@ def change_permissions(namespace, repository, username): abort(403) # Permission denied + @app.route('/api/repository//permissions/', methods=['DELETE']) @login_required diff --git a/util/validation.py b/util/validation.py index 9f5682677..9121a23e2 100644 --- a/util/validation.py +++ b/util/validation.py @@ -14,6 +14,7 @@ def validate_username(username): len(username) > 1 and len(username) < 256) + def validate_password(password): # No whitespace and minimum length of 8 if re.search(r'\s', password): From 540dfd5343d2d5da435b1f13a298ad3c0e78ab0e Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 23:25:57 -0400 Subject: [PATCH 4/7] Switch the search to use SQL query. --- data/model.py | 27 ++++++++++++++++++++------- endpoints/api.py | 7 +++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/data/model.py b/data/model.py index 6e5dd0c17..9ef8d8ce9 100644 --- a/data/model.py +++ b/data/model.py @@ -1,6 +1,7 @@ import bcrypt import logging import dateutil.parser +import operator from database import * from util.validation import (validate_email, validate_username, @@ -95,11 +96,23 @@ def get_token(code): return AccessToken.get(AccessToken.code == code) -def get_matching_repositories(repo_term): - clauses = (Repository.name ** ('%' + repo_term + '%') | - Repository.namespace ** ('%' + repo_term + '%') | - Repository.description ** ('%' + repo_term + '%')) - return list(Repository.select().where(clauses).limit(10)) +def get_matching_repositories(repo_term, username=None): + query = Repository.select().distinct().limit(10).join(Visibility) + or_clauses = [(Visibility.name == 'public')] + + if username: + with_perms = query.switch(Repository).join(RepositoryPermission, + JOIN_LEFT_OUTER) + query = with_perms.join(User) + or_clauses.append(User.username == username) + + search_clauses = (Repository.name ** ('%' + repo_term + '%') | + Repository.namespace ** ('%' + repo_term + '%') | + Repository.description ** ('%' + repo_term + '%')) + + + final = query.where(search_clauses).where(reduce(operator.or_, or_clauses)) + return list(final) def change_password(user, new_password): @@ -174,8 +187,8 @@ def set_image_metadata(image_id, namespace_name, repository_name, created_date_str, comment): joined = Image.select().join(Repository) image_list = list(joined.where(Repository.name == repository_name, - Repository.namespace == namespace_name, - Image.image_id == image_id)) + Repository.namespace == namespace_name, + Image.image_id == image_id)) if not image_list: raise DataModelException('No image with specified id and repository') diff --git a/endpoints/api.py b/endpoints/api.py index fc07f3a5e..1954f8ff9 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -64,11 +64,10 @@ def match_repos_api(prefix): 'description': repo.description, } - matching = model.get_matching_repositories(prefix) - repos = [repo_view(repo) for repo in matching - if ReadRepositoryPermission(repo.namespace, repo.name).can()] + username = current_user.db_user.username + matching = model.get_matching_repositories(prefix, username) response = { - 'repositories': repos + 'repositories': [repo_view(repo) for repo in matching] } return jsonify(response) From d680a9d173667f179cc1ff89b76ee7513bceba86 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 23:33:59 -0400 Subject: [PATCH 5/7] Create indices that we will probably need. --- data/database.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/data/database.py b/data/database.py index e4c276c03..cf83b50e0 100644 --- a/data/database.py +++ b/data/database.py @@ -15,14 +15,14 @@ class BaseModel(Model): class User(BaseModel): - username = CharField(unique=True) + username = CharField(unique=True, index=True) password_hash = CharField() - email = CharField(unique=True) + email = CharField(unique=True, index=True) verified = BooleanField(default=False) class Visibility(BaseModel): - name = CharField() + name = CharField(index=True) class Repository(BaseModel): @@ -40,14 +40,20 @@ class Repository(BaseModel): class Role(BaseModel): - name = CharField() + name = CharField(index=True) class RepositoryPermission(BaseModel): - user = ForeignKeyField(User) - repository = ForeignKeyField(Repository) + user = ForeignKeyField(User, index=True) + repository = ForeignKeyField(Repository, index=True) role = ForeignKeyField(Role) + class Meta: + database = db + indexes = ( + (('user', 'repository'), True), + ) + def random_string_generator(length=16): def random_string(): @@ -58,14 +64,14 @@ def random_string_generator(length=16): class AccessToken(BaseModel): - code = CharField(default=random_string_generator(), unique=True) + code = CharField(default=random_string_generator(), unique=True, index=True) user = ForeignKeyField(User) repository = ForeignKeyField(Repository) created = DateTimeField(default=datetime.now) class EmailConfirmation(BaseModel): - code = CharField(default=random_string_generator(), unique=True) + code = CharField(default=random_string_generator(), unique=True, index=True) user = ForeignKeyField(User) pw_reset = BooleanField(default=False) email_confirm = BooleanField(default=False) @@ -96,6 +102,12 @@ class RepositoryTag(BaseModel): image = ForeignKeyField(Image) repository = ForeignKeyField(Repository) + class Meta: + database = db + indexes = ( + (('repository', 'name'), True), + ) + def initialize_db(): create_model_tables([User, Repository, Image, AccessToken, Role, From d50a5d83f8e5486ffaaae48604314935fbabaa30 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 27 Sep 2013 23:47:32 -0400 Subject: [PATCH 6/7] Add a public database to the test db. --- test.db | Bin 30720 -> 38912 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test.db b/test.db index 823541a26aefdf68c3a3d7f9f47375f210a222e9..a1177a60b9dadc0d9bfe307a64958bef9a1c7e8f 100644 GIT binary patch delta 3430 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 E0Q8N5s{jB1 delta 2296 zcmZuydu&tZ75~n6?!C@+>^RQL2}x)|vPTkZ{C=d{6jN;Hi4!NmNy=k}xHKb>Db9m5 zFnSZIT_x1je4<^YYMZA032HNM>V!Jf?ypUBRGp@7-P9sFSkdSPsRrFj|Jb>92+Z5n zy`PWIcb>mGO-ZX^$i=|!@Ibivr6AV zgVjo$Qd908%96=UG{;_sKgHfP?Hf4w1UIl2P5!!?IyN)+WO{Z!lb&sLr&?X^)^Cqb zj(*FLOdg5!kBvu{j`a4X=Unmm-R{g}dej~8>=+C$EcF};&F?zunBAQmm|KVp#l&e# z?LKg0s2~GM3 zJVU>nh2$qrc%ay%-0xR!+++i<$uJlTCfEkikFvSE^oNWf_>chj z9g54gMp;6On)>l}smzRI2v0LSjql*=d4%@1fx;SFpyPhDv#HssV~6brQ<*d$d2%Y# zL~p8TE}fZk)S$vzs)0S^Jugrqp^ zgrIW0B7_#B_c_=Pzu+bO8GJ@GHf3tH1UJ~Kt6|lU2T?DL{-O9s(?vjWzPS-3(Y$So zxZd1cFDbU>YM7roHkm%IOC3lbO-)VfluR;SenJ+lZB3wvNZaG6om2$0H-SNHYySWY zXF42EBG#5`MXbZM5$;t{s1q#WyV1TgC!}(PUuJk2U%@H#Bj=abv|7I0k_}hmKA0P2qzM;oqlhkXFTrh85!*h|Bp_v z)8}%yJOOXe>5@oCg>a~6d|)&<9!Vw>gYIBYlo#Bb+Vm6bbbB0rpEu<3yQqnDozXzI zClGSQlY54{dnY_QH*_{t0-Zj;!|V43{2?dxmt9rEyZwW`{e7|F-u_-sc+eLqN+!_h z^*P+mV9@7wQd8U2E>a2ibce?J9_tx)I|l|P_7ti3I=y~}-|2O`LwcVoxXSQ6{sBM1 zRs25wipoEOr>f8n#YieTvluE_XBJ~2>CEC}F>kXd9bSocwK!lStI*@R%J2*P3_r!c z(%2o;%F2XgU$gk(-g>CWpOW+YOX;6MT&K}t zp}ns`9J=g_jvm;&!~if55L}2L8n@@G`u%rk#YX zH4@kX-GM_-1cF|dH{c9~d|sb1CWB_CIr!#cSFM_t;l(l~V((&1F+^omGmG#`VMQsB zi!X*1xj>#f8&;$O*(JgX7D!VzY~T@HO%>8&SGgJgN~L*&;h(4hFX0=6;5ih(Ic15M zsbA^ Date: Sat, 28 Sep 2013 00:05:32 -0400 Subject: [PATCH 7/7] Full add public repository support. --- data/model.py | 29 +++++++++++++++++------------ endpoints/api.py | 16 ++++++++-------- endpoints/index.py | 2 +- endpoints/registry.py | 20 +++++++++++--------- endpoints/tags.py | 4 ++-- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/data/model.py b/data/model.py index 9ef8d8ce9..8a215ad6d 100644 --- a/data/model.py +++ b/data/model.py @@ -96,8 +96,8 @@ def get_token(code): return AccessToken.get(AccessToken.code == code) -def get_matching_repositories(repo_term, username=None): - query = Repository.select().distinct().limit(10).join(Visibility) +def get_visible_repositories(username=None): + query = Repository.select().distinct().join(Visibility) or_clauses = [(Visibility.name == 'public')] if username: @@ -106,12 +106,17 @@ def get_matching_repositories(repo_term, username=None): query = with_perms.join(User) or_clauses.append(User.username == username) + return query.where(reduce(operator.or_, or_clauses)) + + +def get_matching_repositories(repo_term, username=None): + visible = get_visible_repositories(username) search_clauses = (Repository.name ** ('%' + repo_term + '%') | Repository.namespace ** ('%' + repo_term + '%') | Repository.description ** ('%' + repo_term + '%')) - final = query.where(search_clauses).where(reduce(operator.or_, or_clauses)) + final = visible.where(search_clauses).limit(10) return list(final) @@ -144,20 +149,20 @@ def get_all_repo_users(namespace_name, repository_name): Repository.name == repository_name) -def get_repository(namespace, name): +def get_repository(namespace_name, repository_name): try: - return Repository.get(Repository.name == name, - Repository.namespace == namespace) + return Repository.get(Repository.name == repository_name, + Repository.namespace == namespace_name) except Repository.DoesNotExist: return None -def get_user_repositories(user): - select = RepositoryPermission.select(RepositoryPermission, Repository, Role) - with_user = select.join(User).where(User.username == user.username) - with_role = with_user.switch(RepositoryPermission).join(Role) - with_repo = with_role.switch(RepositoryPermission).join(Repository) - return with_repo +def repository_is_public(namespace_name, repository_name): + joined = Repository.select().join(Visibility) + query = joined.where(Repository.namespace == namespace_name, + Repository.name == repository_name, + Visibility.name == 'public') + return len(list(query)) > 0 def create_repository(namespace, name, owner): diff --git a/endpoints/api.py b/endpoints/api.py index 1954f8ff9..14b55ae88 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -76,16 +76,16 @@ def match_repos_api(prefix): @app.route('/api/repository/', methods=['GET']) @login_required def list_repos_api(): - def repo_view(repo_perm): + def repo_view(repo_obj): return { - 'namespace': repo_perm.repository.namespace, - 'name': repo_perm.repository.name, - 'role': repo_perm.role.name, - 'description': repo_perm.repository.description, + 'namespace': repo_obj.namespace, + 'name': repo_obj.name, + 'description': repo_obj.description, } + username = current_user.db_user.username repos = [repo_view(repo) - for repo in model.get_user_repositories(current_user.db_user)] + for repo in model.get_visible_repositories(username)] response = { 'repositories': repos } @@ -136,7 +136,7 @@ def get_repo_api(namespace, repository): } permission = ReadRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can() or model.repository_is_public(namespace, repository): repo = model.get_repository(namespace, repository) if repo: tags = model.list_repository_tags(namespace, repository) @@ -168,7 +168,7 @@ def role_view(repo_perm_obj): @parse_repository_name def list_tag_images(namespace, repository, tag): permission = ReadRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can() or model.repository_is_public(namespace, repository): images = model.get_tag_images(namespace, repository, tag) return jsonify({ diff --git a/endpoints/index.py b/endpoints/index.py index 4fb52cb7f..e6708ed6e 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -157,7 +157,7 @@ def get_repository_images(namespace, repository): # TODO invalidate token? - if permission.can(): + if permission.can() or model.repository_is_public(namespace, repository): all_images = [] for image in model.get_repository_images(namespace, repository): new_image_view = { diff --git a/endpoints/registry.py b/endpoints/registry.py index 7dfb74667..e1bdc6d42 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -80,14 +80,14 @@ def set_cache_headers(f): @set_cache_headers def get_image_layer(namespace, repository, image_id, headers): permission = ReadRepositoryPermission(namespace, repository) - if not permission.can(): - abort(403) + if permission.can() or model.repository_is_public(namespace, repository): + try: + return Response(store.stream_read(store.image_layer_path( + namespace, repository, image_id)), headers=headers) + except IOError: + abort(404) #'Image not found', 404) - try: - return Response(store.stream_read(store.image_layer_path( - namespace, repository, image_id)), headers=headers) - except IOError: - abort(404) #'Image not found', 404) + abort(403) @app.route('/v1/images//layer', methods=['PUT']) @@ -182,7 +182,8 @@ def put_image_checksum(namespace, repository, image_id): @set_cache_headers def get_image_json(namespace, repository, image_id, headers): permission = ReadRepositoryPermission(namespace, repository) - if not permission.can(): + if (not permission.can() and not + model.repository_is_public(namespace, repository)): abort(403) try: @@ -211,7 +212,8 @@ def get_image_json(namespace, repository, image_id, headers): @set_cache_headers def get_image_ancestry(namespace, repository, image_id, headers): permission = ReadRepositoryPermission(namespace, repository) - if not permission.can(): + if (not permission.can() and not + model.repository_is_public(namespace, repository)): abort(403) try: diff --git a/endpoints/tags.py b/endpoints/tags.py index 89cbdc442..46d64a985 100644 --- a/endpoints/tags.py +++ b/endpoints/tags.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) def get_tags(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can() or model.repository_is_public(namespace, repository): tags = model.list_repository_tags(namespace, repository) tag_map = {tag.name: tag.image.image_id for tag in tags} return jsonify(tag_map) @@ -40,7 +40,7 @@ def get_tags(namespace, repository): def get_tag(namespace, repository, tag): permission = ReadRepositoryPermission(namespace, repository) - if permission.can(): + if permission.can() or model.repository_is_public(namespace, repository): tag_image = model.get_tag_image(namespace, repository, tag) response = make_response(tag_image.image_id, 200)