diff --git a/data/database.py b/data/database.py index a1f34fe52..88d4e26d6 100644 --- a/data/database.py +++ b/data/database.py @@ -382,10 +382,27 @@ class RepositoryNotification(BaseModel): config_json = TextField() +class RepositoryAuthorizedEmail(BaseModel): + repository = ForeignKeyField(Repository, index=True) + email = CharField() + code = CharField(default=random_string_generator(), unique=True, index=True) + confirmed = BooleanField(default=False) + + class Meta: + database = db + read_slaves = (read_slave,) + indexes = ( + # create a unique index on email and repository + (('email', 'repository'), True), + ) + + + all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility, RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem, RepositoryBuild, Team, TeamMember, TeamRole, LogEntryKind, LogEntry, PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger, OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, Notification, ImageStorageLocation, ImageStoragePlacement, - ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification] + ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, + RepositoryAuthorizedEmail] diff --git a/data/model/legacy.py b/data/model/legacy.py index c36dc5bab..4d8d28146 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1763,3 +1763,39 @@ def check_health(): return found_count > 0 except: return False + +def get_email_authorized_for_repo(namespace, repository, email): + found = list(RepositoryAuthorizedEmail.select() + .join(Repository) + .where(Repository.namespace == namespace, + Repository.name == repository, + RepositoryAuthorizedEmail.email == email) + .switch(RepositoryAuthorizedEmail) + .limit(1)) + if not found or len(found) < 1: + return None + + return found[0] + + +def create_email_authorization_for_repo(namespace_name, repository_name, email): + try: + repo = Repository.get(Repository.name == repository_name, + Repository.namespace == namespace_name) + except Repository.DoesNotExist: + raise DataModelException('Invalid repository %s/%s' % + (namespace_name, repository_name)) + + return RepositoryAuthorizedEmail.create(repository=repo, email=email, confirmed=False) + + +def confirm_email_authorization_for_repo(code): + try: + found = RepositoryAuthorizedEmail.get(RepositoryAuthorizedEmail.code == code) + except RepositoryAuthorizedEmail.DoesNotExist: + raise DataModelException('Invalid confirmation code.') + + found.confirmed = True + found.save() + + return found diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 775e7328b..60edf2e35 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -308,6 +308,7 @@ import endpoints.api.permission import endpoints.api.prototype import endpoints.api.repository import endpoints.api.repositorynotification +import endpoints.api.repoemail import endpoints.api.repotoken import endpoints.api.robot import endpoints.api.search diff --git a/endpoints/api/repoemail.py b/endpoints/api/repoemail.py new file mode 100644 index 000000000..6585bbc49 --- /dev/null +++ b/endpoints/api/repoemail.py @@ -0,0 +1,56 @@ +import logging + +from flask import request, abort + +from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, + log_action, validate_json_request, NotFound, internal_only) + +from app import tf +from data import model +from data.database import db +from util.useremails import send_repo_authorization_email + +import features + + +logger = logging.getLogger(__name__) + +def record_view(record): + return { + 'email': record.email, + 'repository': record.repository.name, + 'namespace': record.repository.namespace, + 'confirmed': record.confirmed + } + + +@internal_only +@resource('/v1/repository//authorizedemail/') +class RepositoryAuthorizedEmail(RepositoryParamResource): + """ Resource for checking and authorizing e-mail addresses to receive repo notifications. """ + @require_repo_admin + @nickname('checkRepoEmailAuthorized') + def get(self, namespace, repository, email): + """ Checks to see if the given e-mail address is authorized on this repository. """ + record = model.get_email_authorized_for_repo(namespace, repository, email) + if not record: + abort(404) + + return record_view(record) + + + @require_repo_admin + @nickname('sendAuthorizeRepoEmail') + def post(self, namespace, repository, email): + """ Starts the authorization process for an e-mail address on a repository. """ + + with tf(db): + record = model.get_email_authorized_for_repo(namespace, repository, email) + if record and record.confirmed: + return record_view(record) + + if not record: + record = model.create_email_authorization_for_repo(namespace, repository, email) + + send_repo_authorization_email(namespace, repository, email, record.code) + return record_view(record) diff --git a/endpoints/api/repositorynotification.py b/endpoints/api/repositorynotification.py index 175f45350..36764866d 100644 --- a/endpoints/api/repositorynotification.py +++ b/endpoints/api/repositorynotification.py @@ -1,11 +1,13 @@ import json -from flask import request +from flask import request, abort from app import notification_queue from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, - log_action, validate_json_request, api, NotFound) + log_action, validate_json_request, api, NotFound, request_error) from endpoints.notificationevent import NotificationEvent +from endpoints.notificationmethod import (NotificationMethod, + CannotValidateNotificationMethodException) from data import model @@ -62,6 +64,15 @@ class RepositoryNotificationList(RepositoryParamResource): repo = model.get_repository(namespace, repository) json = request.get_json() + method_handler = NotificationMethod.get_method(json['method']) + if not method_handler: + raise request_error(message='Unknown method') + + try: + method_handler.validate(repo, json['config']) + except CannotValidateNotificationMethodException as ex: + raise request_error(message=ex.message) + notification = model.create_repo_notification(repo, json['event'], json['method'], json['config']) diff --git a/endpoints/notificationmethod.py b/endpoints/notificationmethod.py index 9f57fc495..b86f0cdd0 100644 --- a/endpoints/notificationmethod.py +++ b/endpoints/notificationmethod.py @@ -14,6 +14,10 @@ logger = logging.getLogger(__name__) class InvalidNotificationMethodException(Exception): pass +class CannotValidateNotificationMethodException(Exception): + pass + + class NotificationMethod(object): def __init__(self): pass @@ -25,6 +29,13 @@ class NotificationMethod(object): """ raise NotImplementedError + def validate(self, repository, config_data): + """ + Validates that the notification can be created with the given data. Throws + a CannotValidateNotificationMethodException on failure. + """ + raise NotImplementedError + def perform(self, notification, event_handler, notification_data): """ Performs the notification method. @@ -49,36 +60,32 @@ class QuayNotificationMethod(NotificationMethod): def method_name(cls): return 'quay_notification' - def perform(self, notification, event_handler, notification_data): - config_data = json.loads(notification.config_json) - repository_id = notification_data['repository_id'] - repository = model.lookup_repository(repository_id) - if not repository: - # Probably deleted. - return True - - # Lookup the target user or team to which we'll send the notification. + def validate(self, repository, config_data): + status, err_message, target_users = self.find_targets(repository, config_data) + if err_message: + raise CannotValidateNotificationMethodException(err_message) + + def find_targets(self, repository, config_data): target_info = config_data['target'] - target_users = [] if target_info['kind'] == 'user': target = model.get_user(target_info['name']) if not target: # Just to be safe. - return True + return (True, 'Unknown user %s' % target_info['name'], []) - target_users.append(target) + return (True, None, [target]) elif target_info['kind'] == 'org': target = model.get_organization(target_info['name']) if not target: # Just to be safe. - return True + return (True, 'Unknown organization %s' % target_info['name'], None) # Only repositories under the organization can cause notifications to that org. if target_info['name'] != repository.namespace: - return False + return (False, 'Organization name must match repository namespace') - target_users.append(target) + return (True, None, [target]) elif target_info['kind'] == 'team': # Lookup the team. team = None @@ -86,13 +93,27 @@ class QuayNotificationMethod(NotificationMethod): team = model.get_organization_team(repository.namespace, target_info['name']) except model.InvalidTeamException: # Probably deleted. - return True + return (True, 'Unknown team %s' % target_info['name'], None) # Lookup the team's members - target_users = model.get_organization_team_members(team.id) + return (True, None, model.get_organization_team_members(team.id)) + + + def perform(self, notification, event_handler, notification_data): + repository_id = notification_data['repository_id'] + repository = model.lookup_repository(repository_id) + if not repository: + # Probably deleted. + return True + + # Lookup the target user or team to which we'll send the notification. + config_data = json.loads(notification.config_json) + status, err_message, target_users = self.find_targets(repository, config_data) + if not status: + return False # For each of the target users, create a notification. - for target_user in set(target_users): + for target_user in set(target_users or []): model.create_notification(event_handler.event_name(), target_user, metadata=notification_data['event_data']) return True @@ -103,6 +124,18 @@ class EmailMethod(NotificationMethod): def method_name(cls): return 'email' + def validate(self, repository, config_data): + email = config_data.get('email', '') + if not email: + raise CannotValidateNotificationMethodException('Missing e-mail address') + + record = model.get_email_authorized_for_repo(repository.namespace, repository.name, email) + if not record or not record.confirmed: + raise CannotValidateNotificationMethodException('The specified e-mail address ' + 'is not authorized to receive ' + 'notifications for this repository') + + def perform(self, notification, event_handler, notification_data): config_data = json.loads(notification.config_json) email = config_data.get('email', '') @@ -129,6 +162,11 @@ class WebhookMethod(NotificationMethod): def method_name(cls): return 'webhook' + def validate(self, repository, config_data): + url = config_data.get('url', '') + if not url: + raise CannotValidateNotificationMethodException('Missing webhook URL') + def perform(self, notification, event_handler, notification_data): config_data = json.loads(notification.config_json) url = config_data.get('url', '') diff --git a/endpoints/web.py b/endpoints/web.py index 7cd95f1a1..cce13752d 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -214,6 +214,26 @@ def receipt(): abort(404) +@web.route('/authrepoemail', methods=['GET']) +def confirm_repo_email(): + code = request.values['code'] + record = None + + try: + record = model.confirm_email_authorization_for_repo(code) + except model.DataModelException as ex: + return render_page_template('confirmerror.html', error_message=ex.message) + + message = """ + Your E-mail address has been authorized to receive notifications for repository + %s/%s. + """ % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'], + record.repository.namespace, record.repository.name, + record.repository.namespace, record.repository.name) + + return render_page_template('message.html', message=message) + + @web.route('/confirm', methods=['GET']) def confirm_email(): code = request.values['code'] diff --git a/initdb.py b/initdb.py index c55aceceb..1021f35ea 100644 --- a/initdb.py +++ b/initdb.py @@ -424,6 +424,12 @@ def populate_database(): 'build_subdir': '', } + record = model.create_email_authorization_for_repo(new_user_1.username, 'simple', 'jschorr@devtable.com') + record.confirmed = True + record.save() + + model.create_email_authorization_for_repo(new_user_1.username, 'simple', 'jschorr+other@devtable.com') + build2 = model.create_repository_build(building, token, job_config, '68daeebd-a5b9-457f-80a0-4363b882f8ea', 'build-name', trigger) diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html index 712a203da..d384f3f59 100644 --- a/static/directives/create-external-notification-dialog.html +++ b/static/directives/create-external-notification-dialog.html @@ -10,9 +10,26 @@ + + + -