Merge remote-tracking branch 'origin/master' into tagyourit

Conflicts:
	endpoints/api.py
	static/js/app.js
	static/partials/view-repo.html
	test/data/test.db
	test/specs.py
	test/test_api_usage.py
This commit is contained in:
jakedt 2014-03-26 19:42:29 -04:00
commit 302bfb27ae
123 changed files with 16314 additions and 3789 deletions

View file

@ -55,6 +55,13 @@ running the tests:
STACK=test python -m unittest discover STACK=test python -m unittest discover
``` ```
running the tests with coverage (requires coverage module):
```
STACK=test coverage run -m unittest discover
coverage html
```
generating screenshots: generating screenshots:
``` ```

7
app.py
View file

@ -8,7 +8,7 @@ from flask.ext.login import LoginManager
from flask.ext.mail import Mail from flask.ext.mail import Mail
from config import (ProductionConfig, DebugConfig, LocalHostedConfig, from config import (ProductionConfig, DebugConfig, LocalHostedConfig,
TestConfig) TestConfig, StagingConfig)
from util import analytics from util import analytics
@ -20,6 +20,9 @@ stack = os.environ.get('STACK', '').strip().lower()
if stack.startswith('prod'): if stack.startswith('prod'):
logger.info('Running with production config.') logger.info('Running with production config.')
config = ProductionConfig() config = ProductionConfig()
elif stack.startswith('staging'):
logger.info('Running with staging config on production data.')
config = StagingConfig()
elif stack.startswith('localhosted'): elif stack.startswith('localhosted'):
logger.info('Running with debug config on production data.') logger.info('Running with debug config on production data.')
config = LocalHostedConfig() config = LocalHostedConfig()
@ -32,7 +35,7 @@ else:
app.config.from_object(config) app.config.from_object(config)
Principal(app, use_sessions=True) Principal(app, use_sessions=False)
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)

View file

@ -9,22 +9,24 @@ application.config['LOGGING_CONFIG']()
# Turn off debug logging for boto # Turn off debug logging for boto
logging.getLogger('boto').setLevel(logging.CRITICAL) logging.getLogger('boto').setLevel(logging.CRITICAL)
from endpoints.api import api from endpoints.api import api_bp
from endpoints.index import index from endpoints.index import index
from endpoints.web import web from endpoints.web import web
from endpoints.tags import tags from endpoints.tags import tags
from endpoints.registry import registry from endpoints.registry import registry
from endpoints.webhooks import webhooks from endpoints.webhooks import webhooks
from endpoints.realtime import realtime from endpoints.realtime import realtime
from endpoints.callbacks import callback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
application.register_blueprint(web) application.register_blueprint(web)
application.register_blueprint(callback, url_prefix='/oauth2')
application.register_blueprint(index, url_prefix='/v1') application.register_blueprint(index, url_prefix='/v1')
application.register_blueprint(tags, url_prefix='/v1') application.register_blueprint(tags, url_prefix='/v1')
application.register_blueprint(registry, url_prefix='/v1') application.register_blueprint(registry, url_prefix='/v1')
application.register_blueprint(api, url_prefix='/api') application.register_blueprint(api_bp, url_prefix='/api')
application.register_blueprint(webhooks, url_prefix='/webhooks') application.register_blueprint(webhooks, url_prefix='/webhooks')
application.register_blueprint(realtime, url_prefix='/realtime') application.register_blueprint(realtime, url_prefix='/realtime')
@ -37,9 +39,5 @@ def close_db(exc):
application.teardown_request(close_db) application.teardown_request(close_db)
# Remove this for prod config
application.debug = True
if __name__ == '__main__': if __name__ == '__main__':
application.run(port=5000, debug=True, threaded=True, host='0.0.0.0') application.run(port=5000, debug=True, threaded=True, host='0.0.0.0')

View file

@ -1,20 +1,69 @@
import logging import logging
from functools import wraps from functools import wraps
from flask import request, _request_ctx_stack, session from datetime import datetime
from flask import request, session
from flask.ext.principal import identity_changed, Identity from flask.ext.principal import identity_changed, Identity
from flask.ext.login import current_user
from base64 import b64decode from base64 import b64decode
import scopes
from data import model from data import model
from data.model import oauth
from app import app from app import app
from permissions import QuayDeferredPermissionUser from permissions import QuayDeferredPermissionUser
from auth_context import (set_authenticated_user, set_validated_token,
from util.names import parse_namespace_repository set_authenticated_user_deferred, set_validated_oauth_token)
from util.http import abort from util.http import abort
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _load_user_from_cookie():
if not current_user.is_anonymous():
logger.debug('Loading user from cookie: %s', current_user.get_id())
set_authenticated_user_deferred(current_user.get_id())
loaded = QuayDeferredPermissionUser(current_user.get_id(), 'username', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=loaded)
return current_user.db_user()
return None
def _validate_and_apply_oauth_token(token):
validated = oauth.validate_access_token(token)
if not validated:
logger.warning('OAuth access token could not be validated: %s', token)
authenticate_header = {
'WWW-Authenticate': ('Bearer error="invalid_token", '
'error_description="The access token is invalid"'),
}
abort(401, message='OAuth access token could not be validated: %(token)s',
issue='invalid-oauth-token', token=token, headers=authenticate_header)
elif validated.expires_at <= datetime.now():
logger.info('OAuth access with an expired token: %s', token)
authenticate_header = {
'WWW-Authenticate': ('Bearer error="invalid_token", '
'error_description="The access token expired"'),
}
abort(401, message='OAuth access token has expired: %(token)s',
issue='invalid-oauth-token', token=token, headers=authenticate_header)
# We have a valid token
scope_set = scopes.scopes_from_scope_string(validated.scope)
logger.debug('Successfully validated oauth access token: %s with scope: %s', token,
scope_set)
set_authenticated_user(validated.authorized_user)
set_validated_oauth_token(validated)
new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username',
scope_set)
identity_changed.send(app, identity=new_identity)
def process_basic_auth(auth): def process_basic_auth(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2: if normalized[0].lower() != 'basic' or len(normalized) != 2:
@ -31,8 +80,7 @@ def process_basic_auth(auth):
try: try:
token = model.load_token_data(credentials[1]) token = model.load_token_data(credentials[1])
logger.debug('Successfully validated token: %s' % credentials[1]) logger.debug('Successfully validated token: %s' % credentials[1])
ctx = _request_ctx_stack.top set_validated_token(token)
ctx.validated_token = token
identity_changed.send(app, identity=Identity(token.code, 'token')) identity_changed.send(app, identity=Identity(token.code, 'token'))
return return
@ -40,16 +88,21 @@ def process_basic_auth(auth):
except model.DataModelException: except model.DataModelException:
logger.debug('Invalid token: %s' % credentials[1]) logger.debug('Invalid token: %s' % credentials[1])
elif credentials[0] == '$oauthtoken':
oauth_token = credentials[1]
_validate_and_apply_oauth_token(oauth_token)
elif '+' in credentials[0]: elif '+' in credentials[0]:
logger.debug('Trying robot auth with credentials %s' % str(credentials)) logger.debug('Trying robot auth with credentials %s' % str(credentials))
# Use as robot auth # Use as robot auth
try: try:
robot = model.verify_robot(credentials[0], credentials[1]) robot = model.verify_robot(credentials[0], credentials[1])
logger.debug('Successfully validated robot: %s' % credentials[0]) logger.debug('Successfully validated robot: %s' % credentials[0])
ctx = _request_ctx_stack.top set_authenticated_user(robot)
ctx.authenticated_user = robot
identity_changed.send(app, identity=Identity(robot.username, 'username')) deferred_robot = QuayDeferredPermissionUser(robot.username, 'username',
{scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=deferred_robot)
return return
except model.InvalidRobotException: except model.InvalidRobotException:
logger.debug('Invalid robot or password for robot: %s' % credentials[0]) logger.debug('Invalid robot or password for robot: %s' % credentials[0])
@ -59,11 +112,10 @@ def process_basic_auth(auth):
if authenticated: if authenticated:
logger.debug('Successfully validated user: %s' % authenticated.username) logger.debug('Successfully validated user: %s' % authenticated.username)
ctx = _request_ctx_stack.top set_authenticated_user(authenticated)
ctx.authenticated_user = authenticated
new_identity = QuayDeferredPermissionUser(authenticated.username, new_identity = QuayDeferredPermissionUser(authenticated.username, 'username',
'username') {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
return return
@ -81,31 +133,49 @@ def process_token(auth):
if len(token_details) != 1: if len(token_details) != 1:
logger.warning('Invalid token format: %s' % auth) logger.warning('Invalid token format: %s' % auth)
abort(401, message="Invalid token format: %(auth)", issue='invalid-auth-token', auth=auth) abort(401, message='Invalid token format: %(auth)s', issue='invalid-auth-token', auth=auth)
token_vals = {val[0]: val[1] for val in token_vals = {val[0]: val[1] for val in
(detail.split('=') for detail in token_details)} (detail.split('=') for detail in token_details)}
if 'signature' not in token_vals: if 'signature' not in token_vals:
logger.warning('Token does not contain signature: %s' % auth) logger.warning('Token does not contain signature: %s' % auth)
abort(401, message="Token does not contain a valid signature: %(auth)", issue='invalid-auth-token', auth=auth) abort(401, message='Token does not contain a valid signature: %(auth)s',
issue='invalid-auth-token', auth=auth)
try: try:
token_data = model.load_token_data(token_vals['signature']) token_data = model.load_token_data(token_vals['signature'])
except model.InvalidTokenException: except model.InvalidTokenException:
logger.warning('Token could not be validated: %s' % logger.warning('Token could not be validated: %s', token_vals['signature'])
token_vals['signature']) abort(401, message='Token could not be validated: %(auth)s', issue='invalid-auth-token',
abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token', auth=auth) auth=auth)
logger.debug('Successfully validated token: %s' % token_data.code) logger.debug('Successfully validated token: %s', token_data.code)
ctx = _request_ctx_stack.top set_validated_token(token_data)
ctx.validated_token = token_data
identity_changed.send(app, identity=Identity(token_data.code, 'token')) identity_changed.send(app, identity=Identity(token_data.code, 'token'))
def process_auth(f): def process_oauth(func):
@wraps(f) @wraps(func)
def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '')
if auth:
normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'bearer' or len(normalized) != 2:
logger.debug('Invalid oauth bearer token format.')
return
token = normalized[1]
_validate_and_apply_oauth_token(token)
elif _load_user_from_cookie() is None:
logger.debug('No auth header or login cookie.')
return func(*args, **kwargs)
return wrapper
def process_auth(func):
@wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '') auth = request.headers.get('authorization', '')
@ -116,17 +186,26 @@ def process_auth(f):
else: else:
logger.debug('No auth header.') logger.debug('No auth header.')
return f(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
def extract_namespace_repo_from_session(f): def require_session_login(func):
@wraps(f) @wraps(func)
def wrapper(*args, **kwargs):
loaded = _load_user_from_cookie()
if loaded is None or loaded.organization:
abort(401, message='Method requires login and no valid login could be loaded.')
return func(*args, **kwargs)
return wrapper
def extract_namespace_repo_from_session(func):
@wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if 'namespace' not in session or 'repository' not in session: if 'namespace' not in session or 'repository' not in session:
logger.error('Unable to load namespace or repository from session: %s' % logger.error('Unable to load namespace or repository from session: %s' % session)
session) abort(400, message='Missing namespace in request')
abort(400, message="Missing namespace in request")
return f(session['namespace'], session['repository'], *args, **kwargs) return func(session['namespace'], session['repository'], *args, **kwargs)
return wrapper return wrapper

View file

@ -1,7 +1,53 @@
import logging
from flask import _request_ctx_stack from flask import _request_ctx_stack
from data import model
logger = logging.getLogger(__name__)
def get_authenticated_user(): def get_authenticated_user():
return getattr(_request_ctx_stack.top, 'authenticated_user', None) user = getattr(_request_ctx_stack.top, 'authenticated_user', None)
if not user:
username = getattr(_request_ctx_stack.top, 'authenticated_username', None)
if not username:
logger.debug('No authenticated user or deferred username.')
return None
logger.debug('Loading deferred authenticated user.')
loaded = model.get_user(username)
set_authenticated_user(loaded)
user = loaded
logger.debug('Returning authenticated user: %s', user.username)
return user
def set_authenticated_user(user_or_robot):
ctx = _request_ctx_stack.top
ctx.authenticated_user = user_or_robot
def set_authenticated_user_deferred(username_or_robotname):
logger.debug('Deferring loading of authenticated user object: %s', username_or_robotname)
ctx = _request_ctx_stack.top
ctx.authenticated_username = username_or_robotname
def get_validated_oauth_token():
return getattr(_request_ctx_stack.top, 'validated_oauth_token', None)
def set_validated_oauth_token(token):
ctx = _request_ctx_stack.top
ctx.validated_oauth_token = token
def get_validated_token(): def get_validated_token():
return getattr(_request_ctx_stack.top, 'validated_token', None) return getattr(_request_ctx_stack.top, 'validated_token', None)
def set_validated_token(token):
ctx = _request_ctx_stack.top
ctx.validated_token = token

View file

@ -1,10 +1,11 @@
import logging import logging
from flask.ext.principal import (identity_loaded, UserNeed, Permission, from flask.ext.principal import identity_loaded, Permission, Identity, identity_changed
Identity, identity_changed) from collections import namedtuple, defaultdict
from collections import namedtuple
from functools import partial from functools import partial
import scopes
from data import model from data import model
from app import app from app import app
@ -14,44 +15,117 @@ logger = logging.getLogger(__name__)
_ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role']) _ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role'])
_RepositoryNeed = partial(_ResourceNeed, 'repository') _RepositoryNeed = partial(_ResourceNeed, 'repository')
_OrganizationNeed = namedtuple('organization', ['orgname', 'role']) _NamespaceWideNeed = namedtuple('namespacewide', ['type', 'namespace', 'role'])
_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role']) _OrganizationNeed = partial(_NamespaceWideNeed, 'organization')
_OrganizationRepoNeed = partial(_NamespaceWideNeed, 'organizationrepo')
_TeamTypeNeed = namedtuple('teamwideneed', ['type', 'orgname', 'teamname', 'role'])
_TeamNeed = partial(_TeamTypeNeed, 'orgteam')
_UserTypeNeed = namedtuple('userspecificneed', ['type', 'username', 'role'])
_UserNeed = partial(_UserTypeNeed, 'user')
REPO_ROLES = [None, 'read', 'write', 'admin']
TEAM_ROLES = [None, 'member', 'creator', 'admin']
USER_ROLES = [None, 'read', 'admin']
TEAM_REPO_ROLES = {
'admin': 'admin',
'creator': 'read',
'member': 'read',
}
SCOPE_MAX_REPO_ROLES = defaultdict(lambda: None)
SCOPE_MAX_REPO_ROLES.update({
scopes.READ_REPO: 'read',
scopes.WRITE_REPO: 'write',
scopes.ADMIN_REPO: 'admin',
scopes.DIRECT_LOGIN: 'admin',
})
SCOPE_MAX_TEAM_ROLES = defaultdict(lambda: None)
SCOPE_MAX_TEAM_ROLES.update({
scopes.CREATE_REPO: 'creator',
scopes.DIRECT_LOGIN: 'admin',
})
SCOPE_MAX_USER_ROLES = defaultdict(lambda: None)
SCOPE_MAX_USER_ROLES.update({
scopes.READ_USER: 'read',
scopes.DIRECT_LOGIN: 'admin',
})
class QuayDeferredPermissionUser(Identity): class QuayDeferredPermissionUser(Identity):
def __init__(self, id, auth_type=None): def __init__(self, id, auth_type, scopes):
super(QuayDeferredPermissionUser, self).__init__(id, auth_type) super(QuayDeferredPermissionUser, self).__init__(id, auth_type)
self._permissions_loaded = False self._permissions_loaded = False
self._scope_set = scopes
def _translate_role_for_scopes(self, cardinality, max_roles, role):
if self._scope_set is None:
return role
max_for_scopes = max({cardinality.index(max_roles[scope]) for scope in self._scope_set})
if max_for_scopes < cardinality.index(role):
logger.debug('Translated permission %s -> %s', role, cardinality[max_for_scopes])
return cardinality[max_for_scopes]
else:
return role
def _team_role_for_scopes(self, role):
return self._translate_role_for_scopes(TEAM_ROLES, SCOPE_MAX_TEAM_ROLES, role)
def _repo_role_for_scopes(self, role):
return self._translate_role_for_scopes(REPO_ROLES, SCOPE_MAX_REPO_ROLES, role)
def _user_role_for_scopes(self, role):
return self._translate_role_for_scopes(USER_ROLES, SCOPE_MAX_USER_ROLES, role)
def can(self, permission): def can(self, permission):
if not self._permissions_loaded: if not self._permissions_loaded:
logger.debug('Loading user permissions after deferring.') logger.debug('Loading user permissions after deferring.')
user_object = model.get_user(self.id) user_object = model.get_user(self.id)
# Add the user specific permissions # Add the user specific permissions, only for non-oauth permission
user_grant = UserNeed(user_object.username) user_grant = _UserNeed(user_object.username, self._user_role_for_scopes('admin'))
logger.debug('User permission: {0}'.format(user_grant))
self.provides.add(user_grant) self.provides.add(user_grant)
# Every user is the admin of their own 'org' # Every user is the admin of their own 'org'
user_namespace = _OrganizationNeed(user_object.username, 'admin') user_namespace = _OrganizationNeed(user_object.username, self._team_role_for_scopes('admin'))
logger.debug('User namespace permission: {0}'.format(user_namespace))
self.provides.add(user_namespace) self.provides.add(user_namespace)
# Org repo roles can differ for scopes
user_repos = _OrganizationRepoNeed(user_object.username, self._repo_role_for_scopes('admin'))
logger.debug('User namespace repo permission: {0}'.format(user_repos))
self.provides.add(user_repos)
# Add repository permissions # Add repository permissions
for perm in model.get_all_user_permissions(user_object): for perm in model.get_all_user_permissions(user_object):
grant = _RepositoryNeed(perm.repository.namespace, repo_grant = _RepositoryNeed(perm.repository.namespace, perm.repository.name,
perm.repository.name, perm.role.name) self._repo_role_for_scopes(perm.role.name))
logger.debug('User added permission: {0}'.format(grant)) logger.debug('User added permission: {0}'.format(repo_grant))
self.provides.add(grant) self.provides.add(repo_grant)
# Add namespace permissions derived # Add namespace permissions derived
for team in model.get_org_wide_permissions(user_object): for team in model.get_org_wide_permissions(user_object):
grant = _OrganizationNeed(team.organization.username, team.role.name) team_org_grant = _OrganizationNeed(team.organization.username,
logger.debug('Organization team added permission: {0}'.format(grant)) self._team_role_for_scopes(team.role.name))
self.provides.add(grant) logger.debug('Organization team added permission: {0}'.format(team_org_grant))
self.provides.add(team_org_grant)
team_repo_role = TEAM_REPO_ROLES[team.role.name]
org_repo_grant = _OrganizationRepoNeed(team.organization.username,
self._repo_role_for_scopes(team_repo_role))
logger.debug('Organization team added repo permission: {0}'.format(org_repo_grant))
self.provides.add(org_repo_grant)
team_grant = _TeamNeed(team.organization.username, team.name, team_grant = _TeamNeed(team.organization.username, team.name,
team.role.name) self._team_role_for_scopes(team.role.name))
logger.debug('Team added permission: {0}'.format(team_grant)) logger.debug('Team added permission: {0}'.format(team_grant))
self.provides.add(team_grant) self.provides.add(team_grant)
@ -64,9 +138,10 @@ class ModifyRepositoryPermission(Permission):
def __init__(self, namespace, name): def __init__(self, namespace, name):
admin_need = _RepositoryNeed(namespace, name, 'admin') admin_need = _RepositoryNeed(namespace, name, 'admin')
write_need = _RepositoryNeed(namespace, name, 'write') write_need = _RepositoryNeed(namespace, name, 'write')
org_admin_need = _OrganizationNeed(namespace, 'admin') org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, org_write_need = _OrganizationRepoNeed(namespace, 'write')
org_admin_need) super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, org_admin_need,
org_write_need)
class ReadRepositoryPermission(Permission): class ReadRepositoryPermission(Permission):
@ -74,15 +149,17 @@ class ReadRepositoryPermission(Permission):
admin_need = _RepositoryNeed(namespace, name, 'admin') admin_need = _RepositoryNeed(namespace, name, 'admin')
write_need = _RepositoryNeed(namespace, name, 'write') write_need = _RepositoryNeed(namespace, name, 'write')
read_need = _RepositoryNeed(namespace, name, 'read') read_need = _RepositoryNeed(namespace, name, 'read')
org_admin_need = _OrganizationNeed(namespace, 'admin') org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
super(ReadRepositoryPermission, self).__init__(admin_need, write_need, org_write_need = _OrganizationRepoNeed(namespace, 'write')
read_need, org_admin_need) org_read_need = _OrganizationRepoNeed(namespace, 'read')
super(ReadRepositoryPermission, self).__init__(admin_need, write_need, read_need,
org_admin_need, org_read_need, org_write_need)
class AdministerRepositoryPermission(Permission): class AdministerRepositoryPermission(Permission):
def __init__(self, namespace, name): def __init__(self, namespace, name):
admin_need = _RepositoryNeed(namespace, name, 'admin') admin_need = _RepositoryNeed(namespace, name, 'admin')
org_admin_need = _OrganizationNeed(namespace, 'admin') org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
super(AdministerRepositoryPermission, self).__init__(admin_need, super(AdministerRepositoryPermission, self).__init__(admin_need,
org_admin_need) org_admin_need)
@ -95,10 +172,17 @@ class CreateRepositoryPermission(Permission):
create_repo_org) create_repo_org)
class UserPermission(Permission): class UserAdminPermission(Permission):
def __init__(self, username): def __init__(self, username):
user_need = UserNeed(username) user_admin = _UserNeed(username, 'admin')
super(UserPermission, self).__init__(user_need) super(UserAdminPermission, self).__init__(user_admin)
class UserReadPermission(Permission):
def __init__(self, username):
user_admin = _UserNeed(username, 'admin')
user_read = _UserNeed(username, 'read')
super(UserReadPermission, self).__init__(user_read, user_admin)
class AdministerOrganizationPermission(Permission): class AdministerOrganizationPermission(Permission):
@ -132,14 +216,15 @@ def on_identity_loaded(sender, identity):
# We have verified an identity, load in all of the permissions # We have verified an identity, load in all of the permissions
if isinstance(identity, QuayDeferredPermissionUser): if isinstance(identity, QuayDeferredPermissionUser):
logger.debug('Deferring permissions for user: %s' % identity.id) logger.debug('Deferring permissions for user: %s', identity.id)
elif identity.auth_type == 'username': elif identity.auth_type == 'username':
switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'username') logger.debug('Switching username permission to deferred object: %s', identity.id)
switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'username', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=switch_to_deferred) identity_changed.send(app, identity=switch_to_deferred)
elif identity.auth_type == 'token': elif identity.auth_type == 'token':
logger.debug('Loading permissions for token: %s' % identity.id) logger.debug('Loading permissions for token: %s', identity.id)
token_data = model.load_token_data(identity.id) token_data = model.load_token_data(identity.id)
repo_grant = _RepositoryNeed(token_data.repository.namespace, repo_grant = _RepositoryNeed(token_data.repository.namespace,
@ -149,4 +234,4 @@ def on_identity_loaded(sender, identity):
identity.provides.add(repo_grant) identity.provides.add(repo_grant)
else: else:
logger.error('Unknown identity auth type: %s' % identity.auth_type) logger.error('Unknown identity auth type: %s', identity.auth_type)

92
auth/scopes.py Normal file
View file

@ -0,0 +1,92 @@
from collections import namedtuple
Scope = namedtuple('scope', ['scope', 'icon', 'title', 'description'])
READ_REPO = Scope(scope='repo:read',
icon='fa-hdd-o',
title='View all visible repositories',
description=('This application will be able to view and pull all repositories '
'visible to the granting user or robot account'))
WRITE_REPO = Scope(scope='repo:write',
icon='fa-hdd-o',
title='Read/Write to any accessible repositories',
description=('This application will be able to view, push and pull to all '
'repositories to which the granting user or robot account has '
'write access'))
ADMIN_REPO = Scope(scope='repo:admin',
icon='fa-hdd-o',
title='Administer Repositories',
description=('This application will have administrator access to all '
'repositories to which the granting user or robot account has '
'access'))
CREATE_REPO = Scope(scope='repo:create',
icon='fa-plus',
title='Create Repositories',
description=('This application will be able to create repositories in to any '
'namespaces that the granting user or robot account is allowed to '
'create repositories'))
READ_USER = Scope(scope= 'user:read',
icon='fa-user',
title='Read User Information',
description=('This application will be able to read user information such as '
'username and email address.'))
DIRECT_LOGIN = Scope(scope='direct_user_login',
icon='fa-exclamation-triangle',
title='Full Access',
description=('This scope should not be available to OAuth applications. '
'Never approve a request for this scope!'))
ALL_SCOPES = {scope.scope:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO,
READ_USER)}
IMPLIED_SCOPES = {
ADMIN_REPO: {ADMIN_REPO, WRITE_REPO, READ_REPO},
WRITE_REPO: {WRITE_REPO, READ_REPO},
READ_REPO: {READ_REPO},
CREATE_REPO: {CREATE_REPO},
READ_USER: {READ_USER},
None: set(),
}
def scopes_from_scope_string(scopes):
return {ALL_SCOPES.get(scope, None) for scope in scopes.split(',')}
def validate_scope_string(scopes):
decoded = scopes_from_scope_string(scopes)
return None not in decoded and len(decoded) > 0
def is_subset_string(full_string, expected_string):
""" Returns true if the scopes found in expected_string are also found
in full_string.
"""
full_scopes = scopes_from_scope_string(full_string)
full_implied_scopes = set.union(*[IMPLIED_SCOPES[scope] for scope in full_scopes])
expected_scopes = scopes_from_scope_string(expected_string)
return expected_scopes.issubset(full_implied_scopes)
def get_scope_information(scopes_string):
scopes = scopes_from_scope_string(scopes_string)
scope_info = []
for scope in scopes:
if scope:
scope_info.append({
'title': scope.title,
'scope': scope.scope,
'description': scope.description,
'icon': scope.icon,
})
return scope_info

1
buildstatus/building.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="146" height="18" fill="#555"/><rect rx="4" x="92" width="54" height="18" fill="#dfb317"/><path fill="#dfb317" d="M92 0h4v18h-4z"/><rect rx="4" width="146" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="118" y="13" fill="#010101" fill-opacity=".3">building</text><text x="118" y="12">building</text></g></svg>

After

Width:  |  Height:  |  Size: 835 B

1
buildstatus/failed.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="164" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="164" height="18" fill="#555"/><rect rx="4" x="92" width="72" height="18" fill="#e05d44"/><path fill="#e05d44" d="M92 0h4v18h-4z"/><rect rx="4" width="164" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="127" y="13" fill="#010101" fill-opacity=".3">build failed</text><text x="127" y="12">build failed</text></g></svg>

After

Width:  |  Height:  |  Size: 843 B

1
buildstatus/none.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="130" height="18" fill="#555"/><rect rx="4" x="92" width="38" height="18" fill="#9f9f9f"/><path fill="#9f9f9f" d="M92 0h4v18h-4z"/><rect rx="4" width="130" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="110" y="13" fill="#010101" fill-opacity=".3">none</text><text x="110" y="12">none</text></g></svg>

After

Width:  |  Height:  |  Size: 827 B

1
buildstatus/ready.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="135" height="18"><linearGradient id="a" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-opacity=".3"/><stop offset="1" stop-opacity=".5"/></linearGradient><rect rx="4" width="135" height="18" fill="#555"/><rect rx="4" x="92" width="43" height="18" fill="#4c1"/><path fill="#4c1" d="M92 0h4v18h-4z"/><rect rx="4" width="135" height="18" fill="url(#a)"/><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="47" y="13" fill="#010101" fill-opacity=".3">Docker Image</text><text x="47" y="12">Docker Image</text><text x="112.5" y="13" fill="#010101" fill-opacity=".3">ready</text><text x="112.5" y="12">ready</text></g></svg>

After

Width:  |  Height:  |  Size: 827 B

View file

@ -1,5 +1,7 @@
import logging import logging
import logstash_formatter import logstash_formatter
import requests
import os.path
from peewee import MySQLDatabase, SqliteDatabase from peewee import MySQLDatabase, SqliteDatabase
from storage.s3 import S3Storage from storage.s3 import S3Storage
@ -18,6 +20,7 @@ class FlaskConfig(object):
SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884' SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884'
JSONIFY_PRETTYPRINT_REGULAR = False JSONIFY_PRETTYPRINT_REGULAR = False
class FlaskProdConfig(FlaskConfig): class FlaskProdConfig(FlaskConfig):
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@ -44,7 +47,8 @@ class RealTransactions(object):
class SQLiteDB(RealTransactions): class SQLiteDB(RealTransactions):
DB_NAME = 'test/data/test.db' DB_NAME = 'test/data/test.db'
DB_CONNECTION_ARGS = { DB_CONNECTION_ARGS = {
'threadlocals': True 'threadlocals': True,
'autorollback': True,
} }
DB_DRIVER = SqliteDatabase DB_DRIVER = SqliteDatabase
@ -76,6 +80,7 @@ class RDSMySQL(RealTransactions):
'user': 'fluxmonkey', 'user': 'fluxmonkey',
'passwd': '8eifM#uoZ85xqC^', 'passwd': '8eifM#uoZ85xqC^',
'threadlocals': True, 'threadlocals': True,
'autorollback': True,
} }
DB_DRIVER = MySQLDatabase DB_DRIVER = MySQLDatabase
@ -154,6 +159,11 @@ class GitHubTestConfig(object):
GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails' GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails'
class GitHubStagingConfig(GitHubTestConfig):
GITHUB_CLIENT_ID = '4886304accbc444f0471'
GITHUB_CLIENT_SECRET = '27d8a5d99af02dda821eb10883bcb2e785e70a62'
class GitHubProdConfig(GitHubTestConfig): class GitHubProdConfig(GitHubTestConfig):
GITHUB_CLIENT_ID = '5a8c08b06c48d89d4d1e' GITHUB_CLIENT_ID = '5a8c08b06c48d89d4d1e'
GITHUB_CLIENT_SECRET = 'f89d8bb28ea3bd4e1c68808500d185a816be53b1' GITHUB_CLIENT_SECRET = 'f89d8bb28ea3bd4e1c68808500d185a816be53b1'
@ -185,36 +195,79 @@ def logs_init_builder(level=logging.DEBUG,
return init_logs return init_logs
def build_requests_session():
sess = requests.Session()
adapter = requests.adapters.HTTPAdapter(pool_connections=100,
pool_maxsize=100)
sess.mount('http://', adapter)
sess.mount('https://', adapter)
return sess
class LargePoolHttpClient(object):
HTTPCLIENT = build_requests_session()
class StatusTagConfig(object):
STATUS_TAGS = {}
for tag_name in ['building', 'failed', 'none', 'ready']:
tag_path = os.path.join('buildstatus', tag_name + '.svg')
with open(tag_path) as tag_svg:
STATUS_TAGS[tag_name] = tag_svg.read()
class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles, class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
FakeAnalytics, StripeTestConfig, RedisBuildLogs, FakeAnalytics, StripeTestConfig, RedisBuildLogs,
UserEventConfig): UserEventConfig, LargePoolHttpClient, StatusTagConfig):
LOGGING_CONFIG = logs_init_builder(logging.WARN) LOGGING_CONFIG = logs_init_builder(logging.WARN)
POPULATE_DB_TEST_DATA = True POPULATE_DB_TEST_DATA = True
TESTING = True TESTING = True
URL_SCHEME = 'http'
URL_HOST = 'localhost:5000'
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB, class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig, StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
DigitalOceanConfig, BuildNodeConfig, S3Userfiles, DigitalOceanConfig, BuildNodeConfig, S3Userfiles,
UserEventConfig, TestBuildLogs): UserEventConfig, TestBuildLogs, LargePoolHttpClient,
StatusTagConfig):
LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter()) LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter())
SEND_FILE_MAX_AGE_DEFAULT = 0 SEND_FILE_MAX_AGE_DEFAULT = 0
POPULATE_DB_TEST_DATA = True POPULATE_DB_TEST_DATA = True
URL_SCHEME = 'http'
URL_HOST = 'ci.devtable.com:5000'
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
StripeLiveConfig, MixpanelTestConfig, StripeLiveConfig, MixpanelTestConfig,
GitHubProdConfig, DigitalOceanConfig, GitHubProdConfig, DigitalOceanConfig,
BuildNodeConfig, S3Userfiles, RedisBuildLogs, BuildNodeConfig, S3Userfiles, RedisBuildLogs,
UserEventConfig): UserEventConfig, LargePoolHttpClient,
LOGGING_CONFIG = logs_init_builder() StatusTagConfig):
LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter())
SEND_FILE_MAX_AGE_DEFAULT = 0 SEND_FILE_MAX_AGE_DEFAULT = 0
URL_SCHEME = 'http'
URL_HOST = 'ci.devtable.com:5000'
class StagingConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL,
StripeLiveConfig, MixpanelProdConfig,
GitHubStagingConfig, DigitalOceanConfig, BuildNodeConfig,
S3Userfiles, RedisBuildLogs, UserEventConfig,
LargePoolHttpClient, StatusTagConfig):
LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter())
SEND_FILE_MAX_AGE_DEFAULT = 0
URL_SCHEME = 'https'
URL_HOST = 'staging.quay.io'
class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL, class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL,
StripeLiveConfig, MixpanelProdConfig, StripeLiveConfig, MixpanelProdConfig,
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig, GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
S3Userfiles, RedisBuildLogs, UserEventConfig): S3Userfiles, RedisBuildLogs, UserEventConfig,
LargePoolHttpClient, StatusTagConfig):
LOGGING_CONFIG = logs_init_builder() LOGGING_CONFIG = logs_init_builder()
SEND_FILE_MAX_AGE_DEFAULT = 0 SEND_FILE_MAX_AGE_DEFAULT = 0
URL_SCHEME = 'https'
URL_HOST = 'quay.io'

View file

@ -40,9 +40,12 @@ class BuildLogs(object):
Returns a tuple of the current length of the list and an iterable of the Returns a tuple of the current length of the list and an iterable of the
requested log entries. requested log entries.
""" """
llen = self._redis.llen(self._logs_key(build_id)) try:
log_entries = self._redis.lrange(self._logs_key(build_id), start_index, -1) llen = self._redis.llen(self._logs_key(build_id))
return (llen, (json.loads(entry) for entry in log_entries)) log_entries = self._redis.lrange(self._logs_key(build_id), start_index, -1)
return (llen, (json.loads(entry) for entry in log_entries))
except redis.ConnectionError:
return (0, [])
@staticmethod @staticmethod
def _status_key(build_id): def _status_key(build_id):
@ -59,5 +62,9 @@ class BuildLogs(object):
""" """
Loads the status information for the specified build id. Loads the status information for the specified build id.
""" """
fetched = self._redis.get(self._status_key(build_id)) try:
fetched = self._redis.get(self._status_key(build_id))
except redis.ConnectionError:
return None
return json.loads(fetched) if fetched else None return json.loads(fetched) if fetched else None

View file

@ -101,6 +101,7 @@ class Repository(BaseModel):
name = CharField() name = CharField()
visibility = ForeignKeyField(Visibility) visibility = ForeignKeyField(Visibility)
description = TextField(null=True) description = TextField(null=True)
badge_token = CharField(default=uuid_generator)
class Meta: class Meta:
database = db database = db
@ -163,6 +164,20 @@ class AccessToken(BaseModel):
temporary = BooleanField(default=True) temporary = BooleanField(default=True)
class BuildTriggerService(BaseModel):
name = CharField(index=True)
class RepositoryBuildTrigger(BaseModel):
uuid = CharField(default=uuid_generator)
service = ForeignKeyField(BuildTriggerService, index=True)
repository = ForeignKeyField(Repository, index=True)
connected_user = ForeignKeyField(User)
auth_token = CharField()
config = TextField(default='{}')
write_token = ForeignKeyField(AccessToken, null=True)
class EmailConfirmation(BaseModel): class EmailConfirmation(BaseModel):
code = CharField(default=random_string_generator(), unique=True, index=True) code = CharField(default=random_string_generator(), unique=True, index=True)
user = ForeignKeyField(User) user = ForeignKeyField(User)
@ -223,11 +238,12 @@ class RepositoryBuild(BaseModel):
uuid = CharField(default=uuid_generator, index=True) uuid = CharField(default=uuid_generator, index=True)
repository = ForeignKeyField(Repository, index=True) repository = ForeignKeyField(Repository, index=True)
access_token = ForeignKeyField(AccessToken) access_token = ForeignKeyField(AccessToken)
resource_key = CharField() resource_key = CharField(index=True)
tag = CharField() job_config = TextField()
phase = CharField(default='waiting') phase = CharField(default='waiting')
started = DateTimeField(default=datetime.now) started = DateTimeField(default=datetime.now)
display_name = CharField() display_name = CharField()
trigger = ForeignKeyField(RepositoryBuildTrigger, null=True, index=True)
class QueueItem(BaseModel): class QueueItem(BaseModel):
@ -255,8 +271,52 @@ class LogEntry(BaseModel):
metadata_json = TextField(default='{}') metadata_json = TextField(default='{}')
all_models = [User, Repository, Image, AccessToken, Role, class OAuthApplication(BaseModel):
RepositoryPermission, Visibility, RepositoryTag, client_id = CharField(index=True, default=random_string_generator(length=20))
EmailConfirmation, FederatedLogin, LoginService, QueueItem, client_secret = CharField(default=random_string_generator(length=40))
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, redirect_uri = CharField()
LogEntryKind, LogEntry, PermissionPrototype, ImageStorage] application_uri = CharField()
organization = ForeignKeyField(User)
name = CharField()
description = TextField(default='')
gravatar_email = CharField(null=True)
class OAuthAuthorizationCode(BaseModel):
application = ForeignKeyField(OAuthApplication)
code = CharField(index=True)
scope = CharField()
data = TextField() # Context for the code, such as the user
class OAuthAccessToken(BaseModel):
uuid = CharField(default=uuid_generator, index=True)
application = ForeignKeyField(OAuthApplication)
authorized_user = ForeignKeyField(User)
scope = CharField()
access_token = CharField(index=True)
token_type = CharField(default='Bearer')
expires_at = DateTimeField()
refresh_token = CharField(index=True, null=True)
data = TextField() # This is context for which this token was generated, such as the user
class NotificationKind(BaseModel):
name = CharField(index=True)
class Notification(BaseModel):
uuid = CharField(default=uuid_generator, index=True)
kind = ForeignKeyField(NotificationKind, index=True)
target = ForeignKeyField(User, index=True)
metadata_json = TextField(default='{}')
created = DateTimeField(default=datetime.now, index=True)
all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility,
RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry,
PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger,
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
Notification]

1
data/model/__init__.py Normal file
View file

@ -0,0 +1 @@
from data.model.legacy import *

View file

@ -2,11 +2,10 @@ import bcrypt
import logging import logging
import datetime import datetime
import dateutil.parser import dateutil.parser
import operator
import json import json
from database import * from data.database import *
from util.validation import * from util.validation import *
from util.names import format_robot_username from util.names import format_robot_username
@ -55,7 +54,11 @@ class InvalidWebhookException(DataModelException):
pass pass
def create_user(username, password, email): class InvalidBuildTriggerException(DataModelException):
pass
def create_user(username, password, email, is_organization=False):
if not validate_email(email): if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email) raise InvalidEmailAddressException('Invalid email address: %s' % email)
if not validate_username(username): if not validate_username(username):
@ -89,6 +92,12 @@ def create_user(username, password, email):
new_user = User.create(username=username, password_hash=pw_hash, new_user = User.create(username=username, password_hash=pw_hash,
email=email) email=email)
# If the password is None, then add a notification for the user to change
# their password ASAP.
if not pw_hash and not is_organization:
create_notification('password_required', new_user)
return new_user return new_user
except Exception as ex: except Exception as ex:
raise DataModelException(ex.message) raise DataModelException(ex.message)
@ -97,7 +106,7 @@ def create_user(username, password, email):
def create_organization(name, email, creating_user): def create_organization(name, email, creating_user):
try: try:
# Create the org # Create the org
new_org = create_user(name, None, email) new_org = create_user(name, None, email, is_organization=True)
new_org.organization = True new_org.organization = True
new_org.save() new_org.save()
@ -546,9 +555,9 @@ def get_visible_repository_count(username=None, include_public=True,
def get_visible_repositories(username=None, include_public=True, page=None, def get_visible_repositories(username=None, include_public=True, page=None,
limit=None, sort=False, namespace=None): limit=None, sort=False, namespace=None):
query = _visible_repository_query(username=username, query = _visible_repository_query(username=username, include_public=include_public, page=page,
include_public=include_public, page=page, limit=limit, namespace=namespace,
limit=limit, namespace=namespace) select_models=[Repository, Visibility])
if sort: if sort:
query = query.order_by(Repository.description.desc()) query = query.order_by(Repository.description.desc())
@ -560,9 +569,9 @@ def get_visible_repositories(username=None, include_public=True, page=None,
def _visible_repository_query(username=None, include_public=True, limit=None, def _visible_repository_query(username=None, include_public=True, limit=None,
page=None, namespace=None): page=None, namespace=None, select_models=[]):
query = (Repository query = (Repository
.select() # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains. .select(*select_models) # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains.
.distinct() .distinct()
.join(Visibility) .join(Visibility)
.switch(Repository) .switch(Repository)
@ -658,6 +667,9 @@ def change_password(user, new_password):
user.password_hash = pw_hash user.password_hash = pw_hash
user.save() user.save()
# Remove any password required notifications for the user.
delete_notifications_by_kind(user, 'password_required')
def change_invoice_email(user, invoice_email): def change_invoice_email(user, invoice_email):
user.invoice_email = invoice_email user.invoice_email = invoice_email
@ -771,14 +783,15 @@ def get_all_repo_users(namespace_name, repository_name):
def get_repository_for_resource(resource_key): def get_repository_for_resource(resource_key):
joined = Repository.select().join(RepositoryBuild) try:
query = joined.where(RepositoryBuild.resource_key == resource_key).limit(1) return (Repository
result = list(query) .select()
if not result: .join(RepositoryBuild)
.where(RepositoryBuild.resource_key == resource_key)
.get())
except Repository.DoesNotExist:
return None return None
return result[0]
def get_repository(namespace_name, repository_name): def get_repository(namespace_name, repository_name):
try: try:
@ -1284,7 +1297,10 @@ def set_user_repo_permission(username, namespace_name, repository_name,
if username == namespace_name: if username == namespace_name:
raise DataModelException('Namespace owner must always be admin.') raise DataModelException('Namespace owner must always be admin.')
user = User.get(User.username == username) try:
user = User.get(User.username == username)
except User.DoesNotExist:
raise InvalidUsernameException('Invalid username: %s' % username)
return __set_entity_repo_permission(user, 'user', namespace_name, return __set_entity_repo_permission(user, 'user', namespace_name,
repository_name, role_name) repository_name, role_name)
@ -1327,8 +1343,9 @@ def create_access_token(repository, role):
return new_token return new_token
def create_delegate_token(namespace_name, repository_name, friendly_name): def create_delegate_token(namespace_name, repository_name, friendly_name,
read_only = Role.get(name='read') role='read'):
read_only = Role.get(name=role)
repo = Repository.get(Repository.name == repository_name, repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name) Repository.namespace == namespace_name)
new_token = AccessToken.create(repository=repo, role=read_only, new_token = AccessToken.create(repository=repo, role=read_only,
@ -1388,35 +1405,49 @@ def load_token_data(code):
def get_repository_build(namespace_name, repository_name, build_uuid): def get_repository_build(namespace_name, repository_name, build_uuid):
joined = RepositoryBuild.select().join(Repository) try:
fetched = list(joined.where(Repository.name == repository_name, query = list_repository_builds(namespace_name, repository_name, 1)
Repository.namespace == namespace_name, return query.where(RepositoryBuild.uuid == build_uuid).get()
RepositoryBuild.uuid == build_uuid))
if not fetched: except RepositoryBuild.DoesNotExist:
msg = 'Unable to locate a build by id: %s' % build_uuid msg = 'Unable to locate a build by id: %s' % build_uuid
raise InvalidRepositoryBuildException(msg) raise InvalidRepositoryBuildException(msg)
return fetched[0]
def list_repository_builds(namespace_name, repository_name, limit,
def list_repository_builds(namespace_name, repository_name,
include_inactive=True): include_inactive=True):
joined = RepositoryBuild.select().join(Repository) query = (RepositoryBuild
filtered = joined .select(RepositoryBuild, RepositoryBuildTrigger, BuildTriggerService)
.join(Repository)
.switch(RepositoryBuild)
.join(RepositoryBuildTrigger, JOIN_LEFT_OUTER)
.join(BuildTriggerService, JOIN_LEFT_OUTER)
.where(Repository.name == repository_name,
Repository.namespace == namespace_name)
.order_by(RepositoryBuild.started.desc())
.limit(limit))
if not include_inactive: if not include_inactive:
filtered = filtered.where(RepositoryBuild.phase != 'error', query = query.where(RepositoryBuild.phase != 'error',
RepositoryBuild.phase != 'complete') RepositoryBuild.phase != 'complete')
fetched = list(filtered.where(Repository.name == repository_name,
Repository.namespace == namespace_name)) return query
return fetched
def create_repository_build(repo, access_token, resource_key, tag, def get_recent_repository_build(namespace_name, repository_name):
display_name): query = list_repository_builds(namespace_name, repository_name, 1)
try:
return query.get()
except RepositoryBuild.DoesNotExist:
return None
def create_repository_build(repo, access_token, job_config_obj, dockerfile_id,
display_name, trigger=None):
return RepositoryBuild.create(repository=repo, access_token=access_token, return RepositoryBuild.create(repository=repo, access_token=access_token,
resource_key=resource_key, tag=tag, job_config=json.dumps(job_config_obj),
display_name=display_name) display_name=display_name, trigger=trigger,
resource_key=dockerfile_id)
def create_webhook(repo, params_obj): def create_webhook(repo, params_obj):
@ -1446,26 +1477,115 @@ def delete_webhook(namespace_name, repository_name, public_id):
webhook.delete_instance() webhook.delete_instance()
return webhook return webhook
def list_logs(user_or_organization_name, start_time, end_time, performer = None, repository = None):
joined = LogEntry.select().join(User)
if repository:
joined = joined.where(LogEntry.repository == repository)
if performer: def list_logs(user_or_organization_name, start_time, end_time, performer=None,
joined = joined.where(LogEntry.performer == performer) repository=None):
joined = LogEntry.select().join(User)
if repository:
joined = joined.where(LogEntry.repository == repository)
return joined.where( if performer:
User.username == user_or_organization_name, joined = joined.where(LogEntry.performer == performer)
LogEntry.datetime >= start_time,
LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc())
def log_action(kind_name, user_or_organization_name, performer=None, repository=None, return joined.where(
access_token=None, ip=None, metadata={}, timestamp=None): User.username == user_or_organization_name,
LogEntry.datetime >= start_time,
LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc())
def log_action(kind_name, user_or_organization_name, performer=None,
repository=None, access_token=None, ip=None, metadata={},
timestamp=None):
if not timestamp: if not timestamp:
timestamp = datetime.today() timestamp = datetime.today()
kind = LogEntryKind.get(LogEntryKind.name == kind_name) kind = LogEntryKind.get(LogEntryKind.name == kind_name)
account = User.get(User.username == user_or_organization_name) account = User.get(User.username == user_or_organization_name)
entry = LogEntry.create(kind=kind, account=account, performer=performer, LogEntry.create(kind=kind, account=account, performer=performer,
repository=repository, access_token=access_token, ip=ip, repository=repository, access_token=access_token, ip=ip,
metadata_json=json.dumps(metadata), datetime=timestamp) metadata_json=json.dumps(metadata), datetime=timestamp)
def create_build_trigger(repo, service_name, auth_token, user):
service = BuildTriggerService.get(name=service_name)
trigger = RepositoryBuildTrigger.create(repository=repo, service=service,
auth_token=auth_token,
connected_user=user)
return trigger
def get_build_trigger(namespace_name, repository_name, trigger_uuid):
try:
return (RepositoryBuildTrigger
.select(RepositoryBuildTrigger, BuildTriggerService, Repository)
.join(BuildTriggerService)
.switch(RepositoryBuildTrigger)
.join(Repository)
.switch(RepositoryBuildTrigger)
.join(User)
.where(RepositoryBuildTrigger.uuid == trigger_uuid,
Repository.namespace == namespace_name,
Repository.name == repository_name)
.get())
except RepositoryBuildTrigger.DoesNotExist:
msg = 'No build trigger with uuid: %s' % trigger_uuid
raise InvalidBuildTriggerException(msg)
def list_build_triggers(namespace_name, repository_name):
return (RepositoryBuildTrigger
.select(RepositoryBuildTrigger, BuildTriggerService, Repository)
.join(BuildTriggerService)
.switch(RepositoryBuildTrigger)
.join(Repository)
.where(Repository.namespace == namespace_name,
Repository.name == repository_name))
def list_trigger_builds(namespace_name, repository_name, trigger_uuid,
limit):
return (list_repository_builds(namespace_name, repository_name, limit)
.where(RepositoryBuildTrigger.uuid == trigger_uuid))
def create_notification(kind, target, metadata={}):
kind_ref = NotificationKind.get(name=kind)
notification = Notification.create(kind=kind_ref, target=target,
metadata_json=json.dumps(metadata))
return notification
def list_notifications(user, kind=None):
Org = User.alias()
AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias()
AdminUser = User.alias()
query = (Notification.select()
.join(User)
.switch(Notification)
.join(Org, JOIN_LEFT_OUTER, on=(Org.id == Notification.target))
.join(AdminTeam, JOIN_LEFT_OUTER, on=(Org.id ==
AdminTeam.organization))
.join(TeamRole, JOIN_LEFT_OUTER, on=(AdminTeam.role == TeamRole.id))
.switch(AdminTeam)
.join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id ==
AdminTeamMember.team))
.join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user ==
AdminUser.id)))
where_clause = ((Notification.target == user) |
((AdminUser.id == user) &
(TeamRole.name == 'admin')))
if kind:
where_clause = where_clause & (NotificationKind.name == kind)
return query.where(where_clause).order_by(Notification.created).desc()
def delete_notifications_by_kind(target, kind):
kind_ref = NotificationKind.get(name=kind)
Notification.delete().where(Notification.target == target,
Notification.kind == kind_ref).execute()

281
data/model/oauth.py Normal file
View file

@ -0,0 +1,281 @@
import logging
import json
from datetime import datetime, timedelta
from oauth2lib.provider import AuthorizationProvider
from oauth2lib import utils
from data.database import (OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, User,
random_string_generator)
from data.model.legacy import get_user
from auth import scopes
logger = logging.getLogger(__name__)
class DatabaseAuthorizationProvider(AuthorizationProvider):
def get_authorized_user(self):
raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.')
def _generate_data_string(self):
return json.dumps({'username': self.get_authorized_user().username})
@property
def token_expires_in(self):
"""Property method to get the token expiration time in seconds.
"""
return int(60*60*24*365.25*10) # 10 Years
def validate_client_id(self, client_id):
return self.get_application_for_client_id(client_id) is not None
def get_application_for_client_id(self, client_id):
try:
return OAuthApplication.get(client_id=client_id)
except OAuthApplication.DoesNotExist:
return None
def validate_client_secret(self, client_id, client_secret):
try:
OAuthApplication.get(client_id=client_id, client_secret=client_secret)
return True
except OAuthApplication.DoesNotExist:
return False
def validate_redirect_uri(self, client_id, redirect_uri):
try:
app = OAuthApplication.get(client_id=client_id)
if app.redirect_uri and redirect_uri.startswith(app.redirect_uri):
return True
return False
except OAuthApplication.DoesNotExist:
return False
def validate_scope(self, client_id, scopes_string):
return scopes.validate_scope_string(scopes_string)
def validate_access(self):
return self.get_authorized_user() is not None
def load_authorized_scope_string(self, client_id, username):
found = (OAuthAccessToken
.select()
.join(OAuthApplication)
.switch(OAuthAccessToken)
.join(User)
.where(OAuthApplication.client_id == client_id, User.username == username,
OAuthAccessToken.expires_at > datetime.now()))
found = list(found)
logger.debug('Found %s matching tokens.', len(found))
long_scope_string = ','.join([token.scope for token in found])
logger.debug('Computed long scope string: %s', long_scope_string)
return long_scope_string
def validate_has_scopes(self, client_id, username, scope):
long_scope_string = self.load_authorized_scope_string(client_id, username)
# Make sure the token contains the given scopes (at least).
return scopes.is_subset_string(long_scope_string, scope)
def from_authorization_code(self, client_id, code, scope):
try:
found = (OAuthAuthorizationCode
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code,
OAuthAuthorizationCode.scope == scope)
.get())
logger.debug('Returning data: %s', found.data)
return found.data
except OAuthAuthorizationCode.DoesNotExist:
return None
def from_refresh_token(self, client_id, refresh_token, scope):
try:
found = (OAuthAccessToken
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id,
OAuthAccessToken.refresh_token == refresh_token,
OAuthAccessToken.scope == scope)
.get())
return found.data
except OAuthAccessToken.DoesNotExist:
return None
def persist_authorization_code(self, client_id, code, scope):
app = OAuthApplication.get(client_id=client_id)
data = self._generate_data_string()
OAuthAuthorizationCode.create(application=app, code=code, scope=scope, data=data)
def persist_token_information(self, client_id, scope, access_token, token_type, expires_in,
refresh_token, data):
user = get_user(json.loads(data)['username'])
if not user:
raise RuntimeError('Username must be in the data field')
app = OAuthApplication.get(client_id=client_id)
expires_at = datetime.now() + timedelta(seconds=expires_in)
OAuthAccessToken.create(application=app, authorized_user=user, scope=scope,
access_token=access_token, token_type=token_type,
expires_at=expires_at, refresh_token=refresh_token, data=data)
def discard_authorization_code(self, client_id, code):
found = (OAuthAuthorizationCode
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code)
.get())
found.delete_instance()
def discard_refresh_token(self, client_id, refresh_token):
found = (AccessToken
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id,
OAuthAccessToken.refresh_token == refresh_token)
.get())
found.delete_instance()
def get_auth_denied_response(self, response_type, client_id, redirect_uri, **params):
# Ensure proper response_type
if response_type != 'token':
err = 'unsupported_response_type'
return self._make_redirect_error_response(redirect_uri, err)
# Check redirect URI
is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri)
if not is_valid_redirect_uri:
return self._invalid_redirect_uri_response()
return self._make_redirect_error_response(redirect_uri, 'authorization_denied')
def get_token_response(self, response_type, client_id, redirect_uri, **params):
# Ensure proper response_type
if response_type != 'token':
err = 'unsupported_response_type'
return self._make_redirect_error_response(redirect_uri, err)
# Check redirect URI
is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri)
if not is_valid_redirect_uri:
return self._invalid_redirect_uri_response()
# Check conditions
is_valid_client_id = self.validate_client_id(client_id)
is_valid_access = self.validate_access()
scope = params.get('scope', '')
are_valid_scopes = self.validate_scope(client_id, scope)
# Return proper error responses on invalid conditions
if not is_valid_client_id:
err = 'unauthorized_client'
return self._make_redirect_error_response(redirect_uri, err)
if not is_valid_access:
err = 'access_denied'
return self._make_redirect_error_response(redirect_uri, err)
if not are_valid_scopes:
err = 'invalid_scope'
return self._make_redirect_error_response(redirect_uri, err)
access_token = self.generate_access_token()
token_type = self.token_type
expires_in = self.token_expires_in
refresh_token = None # No refresh token for this kind of flow
data = self._generate_data_string()
self.persist_token_information(client_id=client_id, scope=scope, access_token=access_token,
token_type=token_type, expires_in=expires_in,
refresh_token=refresh_token, data=data)
url = utils.build_url(redirect_uri, params)
url += '#access_token=%s&token_type=%s&expires_in=%s' % (access_token, token_type, expires_in)
return self._make_response(headers={'Location': url}, status_code=302)
def create_application(org, name, application_uri, redirect_uri, **kwargs):
return OAuthApplication.create(organization=org, name=name, application_uri=application_uri,
redirect_uri=redirect_uri, **kwargs)
def validate_access_token(access_token):
try:
found = (OAuthAccessToken
.select(OAuthAccessToken, User)
.join(User)
.where(OAuthAccessToken.access_token == access_token)
.get())
return found
except OAuthAccessToken.DoesNotExist:
return None
def get_application_for_client_id(client_id):
try:
return OAuthApplication.get(client_id=client_id)
except OAuthApplication.DoesNotExist:
return None
def reset_client_secret(application):
application.client_secret = random_string_generator(length=40)()
application.save()
return application
def lookup_application(org, client_id):
try:
return OAuthApplication.get(organization = org, client_id=client_id)
except OAuthApplication.DoesNotExist:
return None
def delete_application(org, client_id):
application = lookup_application(org, client_id)
if not application:
return
application.delete_instance(recursive=True, delete_nullable=True)
return application
def lookup_access_token_for_user(user, token_uuid):
try:
return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user,
OAuthAccessToken.uuid == token_uuid)
except OAuthAccessToken.DoesNotExist:
return None
def list_access_tokens_for_user(user):
query = (OAuthAccessToken
.select()
.join(OAuthApplication)
.switch(OAuthAccessToken)
.join(User)
.where(OAuthAccessToken.authorized_user == user))
return query
def list_applications_for_org(org):
query = (OAuthApplication
.select()
.join(User)
.where(OAuthApplication.organization == org))
return query
def create_access_token_for_testing(user, client_id, scope):
expires_at = datetime.now() + timedelta(seconds=10000)
application = get_application_for_client_id(client_id)
OAuthAccessToken.create(application=application, authorized_user=user, scope=scope,
token_type='token', access_token='test',
expires_at=expires_at, refresh_token='', data='')

View file

@ -68,5 +68,5 @@ class WorkQueue(object):
image_diff_queue = WorkQueue('imagediff') image_diff_queue = WorkQueue('imagediff')
dockerfile_build_queue = WorkQueue('dockerfilebuild2') dockerfile_build_queue = WorkQueue('dockerfilebuild3')
webhook_queue = WorkQueue('webhook') webhook_queue = WorkQueue('webhook')

View file

@ -40,14 +40,15 @@ class UserRequestFiles(object):
encrypt_key=True) encrypt_key=True)
return (url, file_id) return (url, file_id)
def store_file(self, flask_file): def store_file(self, file_like_obj, content_type):
self._initialize_s3() self._initialize_s3()
file_id = str(uuid4()) file_id = str(uuid4())
full_key = os.path.join(self._prefix, file_id) full_key = os.path.join(self._prefix, file_id)
k = Key(self._bucket, full_key) k = Key(self._bucket, full_key)
logger.debug('Setting s3 content type to: %s' % flask_file.content_type) logger.debug('Setting s3 content type to: %s' % content_type)
k.set_metadata('Content-Type', flask_file.content_type) k.set_metadata('Content-Type', content_type)
bytes_written = k.set_contents_from_file(flask_file, encrypt_key=True) bytes_written = k.set_contents_from_file(file_like_obj, encrypt_key=True,
rewind=True)
if bytes_written == 0: if bytes_written == 0:
raise S3FileWriteException('Unable to write file to S3') raise S3FileWriteException('Unable to write file to S3')

File diff suppressed because it is too large Load diff

281
endpoints/api/__init__.py Normal file
View file

@ -0,0 +1,281 @@
import logging
import json
from flask import Blueprint, request, make_response, jsonify
from flask.ext.restful import Resource, abort, Api, reqparse
from flask.ext.restful.utils.cors import crossdomain
from werkzeug.exceptions import HTTPException
from calendar import timegm
from email.utils import formatdate
from functools import partial, wraps
from jsonschema import validate, ValidationError
from data import model
from util.names import parse_namespace_repository
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission, UserReadPermission,
UserAdminPermission)
from auth import scopes
from auth.auth_context import get_authenticated_user, get_validated_oauth_token
from auth.auth import process_oauth
from endpoints.csrf import csrf_protect
logger = logging.getLogger(__name__)
api_bp = Blueprint('api', __name__)
api = Api()
api.init_app(api_bp)
api.decorators = [csrf_protect,
process_oauth,
crossdomain(origin='*', headers=['Authorization', 'Content-Type'])]
class ApiException(Exception):
def __init__(self, error_type, status_code, error_description, payload=None):
Exception.__init__(self)
self.error_description = error_description
self.status_code = status_code
self.payload = payload
self.error_type = error_type
def to_dict(self):
rv = dict(self.payload or ())
if self.error_description is not None:
rv['error_description'] = self.error_description
rv['error_type'] = self.error_type
return rv
class InvalidRequest(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'invalid_request', 400, error_description, payload)
class InvalidToken(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, 'invalid_token', 401, error_description, payload)
class Unauthorized(ApiException):
def __init__(self, payload=None):
user = get_authenticated_user()
if user is None or user.organization:
ApiException.__init__(self, 'invalid_token', 401, "Requires authentication", payload)
else:
ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload)
class NotFound(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, None, 404, 'Not Found', payload)
@api_bp.app_errorhandler(ApiException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_api_error(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
if error.error_type is not None:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %
(error.error_type, error.error_description))
return response
def resource(*urls, **kwargs):
def wrapper(api_resource):
api.add_resource(api_resource, *urls, **kwargs)
return api_resource
return wrapper
def truthy_bool(param):
return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'}
def format_date(date):
""" Output an RFC822 date format. """
if date is None:
return None
return formatdate(timegm(date.utctimetuple()))
def add_method_metadata(name, value):
def modifier(func):
if '__api_metadata' not in dir(func):
func.__api_metadata = {}
func.__api_metadata[name] = value
return func
return modifier
def method_metadata(func, name):
if '__api_metadata' in dir(func):
return func.__api_metadata.get(name, None)
return None
nickname = partial(add_method_metadata, 'nickname')
related_user_resource = partial(add_method_metadata, 'related_user_resource')
internal_only = add_method_metadata('internal', True)
def query_param(name, help_str, type=reqparse.text_type, default=None,
choices=(), required=False):
def add_param(func):
if '__api_query_params' not in dir(func):
func.__api_query_params = []
func.__api_query_params.append({
'name': name,
'type': type,
'help': help_str,
'default': default,
'choices': choices,
'required': required,
})
return func
return add_param
def parse_args(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if '__api_query_params' not in dir(func):
abort(500)
parser = reqparse.RequestParser()
for arg_spec in func.__api_query_params:
parser.add_argument(**arg_spec)
parsed_args = parser.parse_args()
return func(self, parsed_args, *args, **kwargs)
return wrapper
def parse_repository_name(func):
@wraps(func)
def wrapper(repository, *args, **kwargs):
(namespace, repository) = parse_namespace_repository(repository)
return func(namespace, repository, *args, **kwargs)
return wrapper
class ApiResource(Resource):
def options(self):
return None, 200
class RepositoryParamResource(ApiResource):
method_decorators = [parse_repository_name]
def require_repo_permission(permission_class, scope, allow_public=False):
def wrapper(func):
@add_method_metadata('oauth2_scope', scope)
@wraps(func)
def wrapped(self, namespace, repository, *args, **kwargs):
logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace,
repository)
permission = permission_class(namespace, repository)
if (permission.can() or
(allow_public and
model.repository_is_public(namespace, repository))):
return func(self, namespace, repository, *args, **kwargs)
raise Unauthorized()
return wrapped
return wrapper
require_repo_read = require_repo_permission(ReadRepositoryPermission, scopes.READ_REPO, True)
require_repo_write = require_repo_permission(ModifyRepositoryPermission, scopes.WRITE_REPO)
require_repo_admin = require_repo_permission(AdministerRepositoryPermission, scopes.ADMIN_REPO)
def require_user_permission(permission_class, scope=None):
def wrapper(func):
@add_method_metadata('oauth2_scope', scope)
@wraps(func)
def wrapped(self, *args, **kwargs):
user = get_authenticated_user()
if not user:
raise Unauthorized()
logger.debug('Checking permission %s for user %s', permission_class, user.username)
permission = permission_class(user.username)
if permission.can():
return func(self, *args, **kwargs)
raise Unauthorized()
return wrapped
return wrapper
require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER)
require_user_admin = require_user_permission(UserAdminPermission, None)
def require_scope(scope_object):
def wrapper(func):
@add_method_metadata('oauth2_scope', scope_object)
@wraps(func)
def wrapped(*args, **kwargs):
return func(*args, **kwargs)
return wrapped
return wrapper
def validate_json_request(schema_name):
def wrapper(func):
@add_method_metadata('request_schema', schema_name)
@wraps(func)
def wrapped(self, *args, **kwargs):
schema = self.schemas[schema_name]
try:
validate(request.get_json(), schema)
return func(self, *args, **kwargs)
except ValidationError as ex:
raise InvalidRequest(ex.message)
return wrapped
return wrapper
def request_error(exception=None, **kwargs):
data = kwargs.copy()
message = 'Request error.'
if exception:
message = exception.message
raise InvalidRequest(message, data)
def log_action(kind, user_or_orgname, metadata=None, repo=None):
if not metadata:
metadata = {}
oauth_token = get_validated_oauth_token()
if oauth_token:
metadata['oauth_token_id'] = oauth_token.id
metadata['oauth_token_application_id'] = oauth_token.application.client_id
metadata['oauth_token_application'] = oauth_token.application.name
performer = get_authenticated_user()
model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr,
metadata=metadata, repository=repo)
import endpoints.api.billing
import endpoints.api.build
import endpoints.api.discovery
import endpoints.api.image
import endpoints.api.logs
import endpoints.api.organization
import endpoints.api.permission
import endpoints.api.prototype
import endpoints.api.repository
import endpoints.api.repotoken
import endpoints.api.robot
import endpoints.api.search
import endpoints.api.tag
import endpoints.api.team
import endpoints.api.trigger
import endpoints.api.user
import endpoints.api.webhook

326
endpoints/api/billing.py Normal file
View file

@ -0,0 +1,326 @@
import stripe
from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin)
from endpoints.api.subscribe import subscribe, subscription_view
from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model
from data.plans import PLANS
def carderror_response(e):
return {'carderror': e.message}, 402
def get_card(user):
card_info = {
'is_valid': False
}
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus and cus.default_card:
# Find the default card.
default_card = None
for card in cus.cards.data:
if card.id == cus.default_card:
default_card = card
break
if default_card:
card_info = {
'owner': default_card.name,
'type': default_card.type,
'last4': default_card.last4
}
return {'card': card_info}
def set_card(user, token):
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus:
try:
cus.card = token
cus.save()
except stripe.CardError as exc:
return carderror_response(exc)
except stripe.InvalidRequestError as exc:
return carderror_response(exc)
return get_card(user)
def get_invoices(customer_id):
def invoice_view(i):
return {
'id': i.id,
'date': i.date,
'period_start': i.period_start,
'period_end': i.period_end,
'paid': i.paid,
'amount_due': i.amount_due,
'next_payment_attempt': i.next_payment_attempt,
'attempted': i.attempted,
'closed': i.closed,
'total': i.total,
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
}
invoices = stripe.Invoice.all(customer=customer_id, count=12)
return {
'invoices': [invoice_view(i) for i in invoices.data]
}
@resource('/v1/plans/')
class ListPlans(ApiResource):
""" Resource for listing the available plans. """
@nickname('listPlans')
def get(self):
""" List the avaialble plans. """
return {
'plans': PLANS,
}
@resource('/v1/user/card')
@internal_only
class UserCard(ApiResource):
""" Resource for managing a user's credit card. """
schemas = {
'UserCard': {
'id': 'UserCard',
'type': 'object',
'description': 'Description of a user card',
'required': [
'token',
],
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
},
},
}
@require_user_admin
@nickname('getUserCard')
def get(self):
""" Get the user's credit card. """
user = get_authenticated_user()
return get_card(user)
@require_user_admin
@nickname('setUserCard')
@validate_json_request('UserCard')
def post(self):
""" Update the user's credit card. """
user = get_authenticated_user()
token = request.get_json()['token']
response = set_card(user, token)
log_action('account_change_cc', user.username)
return response
@resource('/v1/organization/<orgname>/card')
@internal_only
@related_user_resource(UserCard)
class OrganizationCard(ApiResource):
""" Resource for managing an organization's credit card. """
schemas = {
'OrgCard': {
'id': 'OrgCard',
'type': 'object',
'description': 'Description of a user card',
'required': [
'token',
],
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
},
},
}
@nickname('getOrgCard')
def get(self, orgname):
""" Get the organization's credit card. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
return get_card(organization)
raise Unauthorized()
@nickname('setOrgCard')
@validate_json_request('OrgCard')
def post(self, orgname):
""" Update the orgnaization's credit card. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
token = request.get_json()['token']
response = set_card(organization, token)
log_action('account_change_cc', orgname)
return response
raise Unauthorized()
@resource('/v1/user/plan')
@internal_only
class UserPlan(ApiResource):
""" Resource for managing a user's subscription. """
schemas = {
'UserSubscription': {
'id': 'UserSubscription',
'type': 'object',
'description': 'Description of a user card',
'required': [
'plan',
],
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
'plan': {
'type': 'string',
'description': 'Plan name to which the user wants to subscribe',
},
},
},
}
@require_user_admin
@nickname('updateUserSubscription')
@validate_json_request('UserSubscription')
def put(self):
""" Create or update the user's subscription. """
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
user = get_authenticated_user()
return subscribe(user, plan, token, False) # Business features not required
@require_user_admin
@nickname('getUserSubscription')
def get(self):
""" Fetch any existing subscription for the user. """
user = get_authenticated_user()
private_repos = model.get_private_repo_count(user.username)
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
return subscription_view(cus.subscription, private_repos)
return {
'plan': 'free',
'usedPrivateRepos': private_repos,
}
@resource('/v1/organization/<orgname>/plan')
@internal_only
@related_user_resource(UserPlan)
class OrganizationPlan(ApiResource):
""" Resource for managing a org's subscription. """
schemas = {
'OrgSubscription': {
'id': 'OrgSubscription',
'type': 'object',
'description': 'Description of a user card',
'required': [
'plan',
],
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
'plan': {
'type': 'string',
'description': 'Plan name to which the user wants to subscribe',
},
},
},
}
@nickname('updateOrgSubscription')
@validate_json_request('OrgSubscription')
def put(self, orgname):
""" Create or update the org's subscription. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
organization = model.get_organization(orgname)
return subscribe(organization, plan, token, True) # Business plan required
raise Unauthorized()
@nickname('getOrgSubscription')
def get(self, orgname):
""" Fetch any existing subscription for the org. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
private_repos = model.get_private_repo_count(orgname)
organization = model.get_organization(orgname)
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
return subscription_view(cus.subscription, private_repos)
return {
'plan': 'free',
'usedPrivateRepos': private_repos,
}
raise Unauthorized()
@resource('/v1/user/invoices')
@internal_only
class UserInvoiceList(ApiResource):
""" Resource for listing a user's invoices. """
@require_user_admin
@nickname('listUserInvoices')
def get(self):
""" List the invoices for the current user. """
user = get_authenticated_user()
if not user.stripe_id:
raise NotFound()
return get_invoices(user.stripe_id)
@resource('/v1/organization/<orgname>/invoices')
@internal_only
@related_user_resource(UserInvoiceList)
class OrgnaizationInvoiceList(ApiResource):
""" Resource for listing an orgnaization's invoices. """
@nickname('listOrgInvoices')
def get(self, orgname):
""" List the invoices for the specified orgnaization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
return get_invoices(organization.stripe_id)
raise Unauthorized()

213
endpoints/api/build.py Normal file
View file

@ -0,0 +1,213 @@
import logging
import json
from flask import request
from app import app
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
require_repo_read, require_repo_write, validate_json_request,
ApiResource, internal_only, format_date, api, Unauthorized, NotFound)
from endpoints.common import start_build
from endpoints.trigger import BuildTrigger
from data import model
from auth.permissions import ModifyRepositoryPermission
logger = logging.getLogger(__name__)
user_files = app.config['USERFILES']
build_logs = app.config['BUILDLOGS']
def get_trigger_config(trigger):
try:
return json.loads(trigger.config)
except:
return {}
def get_job_config(build_obj):
try:
return json.loads(build_obj.job_config)
except:
return None
def trigger_view(trigger):
if trigger and trigger.uuid:
config_dict = get_trigger_config(trigger)
build_trigger = BuildTrigger.get_trigger_for_service(trigger.service.name)
return {
'service': trigger.service.name,
'config': config_dict,
'id': trigger.uuid,
'connected_user': trigger.connected_user.username,
'is_active': build_trigger.is_active(config_dict)
}
return None
def build_status_view(build_obj, can_write=False):
status = build_logs.get_status(build_obj.uuid)
logger.debug('Can write: %s job_config: %s', can_write, build_obj.job_config)
resp = {
'id': build_obj.uuid,
'phase': build_obj.phase if status else 'cannot_load',
'started': format_date(build_obj.started),
'display_name': build_obj.display_name,
'status': status or {},
'job_config': get_job_config(build_obj) if can_write else None,
'is_writer': can_write,
'trigger': trigger_view(build_obj.trigger),
'resource_key': build_obj.resource_key,
}
if can_write:
resp['archive_url'] = user_files.get_file_url(build_obj.resource_key)
return resp
@resource('/v1/repository/<repopath:repository>/build/')
class RepositoryBuildList(RepositoryParamResource):
""" Resource related to creating and listing repository builds. """
schemas = {
'RepositoryBuildRequest': {
'id': 'RepositoryBuildRequest',
'type': 'object',
'description': 'Description of a new repository build.',
'required': [
'file_id',
],
'properties': {
'file_id': {
'type': 'string',
'description': 'The file id that was generated when the build spec was uploaded',
},
'subdirectory': {
'type': 'string',
'description': 'Subdirectory in which the Dockerfile can be found',
},
},
},
}
@require_repo_read
@parse_args
@query_param('limit', 'The maximum number of builds to return', type=int, default=5)
@nickname('getRepoBuilds')
def get(self, args, namespace, repository):
""" Get the list of repository builds. """
limit = args['limit']
builds = list(model.list_repository_builds(namespace, repository, limit))
can_write = ModifyRepositoryPermission(namespace, repository).can()
return {
'builds': [build_status_view(build, can_write) for build in builds]
}
@require_repo_write
@nickname('requestRepoBuild')
@validate_json_request('RepositoryBuildRequest')
def post(self, namespace, repository):
""" Request that a repository be built and pushed from the specified input. """
logger.debug('User requested repository initialization.')
request_json = request.get_json()
dockerfile_id = request_json['file_id']
subdir = request_json['subdirectory'] if 'subdirectory' in request_json else ''
# Check if the dockerfile resource has already been used. If so, then it
# can only be reused if the user has access to the repository for which it
# was used.
associated_repository = model.get_repository_for_resource(dockerfile_id)
if associated_repository:
if not ModifyRepositoryPermission(associated_repository.namespace,
associated_repository.name):
raise Unauthorized()
# Start the build.
repo = model.get_repository(namespace, repository)
display_name = user_files.get_file_checksum(dockerfile_id)
build_request = start_build(repo, dockerfile_id, ['latest'], display_name, subdir, True)
resp = build_status_view(build_request, True)
repo_string = '%s/%s' % (namespace, repository)
headers = {
'Location': api.url_for(RepositoryBuildStatus, repository=repo_string,
build_uuid=build_request.uuid),
}
return resp, 201, headers
@resource('/v1/repository/<repopath:repository>/build/<build_uuid>/status')
class RepositoryBuildStatus(RepositoryParamResource):
""" Resource for dealing with repository build status. """
@require_repo_read
@nickname('getRepoBuildStatus')
def get(self, namespace, repository, build_uuid):
""" Return the status for the builds specified by the build uuids. """
build = model.get_repository_build(namespace, repository, build_uuid)
if not build:
raise NotFound()
can_write = ModifyRepositoryPermission(namespace, repository).can()
return build_status_view(build, can_write)
@resource('/v1/repository/<repopath:repository>/build/<build_uuid>/logs')
class RepositoryBuildLogs(RepositoryParamResource):
""" Resource for loading repository build logs. """
@require_repo_write
@nickname('getRepoBuildLogs')
def get(self, namespace, repository, build_uuid):
""" Return the build logs for the build specified by the build uuid. """
response_obj = {}
build = model.get_repository_build(namespace, repository, build_uuid)
start = int(request.args.get('start', 0))
count, logs = build_logs.get_log_entries(build.uuid, start)
response_obj.update({
'start': start,
'total': count,
'logs': [log for log in logs],
})
return response_obj
@resource('/v1/filedrop/')
@internal_only
class FileDropResource(ApiResource):
""" Custom verb for setting up a client side file transfer. """
schemas = {
'FileDropRequest': {
'id': 'FileDropRequest',
'type': 'object',
'description': 'Description of the file that the user wishes to upload.',
'required': [
'mimeType',
],
'properties': {
'mimeType': {
'type': 'string',
'description': 'Type of the file which is about to be uploaded',
},
},
},
}
@nickname('getFiledropUrl')
@validate_json_request('FileDropRequest')
def post(self):
""" Request a URL to which a file may be uploaded. """
mime_type = request.get_json()['mimeType']
(url, file_id) = user_files.prepare_for_drop(mime_type)
return {
'url': url,
'file_id': str(file_id),
}

184
endpoints/api/discovery.py Normal file
View file

@ -0,0 +1,184 @@
import re
import logging
from flask.ext.restful import reqparse
from endpoints.api import (ApiResource, resource, method_metadata, nickname, truthy_bool,
parse_args, query_param)
from app import app
from auth import scopes
logger = logging.getLogger(__name__)
PARAM_REGEX = re.compile(r'<([\w]+:)?([\w]+)>')
TYPE_CONVERTER = {
truthy_bool: 'boolean',
str: 'string',
basestring: 'string',
reqparse.text_type: 'string',
int: 'integer',
}
URL_SCHEME = app.config['URL_SCHEME']
URL_HOST = app.config['URL_HOST']
def fully_qualified_name(method_view_class):
inst = method_view_class()
return '%s.%s' % (inst.__module__, inst.__class__.__name__)
def swagger_route_data(include_internal=False, compact=False):
apis = []
models = {}
for rule in app.url_map.iter_rules():
endpoint_method = app.view_functions[rule.endpoint]
if 'view_class' in dir(endpoint_method):
view_class = endpoint_method.view_class
operations = []
method_names = list(rule.methods.difference(['HEAD', 'OPTIONS']))
for method_name in method_names:
method = getattr(view_class, method_name.lower(), None)
parameters = []
for param in rule.arguments:
parameters.append({
'paramType': 'path',
'name': param,
'dataType': 'string',
'description': 'Param description.',
'required': True,
})
if method is None:
logger.debug('Unable to find method for %s in class %s', method_name, view_class)
else:
req_schema_name = method_metadata(method, 'request_schema')
if req_schema_name:
parameters.append({
'paramType': 'body',
'name': 'body',
'description': 'Request body contents.',
'dataType': req_schema_name,
'required': True,
})
schema = view_class.schemas[req_schema_name]
models[req_schema_name] = schema
if '__api_query_params' in dir(method):
for param_spec in method.__api_query_params:
new_param = {
'paramType': 'query',
'name': param_spec['name'],
'description': param_spec['help'],
'dataType': TYPE_CONVERTER[param_spec['type']],
'required': param_spec['required'],
}
if len(param_spec['choices']) > 0:
new_param['enum'] = list(param_spec['choices'])
parameters.append(new_param)
new_operation = {
'method': method_name,
'nickname': method_metadata(method, 'nickname')
}
if not compact:
new_operation.update({
'type': 'void',
'summary': method.__doc__ if method.__doc__ else '',
'parameters': parameters,
})
scope = method_metadata(method, 'oauth2_scope')
if scope and not compact:
new_operation['authorizations'] = {
'oauth2': [
{
'scope': scope.scope
}
],
}
internal = method_metadata(method, 'internal')
if internal is not None:
new_operation['internal'] = True
if not internal or (internal and include_internal):
operations.append(new_operation)
swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule)
new_resource = {
'path': swagger_path,
'description': view_class.__doc__ if view_class.__doc__ else "",
'operations': operations,
'name': fully_qualified_name(view_class),
}
related_user_res = method_metadata(view_class, 'related_user_resource')
if related_user_res is not None:
new_resource['quayUserRelated'] = fully_qualified_name(related_user_res)
internal = method_metadata(view_class, 'internal')
if internal is not None:
new_resource['internal'] = True
if not internal or (internal and include_internal):
apis.append(new_resource)
# If compact form was requested, simply return the APIs.
if compact:
return {'apis': apis}
swagger_data = {
'apiVersion': 'v1',
'swaggerVersion': '1.2',
'basePath': '%s://%s' % (URL_SCHEME, URL_HOST),
'resourcePath': '/',
'info': {
'title': 'Quay.io API',
'description': ('This API allows you to perform many of the operations required to work '
'with Quay.io repositories, users, and organizations. You can find out more '
'at <a href="https://quay.io">Quay.io</a>.'),
'termsOfServiceUrl': 'https://quay.io/tos',
'contact': 'support@quay.io',
},
'authorizations': {
'oauth2': {
'scopes': [scope._asdict() for scope in scopes.ALL_SCOPES.values()],
'grantTypes': {
"implicit": {
"tokenName": "access_token",
"loginEndpoint": {
"url": "%s://%s/oauth/authorize" % (URL_SCHEME, URL_HOST),
},
},
},
},
},
'apis': apis,
'models': models,
}
return swagger_data
@resource('/v1/discovery')
class DiscoveryResource(ApiResource):
"""Ability to inspect the API for usage information and documentation."""
@parse_args
@query_param('internal', 'Whether to include internal APIs.', type=truthy_bool, default=False)
@nickname('discovery')
def get(self, args):
""" List all of the API endpoints available in the swagger API format."""
return swagger_route_data(args['internal'])

92
endpoints/api/image.py Normal file
View file

@ -0,0 +1,92 @@
import json
from collections import defaultdict
from app import app
from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource,
format_date, NotFound)
from data import model
from util.cache import cache_control_flask_restful
store = app.config['STORAGE']
def image_view(image):
extended_props = image
if image.storage and image.storage.id:
extended_props = image.storage
command = extended_props.command
return {
'id': image.docker_image_id,
'created': format_date(extended_props.created),
'comment': extended_props.comment,
'command': json.loads(command) if command else None,
'ancestors': image.ancestors,
'dbid': image.id,
'size': extended_props.image_size,
}
@resource('/v1/repository/<repopath:repository>/image/')
class RepositoryImageList(RepositoryParamResource):
""" Resource for listing repository images. """
@require_repo_read
@nickname('listRepositoryImages')
def get(self, namespace, repository):
""" List the images for the specified 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 {
'images': [add_tags(image_view(image)) for image in all_images]
}
@resource('/v1/repository/<repopath:repository>/image/<image_id>')
class RepositoryImage(RepositoryParamResource):
""" Resource for handling repository images. """
@require_repo_read
@nickname('getImage')
def get(self, namespace, repository, image_id):
""" Get the information available for the specified image. """
image = model.get_repo_image(namespace, repository, image_id)
if not image:
raise NotFound()
return image_view(image)
@resource('/v1/repository/<repopath:repository>/image/<image_id>/changes')
class RepositoryImageChanges(RepositoryParamResource):
""" Resource for handling repository image change lists. """
@cache_control_flask_restful(max_age=60*60) # Cache for one hour
@require_repo_read
@nickname('getImageChanges')
def get(self, namespace, repository, image_id):
""" Get the list of changes for the specified image. """
image = model.get_repo_image(namespace, repository, image_id)
if not image:
raise NotFound()
uuid = image.storage and image.storage.uuid
diffs_path = store.image_file_diffs_path(namespace, repository, image_id, uuid)
try:
response_json = json.loads(store.get_content(diffs_path))
return response_json
except IOError:
raise NotFound()

126
endpoints/api/logs.py Normal file
View file

@ -0,0 +1,126 @@
import json
from datetime import datetime, timedelta
from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args,
RepositoryParamResource, require_repo_admin, related_user_resource,
format_date, Unauthorized, NotFound, require_user_admin,
internal_only)
from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model
def log_view(log):
view = {
'kind': log.kind.name,
'metadata': json.loads(log.metadata_json),
'ip': log.ip,
'datetime': format_date(log.datetime),
}
if log.performer:
view['performer'] = {
'kind': 'user',
'name': log.performer.username,
'is_robot': log.performer.robot,
}
return view
def get_logs(namespace, start_time, end_time, performer_name=None,
repository=None):
performer = None
if performer_name:
performer = model.get_user(performer_name)
if start_time:
try:
start_time = datetime.strptime(start_time + ' UTC', '%m/%d/%Y %Z')
except ValueError:
start_time = None
if not start_time:
start_time = datetime.today() - timedelta(7) # One week
if end_time:
try:
end_time = datetime.strptime(end_time + ' UTC', '%m/%d/%Y %Z')
end_time = end_time + timedelta(days=1)
except ValueError:
end_time = None
if not end_time:
end_time = datetime.today()
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
repository=repository)
return {
'start_time': format_date(start_time),
'end_time': format_date(end_time),
'logs': [log_view(log) for log in logs]
}
@resource('/v1/repository/<repopath:repository>/logs')
@internal_only
class RepositoryLogs(RepositoryParamResource):
""" Resource for fetching logs for the specific repository. """
@require_repo_admin
@nickname('listRepoLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs (%m/%d/%Y %Z)', type=str)
def get(self, args, namespace, repository):
""" List the logs for the specified repository. """
repo = model.get_repository(namespace, repository)
if not repo:
raise NotFound()
start_time = args['starttime']
end_time = args['endtime']
return get_logs(namespace, start_time, end_time, repository=repo)
@resource('/v1/user/logs')
@internal_only
class UserLogs(ApiResource):
""" Resource for fetching logs for the current user. """
@require_user_admin
@nickname('listUserLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('performer', 'Username for which to filter logs.', type=str)
def get(self, args):
""" List the logs for the current user. """
performer_name = args['performer']
start_time = args['starttime']
end_time = args['endtime']
user = get_authenticated_user()
return get_logs(user.username, start_time, end_time, performer_name=performer_name)
@resource('/v1/organization/<orgname>/logs')
@internal_only
@related_user_resource(UserLogs)
class OrgLogs(ApiResource):
""" Resource for fetching logs for the entire organization. """
@nickname('listOrgLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('performer', 'Username for which to filter logs.', type=str)
def get(self, args, orgname):
""" List the logs for the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
performer_name = args['performer']
start_time = args['starttime']
end_time = args['endtime']
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
raise Unauthorized()

View file

@ -0,0 +1,520 @@
import logging
import stripe
from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin, log_action)
from endpoints.api.team import team_view
from endpoints.api.user import User, PrivateRepositories
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
CreateRepositoryPermission)
from auth.auth_context import get_authenticated_user
from data import model
from data.plans import get_plan
from util.gravatar import compute_hash
logger = logging.getLogger(__name__)
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
@resource('/v1/organization/')
@internal_only
class OrganizationList(ApiResource):
""" Resource for creating organizations. """
schemas = {
'NewOrg': {
'id': 'NewOrg',
'type': 'object',
'description': 'Description of a new organization.',
'required': [
'name',
'email',
],
'properties': {
'name': {
'type': 'string',
'description': 'Organization username',
},
'email': {
'type': 'string',
'description': 'Organization contact email',
},
},
},
}
@require_user_admin
@nickname('createOrganization')
@validate_json_request('NewOrg')
def post(self):
""" Create a new organization. """
user = get_authenticated_user()
org_data = request.get_json()
existing = None
try:
existing = model.get_organization(org_data['name'])
except model.InvalidOrganizationException:
pass
if not existing:
try:
existing = model.get_user(org_data['name'])
except model.InvalidUserException:
pass
if existing:
msg = 'A user or organization with this name already exists'
raise request_error(message=msg)
try:
model.create_organization(org_data['name'], org_data['email'], user)
return 'Created', 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource('/v1/organization/<orgname>')
@internal_only
@related_user_resource(User)
class Organization(ApiResource):
""" Resource for managing organizations. """
schemas = {
'UpdateOrg': {
'id': 'UpdateOrg',
'type': 'object',
'description': 'Description of updates for an existing organization',
'properties': {
'email': {
'type': 'string',
'description': 'Organization contact email',
},
'invoice_email': {
'type': 'boolean',
'description': 'Whether the organization desires to receive emails for invoices',
},
},
},
}
@nickname('getOrganization')
def get(self, orgname):
""" Get the details for the specified organization """
permission = OrganizationMemberPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
teams = model.get_teams_within_org(org)
return org_view(org, teams)
raise Unauthorized()
@nickname('changeOrganizationDetails')
@validate_json_request('UpdateOrg')
def put(self, orgname):
""" Change the details for the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
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'])
if 'email' in org_data and org_data['email'] != org.email:
new_email = org_data['email']
if model.find_user_by_email(new_email):
raise request_error(message='E-mail address already used')
logger.debug('Changing email address for organization: %s', org.username)
model.update_email(org, new_email)
teams = model.get_teams_within_org(org)
return org_view(org, teams)
raise Unauthorized()
@resource('/v1/organization/<orgname>/private')
@internal_only
@related_user_resource(PrivateRepositories)
class OrgPrivateRepositories(ApiResource):
""" Custom verb to compute whether additional private repositories are available. """
@nickname('getOrganizationPrivateAllowed')
def get(self, orgname):
""" Return whether or not this org is allowed to create new private repositories. """
permission = CreateRepositoryPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
private_repos = model.get_private_repo_count(organization.username)
data = {
'privateAllowed': False
}
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
repos_allowed = 0
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan['privateRepos']
data['privateAllowed'] = (private_repos < repos_allowed)
if AdministerOrganizationPermission(orgname).can():
data['privateCount'] = private_repos
return data
raise Unauthorized()
@resource('/v1/organization/<orgname>/members')
@internal_only
class OrgnaizationMemberList(ApiResource):
""" Resource for listing the members of an organization. """
@nickname('getOrganizationMembers')
def get(self, orgname):
""" List the members of the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
# Loop to create the members dictionary. Note that the members collection
# will return an entry for *every team* a member is on, so we will have
# duplicate keys (which is why we pre-build the dictionary).
members_dict = {}
members = model.get_organization_members_with_teams(org)
for member in members:
if not member.user.username in members_dict:
members_dict[member.user.username] = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
members_dict[member.user.username]['teams'].append(member.team.name)
return {'members': members_dict}
raise Unauthorized()
@resource('/v1/organization/<orgname>/members/<membername>')
@internal_only
class OrganizationMember(ApiResource):
""" Resource for managing individual organization members. """
@nickname('getOrganizationMember')
def get(self, orgname, membername):
""" Get information on the specific orgnaization member. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
member_dict = None
member_teams = model.get_organization_members_with_teams(org, membername=membername)
for member in member_teams:
if not member_dict:
member_dict = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
member_dict['teams'].append(member.team.name)
if not member_dict:
raise NotFound()
return {'member': member_dict}
raise Unauthorized()
@resource('/v1/app/<client_id>')
class ApplicationInformation(ApiResource):
""" Resource that returns public information about a registered application. """
@nickname('getApplicationInformation')
def get(self, client_id):
""" Get information on the specified application. """
application = model.oauth.get_application_for_client_id(client_id)
if not application:
raise NotFound()
org_hash = compute_hash(application.organization.email)
gravatar = compute_hash(application.gravatar_email) if application.gravatar_email else org_hash
return {
'name': application.name,
'description': application.description,
'uri': application.application_uri,
'gravatar': gravatar,
'organization': org_view(application.organization, [])
}
def app_view(application):
is_admin = AdministerOrganizationPermission(application.organization.username).can()
return {
'name': application.name,
'description': application.description,
'application_uri': application.application_uri,
'client_id': application.client_id,
'client_secret': application.client_secret if is_admin else None,
'redirect_uri': application.redirect_uri if is_admin else None,
'gravatar_email': application.gravatar_email if is_admin else None,
}
@resource('/v1/organization/<orgname>/applications')
@internal_only
class OrganizationApplications(ApiResource):
""" Resource for managing applications defined by an organizations. """
schemas = {
'NewApp': {
'id': 'NewApp',
'type': 'object',
'description': 'Description of a new organization application.',
'required': [
'name',
],
'properties': {
'name': {
'type': 'string',
'description': 'The name of the application',
},
'redirect_uri': {
'type': 'string',
'description': 'The URI for the application\'s OAuth redirect',
},
'application_uri': {
'type': 'string',
'description': 'The URI for the application\'s homepage',
},
'description': {
'type': 'string',
'description': 'The human-readable description for the application',
},
'gravatar_email': {
'type': 'string',
'description': 'The e-mail address of the gravatar to use for the application',
}
},
},
}
@nickname('getOrganizationApplications')
def get(self, orgname):
""" List the applications for the specified organization """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
applications = model.oauth.list_applications_for_org(org)
return {'applications': [app_view(application) for application in applications]}
raise Unauthorized()
@nickname('createOrganizationApplication')
@validate_json_request('NewApp')
def post(self, orgname):
""" Creates a new application under this organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
app_data = request.get_json()
application = model.oauth.create_application(
org, app_data['name'],
app_data.get('application_uri', ''),
app_data.get('redirect_uri', ''),
description = app_data.get('description', ''),
gravatar_email = app_data.get('gravatar_email', None),)
app_data.update({
'application_name': application.name,
'client_id': application.client_id
})
log_action('create_application', orgname, app_data)
return app_view(application)
raise Unauthorized()
@resource('/v1/organization/<orgname>/applications/<client_id>')
@internal_only
class OrganizationApplicationResource(ApiResource):
""" Resource for managing an application defined by an organizations. """
schemas = {
'UpdateApp': {
'id': 'UpdateApp',
'type': 'object',
'description': 'Description of an updated application.',
'required': [
'name',
'redirect_uri',
'application_uri'
],
'properties': {
'name': {
'type': 'string',
'description': 'The name of the application',
},
'redirect_uri': {
'type': 'string',
'description': 'The URI for the application\'s OAuth redirect',
},
'application_uri': {
'type': 'string',
'description': 'The URI for the application\'s homepage',
},
'description': {
'type': 'string',
'description': 'The human-readable description for the application',
},
'gravatar_email': {
'type': 'string',
'description': 'The e-mail address of the gravatar to use for the application',
}
},
},
}
@nickname('getOrganizationApplication')
def get(self, orgname, client_id):
""" Retrieves the application with the specified client_id under the specified organization """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
return app_view(application)
raise Unauthorized()
@nickname('updateOrganizationApplication')
@validate_json_request('UpdateApp')
def put(self, orgname, client_id):
""" Updates an application under this organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
app_data = request.get_json()
application.name = app_data['name']
application.application_uri = app_data['application_uri']
application.redirect_uri = app_data['redirect_uri']
application.description = app_data.get('description', '')
application.gravatar_email = app_data.get('gravatar_email', None)
application.save()
app_data.update({
'application_name': application.name,
'client_id': application.client_id
})
log_action('update_application', orgname, app_data)
return app_view(application)
raise Unauthorized()
@nickname('deleteOrganizationApplication')
def delete(self, orgname, client_id):
""" Deletes the application under this organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.delete_application(org, client_id)
if not application:
raise NotFound()
log_action('delete_application', orgname,
{'application_name': application.name, 'client_id': client_id})
return 'Deleted', 204
raise Unauthorized()
@resource('/v1/organization/<orgname>/applications/<client_id>/resetclientsecret')
@internal_only
class OrganizationApplicationResetClientSecret(ApiResource):
""" Custom verb for resetting the client secret of an application. """
@nickname('resetOrganizationApplicationClientSecret')
def post(self, orgname, client_id):
""" Resets the client secret of the application. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
application = model.oauth.reset_client_secret(application)
log_action('reset_application_client_secret', orgname,
{'application_name': application.name, 'client_id': client_id})
return app_view(application)
raise Unauthorized()

241
endpoints/api/permission.py Normal file
View file

@ -0,0 +1,241 @@
import logging
from flask import request
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, request_error, validate_json_request)
from data import model
logger = logging.getLogger(__name__)
def role_view(repo_perm_obj):
return {
'role': repo_perm_obj.role.name,
}
def wrap_role_view_user(role_json, user):
role_json['is_robot'] = user.robot
return role_json
def wrap_role_view_org(role_json, user, org_members):
role_json['is_org_member'] = user.robot or user.username in org_members
return role_json
@resource('/v1/repository/<repopath:repository>/permissions/team/')
class RepositoryTeamPermissionList(RepositoryParamResource):
""" Resource for repository team permissions. """
@require_repo_admin
@nickname('listRepoTeamPermissions')
def get(self, namespace, repository):
""" List all team permission. """
repo_perms = model.get_all_repo_teams(namespace, repository)
return {
'permissions': {repo_perm.team.name: role_view(repo_perm)
for repo_perm in repo_perms}
}
@resource('/v1/repository/<repopath:repository>/permissions/user/')
class RepositoryUserPermissionList(RepositoryParamResource):
""" Resource for repository user permissions. """
@require_repo_admin
@nickname('listRepoUserPermissions')
def get(self, namespace, repository):
""" List all user permissions. """
# Lookup the organization (if any).
org = None
try:
org = model.get_organization(namespace) # Will raise an error if not org
except model.InvalidOrganizationException:
# This repository isn't under an org
pass
# Determine how to wrap the role(s).
def wrapped_role_view(repo_perm):
return wrap_role_view_user(role_view(repo_perm), repo_perm.user)
role_view_func = wrapped_role_view
if org:
org_members = model.get_organization_member_set(namespace)
current_func = role_view_func
def wrapped_role_org_view(repo_perm):
return wrap_role_view_org(current_func(repo_perm), repo_perm.user,
org_members)
role_view_func = wrapped_role_org_view
# Load and return the permissions.
repo_perms = model.get_all_repo_users(namespace, repository)
return {
'permissions': {perm.user.username: role_view_func(perm)
for perm in repo_perms}
}
@resource('/v1/repository/<repopath:repository>/permissions/user/<username>')
class RepositoryUserPermission(RepositoryParamResource):
""" Resource for managing individual user permissions. """
schemas = {
'UserPermission': {
'id': 'UserPermission',
'type': 'object',
'description': 'Description of a user permission.',
'required': [
'role',
],
'properties': {
'role': {
'type': 'string',
'description': 'Role to use for the user',
'enum': [
'read',
'write',
'admin',
],
},
},
},
}
@require_repo_admin
@nickname('getUserPermissions')
def get(self, namespace, repository, username):
""" Get the Fetch the permission for the specified user. """
logger.debug('Get repo: %s/%s permissions for user %s' %
(namespace, repository, username))
perm = model.get_user_reponame_permission(username, namespace, repository)
perm_view = wrap_role_view_user(role_view(perm), perm.user)
try:
model.get_organization(namespace)
org_members = model.get_organization_member_set(namespace)
perm_view = wrap_role_view_org(perm_view, perm.user, org_members)
except model.InvalidOrganizationException:
# This repository is not part of an organization
pass
return perm_view
@require_repo_admin
@nickname('changeUserPermissions')
@validate_json_request('UserPermission')
def put(self, namespace, repository, username): # Also needs to respond to post
""" Update the perimssions for an existing repository. """
new_permission = request.get_json()
logger.debug('Setting permission to: %s for user %s' %
(new_permission['role'], username))
try:
perm = model.set_user_repo_permission(username, namespace, repository,
new_permission['role'])
except model.InvalidUsernameException as ex:
raise request_error(exception=ex)
perm_view = wrap_role_view_user(role_view(perm), perm.user)
try:
model.get_organization(namespace)
org_members = model.get_organization_member_set(namespace)
perm_view = wrap_role_view_org(perm_view, perm.user, org_members)
except model.InvalidOrganizationException:
# This repository is not part of an organization
pass
except model.DataModelException as ex:
raise request_error(exception=ex)
log_action('change_repo_permission', namespace,
{'username': username, 'repo': repository,
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
return perm_view, 200
@require_repo_admin
@nickname('deleteUserPermissions')
def delete(self, namespace, repository, username):
""" Delete the permission for the user. """
try:
model.delete_user_permission(username, namespace, repository)
except model.DataModelException as ex:
raise request_error(exception=ex)
log_action('delete_repo_permission', namespace,
{'username': username, 'repo': repository},
repo=model.get_repository(namespace, repository))
return 'Deleted', 204
@resource('/v1/repository/<repopath:repository>/permissions/team/<teamname>')
class RepositoryTeamPermission(RepositoryParamResource):
""" Resource for managing individual team permissions. """
schemas = {
'TeamPermission': {
'id': 'TeamPermission',
'type': 'object',
'description': 'Description of a team permission.',
'required': [
'role',
],
'properties': {
'role': {
'type': 'string',
'description': 'Role to use for the team',
'enum': [
'read',
'write',
'admin',
],
},
},
},
}
@require_repo_admin
@nickname('getTeamPermissions')
def get(self, namespace, repository, teamname):
""" Fetch the permission for the specified team. """
logger.debug('Get repo: %s/%s permissions for team %s' %
(namespace, repository, teamname))
perm = model.get_team_reponame_permission(teamname, namespace, repository)
return role_view(perm)
@require_repo_admin
@nickname('changeTeamPermissions')
@validate_json_request('TeamPermission')
def put(self, namespace, repository, teamname):
""" Update the existing team permission. """
new_permission = request.get_json()
logger.debug('Setting permission to: %s for team %s' %
(new_permission['role'], teamname))
perm = model.set_team_repo_permission(teamname, namespace, repository,
new_permission['role'])
log_action('change_repo_permission', namespace,
{'team': teamname, 'repo': repository,
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
return role_view(perm), 200
@require_repo_admin
@nickname('deleteTeamPermissions')
def delete(self, namespace, repository, teamname):
""" Delete the permission for the specified team. """
model.delete_team_permission(teamname, namespace, repository)
log_action('delete_repo_permission', namespace,
{'team': teamname, 'repo': repository},
repo=model.get_repository(namespace, repository))
return 'Deleted', 204

253
endpoints/api/prototype.py Normal file
View file

@ -0,0 +1,253 @@
from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, Unauthorized, NotFound, internal_only)
from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model
def prototype_view(proto, org_members):
def prototype_user_view(user):
return {
'name': user.username,
'is_robot': user.robot,
'kind': 'user',
'is_org_member': user.robot or user.username in org_members,
}
if proto.delegate_user:
delegate_view = prototype_user_view(proto.delegate_user)
else:
delegate_view = {
'name': proto.delegate_team.name,
'kind': 'team',
}
return {
'activating_user': (prototype_user_view(proto.activating_user)
if proto.activating_user else None),
'delegate': delegate_view,
'role': proto.role.name,
'id': proto.uuid,
}
def log_prototype_action(action_kind, orgname, prototype, **kwargs):
username = get_authenticated_user().username
log_params = {
'prototypeid': prototype.uuid,
'username': username,
'activating_username': (prototype.activating_user.username
if prototype.activating_user else None),
'role': prototype.role.name
}
for key, value in kwargs.items():
log_params[key] = value
if prototype.delegate_user:
log_params['delegate_user'] = prototype.delegate_user.username
elif prototype.delegate_team:
log_params['delegate_team'] = prototype.delegate_team.name
log_action(action_kind, orgname, log_params)
@resource('/v1/organization/<orgname>/prototypes')
@internal_only
class PermissionPrototypeList(ApiResource):
""" Resource for listing and creating permission prototypes. """
schemas = {
'NewPrototype': {
'id': 'NewPrototype',
'type': 'object',
'description': 'Description of a new prototype',
'required': [
'role',
'delegate',
],
'properties': {
'role': {
'type': 'string',
'description': 'Role that should be applied to the delegate',
'enum': [
'read',
'write',
'admin',
],
},
'activating_user': {
'type': 'object',
'description': 'Repository creating user to whom the rule should apply',
'required': [
'name',
],
'properties': {
'name': {
'type': 'string',
'description': 'The username for the activating_user',
},
},
},
'delegate': {
'type': 'object',
'description': 'Information about the user or team to which the rule grants access',
'required': [
'name',
'kind',
],
'properties': {
'name': {
'type': 'string',
'description': 'The name for the delegate team or user',
},
'kind': {
'type': 'string',
'description': 'Whether the delegate is a user or a team',
'enum': [
'user',
'team',
],
},
},
},
},
},
}
@nickname('getOrganizationPrototypePermissions')
def get(self, orgname):
""" List the existing prototypes for this organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
permissions = model.get_prototype_permissions(org)
org_members = model.get_organization_member_set(orgname)
return {'prototypes': [prototype_view(p, org_members) for p in permissions]}
raise Unauthorized()
@nickname('createOrganizationPrototypePermission')
@validate_json_request('NewPrototype')
def post(self, orgname):
""" Create a new permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
details = request.get_json()
activating_username = None
if ('activating_user' in details and details['activating_user'] and
'name' in details['activating_user']):
activating_username = details['activating_user']['name']
delegate = details['delegate'] if 'delegate' in details else {}
delegate_kind = delegate.get('kind', None)
delegate_name = delegate.get('name', None)
delegate_username = delegate_name if delegate_kind == 'user' else None
delegate_teamname = delegate_name if delegate_kind == 'team' else None
activating_user = (model.get_user(activating_username) if activating_username else None)
delegate_user = (model.get_user(delegate_username) if delegate_username else None)
delegate_team = (model.get_organization_team(orgname, delegate_teamname)
if delegate_teamname else None)
if activating_username and not activating_user:
raise request_error(message='Unknown activating user')
if not delegate_user and not delegate_team:
raise request_error(message='Missing delegate user or team')
role_name = details['role']
prototype = model.add_prototype_permission(org, role_name, activating_user,
delegate_user, delegate_team)
log_prototype_action('create_prototype_permission', orgname, prototype)
org_members = model.get_organization_member_set(orgname)
return prototype_view(prototype, org_members)
raise Unauthorized()
@resource('/v1/organization/<orgname>/prototypes/<prototypeid>')
@internal_only
class PermissionPrototype(ApiResource):
""" Resource for managingin individual permission prototypes. """
schemas = {
'PrototypeUpdate': {
'id': 'PrototypeUpdate',
'type': 'object',
'description': 'Description of a the new prototype role',
'required': [
'role',
],
'properties': {
'role': {
'type': 'string',
'description': 'Role that should be applied to the permission',
'enum': [
'read',
'write',
'admin',
],
},
},
},
}
@nickname('deleteOrganizationPrototypePermission')
def delete(self, orgname, prototypeid):
""" Delete an existing permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
prototype = model.delete_prototype_permission(org, prototypeid)
if not prototype:
raise NotFound()
log_prototype_action('delete_prototype_permission', orgname, prototype)
return 'Deleted', 204
raise Unauthorized()
@nickname('updateOrganizationPrototypePermission')
@validate_json_request('PrototypeUpdate')
def put(self, orgname, prototypeid):
""" Update the role of an existing permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
existing = model.get_prototype_permission(org, prototypeid)
if not existing:
raise NotFound()
details = request.get_json()
role_name = details['role']
prototype = model.update_prototype_permission(org, prototypeid, role_name)
if not prototype:
raise NotFound()
log_prototype_action('modify_prototype_permission', orgname, prototype,
original_role=existing.role.name)
org_members = model.get_organization_member_set(orgname)
return prototype_view(prototype, org_members)
raise Unauthorized()

291
endpoints/api/repository.py Normal file
View file

@ -0,0 +1,291 @@
import logging
import json
from flask import request
from data import model
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
request_error, require_scope, Unauthorized, NotFound, InvalidRequest)
from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission,
CreateRepositoryPermission, ReadRepositoryPermission)
from auth.auth_context import get_authenticated_user
from auth import scopes
logger = logging.getLogger(__name__)
@resource('/v1/repository')
class RepositoryList(ApiResource):
"""Operations for creating and listing repositories."""
schemas = {
'NewRepo': {
'id': 'NewRepo',
'type': 'object',
'description': 'Description of a new repository',
'required': [
'repository',
'visibility',
'description',
],
'properties': {
'repository': {
'type': 'string',
'description': 'Repository name',
},
'visibility': {
'type': 'string',
'description': 'Visibility which the repository will start with',
'enum': [
'public',
'private',
],
},
'namespace': {
'type': 'string',
'description': ('Namespace in which the repository should be created. If omitted, the '
'username of the caller is used'),
},
'description': {
'type': 'string',
'description': 'Markdown encoded description for the repository',
},
},
},
}
@require_scope(scopes.CREATE_REPO)
@nickname('createRepo')
@validate_json_request('NewRepo')
def post(self):
"""Create a new repository."""
owner = get_authenticated_user()
req = request.get_json()
if owner is None and 'namespace' not in 'req':
raise InvalidRequest('Must provide a namespace or must be logged in.')
namespace_name = req['namespace'] if 'namespace' in req else owner.username
permission = CreateRepositoryPermission(namespace_name)
if permission.can():
repository_name = req['repository']
visibility = req['visibility']
existing = model.get_repository(namespace_name, repository_name)
if existing:
raise request_error(message='Repository already exists')
visibility = req['visibility']
repo = model.create_repository(namespace_name, repository_name, owner,
visibility)
repo.description = req['description']
repo.save()
log_action('create_repo', namespace_name, {'repo': repository_name,
'namespace': namespace_name}, repo=repo)
return {
'namespace': namespace_name,
'name': repository_name
}, 201
raise Unauthorized()
@require_scope(scopes.READ_REPO)
@nickname('listRepos')
@parse_args
@query_param('page', 'Offset page number. (int)', type=int)
@query_param('limit', 'Limit on the number of results (int)', type=int)
@query_param('namespace', 'Namespace to use when querying for org repositories.', type=str)
@query_param('public', 'Whether to include public repositories.', type=truthy_bool, default=True)
@query_param('private', 'Whether to inlcude private repositories.', type=truthy_bool,
default=True)
@query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False)
@query_param('count', 'Whether to include a count of the total number of results available.',
type=truthy_bool, default=False)
def get(self, args):
"""Fetch the list of repositories under a variety of situations."""
def repo_view(repo_obj):
return {
'namespace': repo_obj.namespace,
'name': repo_obj.name,
'description': repo_obj.description,
'is_public': repo_obj.visibility.name == 'public',
}
username = None
if get_authenticated_user() and args['private']:
username = get_authenticated_user().username
response = {}
repo_count = None
if args['count']:
repo_count = model.get_visible_repository_count(username, include_public=args['public'],
namespace=args['namespace'])
response['count'] = repo_count
repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'],
include_public=args['public'], sort=args['sort'],
namespace=args['namespace'])
response['repositories'] = [repo_view(repo) for repo in repo_query
if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())]
return response
def image_view(image):
extended_props = image
if image.storage and image.storage.id:
extended_props = image.storage
command = extended_props.command
return {
'id': image.docker_image_id,
'created': format_date(extended_props.created),
'comment': extended_props.comment,
'command': json.loads(command) if command else None,
'ancestors': image.ancestors,
'dbid': image.id,
'size': extended_props.image_size,
}
@resource('/v1/repository/<repopath:repository>')
class Repository(RepositoryParamResource):
"""Operations for managing a specific repository."""
schemas = {
'RepoUpdate': {
'id': 'RepoUpdate',
'type': 'object',
'description': 'Fields which can be updated in a repository.',
'required': [
'description',
],
'properties': {
'description': {
'type': 'string',
'description': 'Markdown encoded description for the repository',
},
}
}
}
@require_repo_read
@nickname('getRepo')
def get(self, namespace, repository):
"""Fetch the specified 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:
return {}
return {
'name': tag.name,
'image': image_view(image),
}
organization = None
try:
organization = model.get_organization(namespace)
except model.InvalidOrganizationException:
pass
is_public = model.repository_is_public(namespace, repository)
repo = model.get_repository(namespace, repository)
if repo:
tags = model.list_repository_tags(namespace, repository)
tag_dict = {tag.name: tag_view(tag) for tag in tags}
can_write = ModifyRepositoryPermission(namespace, repository).can()
can_admin = AdministerRepositoryPermission(namespace, repository).can()
active_builds = model.list_repository_builds(namespace, repository, 1,
include_inactive=False)
return {
'namespace': namespace,
'name': repository,
'description': repo.description,
'tags': tag_dict,
'can_write': can_write,
'can_admin': can_admin,
'is_public': is_public,
'is_building': len(list(active_builds)) > 0,
'is_organization': bool(organization),
'status_token': repo.badge_token if not is_public else ''
}
raise NotFound()
@require_repo_write
@nickname('updateRepo')
@validate_json_request('RepoUpdate')
def put(self, namespace, repository):
""" Update the description in the specified repository. """
repo = model.get_repository(namespace, repository)
if repo:
values = request.get_json()
repo.description = values['description']
repo.save()
log_action('set_repo_description', namespace,
{'repo': repository, 'description': values['description']},
repo=repo)
return {
'success': True
}
raise NotFound()
@require_repo_admin
@nickname('deleteRepository')
def delete(self, namespace, repository):
""" Delete a repository. """
model.purge_repository(namespace, repository)
log_action('delete_repo', namespace,
{'repo': repository, 'namespace': namespace})
return 'Deleted', 204
@resource('/v1/repository/<repopath:repository>/changevisibility')
class RepositoryVisibility(RepositoryParamResource):
""" Custom verb for changing the visibility of the repository. """
schemas = {
'ChangeVisibility': {
'id': 'ChangeVisibility',
'type': 'object',
'description': 'Change the visibility for the repository.',
'required': [
'visibility',
],
'properties': {
'visibility': {
'type': 'string',
'description': 'Visibility which the repository will start with',
'enum': [
'public',
'private',
],
},
}
}
}
@require_repo_admin
@nickname('changeRepoVisibility')
@validate_json_request('ChangeVisibility')
def post(self, namespace, repository):
""" Change the visibility of a repository. """
repo = model.get_repository(namespace, repository)
if repo:
values = request.get_json()
model.set_repository_visibility(repo, values['visibility'])
log_action('change_repo_visibility', namespace,
{'repo': repository, 'visibility': values['visibility']},
repo=repo)
return {
'success': True
}

134
endpoints/api/repotoken.py Normal file
View file

@ -0,0 +1,134 @@
import logging
from flask import request
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, validate_json_request, NotFound)
from data import model
logger = logging.getLogger(__name__)
def token_view(token_obj):
return {
'friendlyName': token_obj.friendly_name,
'code': token_obj.code,
'role': token_obj.role.name,
}
@resource('/v1/repository/<repopath:repository>/tokens/')
class RepositoryTokenList(RepositoryParamResource):
""" Resource for creating and listing repository tokens. """
schemas = {
'NewToken': {
'id': 'NewToken',
'type': 'object',
'description': 'Description of a new token.',
'required':[
'friendlyName',
],
'properties': {
'friendlyName': {
'type': 'string',
'description': 'Friendly name to help identify the token',
},
},
},
}
@require_repo_admin
@nickname('listRepoTokens')
def get(self, namespace, repository):
""" List the tokens for the specified repository. """
tokens = model.get_repository_delegate_tokens(namespace, repository)
return {
'tokens': {token.code: token_view(token) for token in tokens}
}
@require_repo_admin
@nickname('createToken')
@validate_json_request('NewToken')
def post(self, namespace, repository):
""" Create a new repository token. """
token_params = request.get_json()
token = model.create_delegate_token(namespace, repository,
token_params['friendlyName'])
log_action('add_repo_accesstoken', namespace,
{'repo': repository, 'token': token_params['friendlyName']},
repo = model.get_repository(namespace, repository))
return token_view(token), 201
@resource('/v1/repository/<repopath:repository>/tokens/<code>')
class RepositoryToken(RepositoryParamResource):
""" Resource for managing individual tokens. """
schemas = {
'TokenPermission': {
'id': 'TokenPermission',
'type': 'object',
'description': 'Description of a token permission',
'required': [
'role',
],
'properties': {
'role': {
'type': 'string',
'description': 'Role to use for the token',
'enum': [
'read',
'write',
'admin',
],
},
},
},
}
@require_repo_admin
@nickname('getTokens')
def get(self, namespace, repository, code):
""" Fetch the specified repository token information. """
try:
perm = model.get_repo_delegate_token(namespace, repository, code)
except model.InvalidTokenException:
raise NotFound()
return token_view(perm)
@require_repo_admin
@nickname('changeToken')
@validate_json_request('TokenPermission')
def put(self, namespace, repository, code):
""" Update the permissions for the specified repository token. """
new_permission = request.get_json()
logger.debug('Setting permission to: %s for code %s' %
(new_permission['role'], code))
token = model.set_repo_delegate_token_role(namespace, repository, code,
new_permission['role'])
log_action('change_repo_permission', namespace,
{'repo': repository, 'token': token.friendly_name, 'code': code,
'role': new_permission['role']},
repo = model.get_repository(namespace, repository))
return token_view(token)
@require_repo_admin
@nickname('deleteToken')
def delete(self, namespace, repository, code):
""" Delete the repository token. """
token = model.delete_delegate_token(namespace, repository, code)
log_action('delete_repo_accesstoken', namespace,
{'repo': repository, 'token': token.friendly_name,
'code': code},
repo = model.get_repository(namespace, repository))
return 'Deleted', 204

98
endpoints/api/robot.py Normal file
View file

@ -0,0 +1,98 @@
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
Unauthorized, require_user_admin, internal_only)
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
from auth.auth_context import get_authenticated_user
from data import model
from util.names import format_robot_username
def robot_view(name, token):
return {
'name': name,
'token': token,
}
@resource('/v1/user/robots')
@internal_only
class UserRobotList(ApiResource):
""" Resource for listing user robots. """
@require_user_admin
@nickname('getUserRobots')
def get(self):
""" List the available robots for the user. """
user = get_authenticated_user()
robots = model.list_entity_robots(user.username)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
@resource('/v1/user/robots/<robot_shortname>')
@internal_only
class UserRobot(ApiResource):
""" Resource for managing a user's robots. """
@require_user_admin
@nickname('createUserRobot')
def put(self, robot_shortname):
""" Create a new user robot with the specified name. """
parent = get_authenticated_user()
robot, password = model.create_robot(robot_shortname, parent)
log_action('create_robot', parent.username, {'robot': robot_shortname})
return robot_view(robot.username, password), 201
@require_user_admin
@nickname('deleteUserRobot')
def delete(self, robot_shortname):
""" Delete an existing robot. """
parent = get_authenticated_user()
model.delete_robot(format_robot_username(parent.username, robot_shortname))
log_action('delete_robot', parent.username, {'robot': robot_shortname})
return 'Deleted', 204
@resource('/v1/organization/<orgname>/robots')
@internal_only
@related_user_resource(UserRobotList)
class OrgRobotList(ApiResource):
""" Resource for listing an organization's robots. """
@nickname('getOrgRobots')
def get(self, orgname):
""" List the organization's robots. """
permission = OrganizationMemberPermission(orgname)
if permission.can():
robots = model.list_entity_robots(orgname)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
raise Unauthorized()
@resource('/v1/organization/<orgname>/robots/<robot_shortname>')
@internal_only
@related_user_resource(UserRobot)
class OrgRobot(ApiResource):
""" Resource for managing an organization's robots. """
@nickname('createOrgRobot')
def put(self, orgname, robot_shortname):
""" Create a new robot in the organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.create_robot(robot_shortname, parent)
log_action('create_robot', orgname, {'robot': robot_shortname})
return robot_view(robot.username, password), 201
raise Unauthorized()
@nickname('deleteOrgRobot')
def delete(self, orgname, robot_shortname):
""" Delete an existing organization robot. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.delete_robot(format_robot_username(orgname, robot_shortname))
log_action('delete_robot', orgname, {'robot': robot_shortname})
return 'Deleted', 204
raise Unauthorized()

116
endpoints/api/search.py Normal file
View file

@ -0,0 +1,116 @@
from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource,
require_scope)
from data import model
from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission,
ReadRepositoryPermission, UserAdminPermission)
from auth.auth_context import get_authenticated_user
from auth import scopes
@resource('/v1/entities/<prefix>')
class EntitySearch(ApiResource):
""" Resource for searching entities. """
@parse_args
@query_param('namespace', 'Namespace to use when querying for org entities.', type=str,
default='')
@query_param('includeTeams', 'Whether to include team names.', type=truthy_bool, default=False)
@nickname('getMatchingEntities')
def get(self, args, prefix):
""" Get a list of entities that match the specified prefix. """
teams = []
namespace_name = args['namespace']
robot_namespace = None
organization = None
try:
organization = model.get_organization(namespace_name)
# namespace name was an org
permission = OrganizationMemberPermission(namespace_name)
if permission.can():
robot_namespace = namespace_name
if args['includeTeams']:
teams = model.get_matching_teams(prefix, organization)
except model.InvalidOrganizationException:
# namespace name was a user
user = get_authenticated_user()
if user and user.username == namespace_name:
# Check if there is admin user permissions (login only)
admin_permission = UserAdminPermission(user.username)
if admin_permission.can():
robot_namespace = namespace_name
users = model.get_matching_users(prefix, robot_namespace, organization)
def entity_team_view(team):
result = {
'name': team.name,
'kind': 'team',
'is_org_member': True
}
return result
def user_view(user):
user_json = {
'name': user.username,
'kind': 'user',
'is_robot': user.is_robot,
}
if organization is not None:
user_json['is_org_member'] = user.is_robot or user.is_org_member
return user_json
team_data = [entity_team_view(team) for team in teams]
user_data = [user_view(user) for user in users]
return {
'results': team_data + user_data
}
def team_view(orgname, team):
view_permission = ViewTeamPermission(orgname, team.name)
role = model.get_team_org_role(team).name
return {
'id': team.id,
'name': team.name,
'description': team.description,
'can_view': view_permission.can(),
'role': role
}
@resource('/v1/find/repository')
class FindRepositories(ApiResource):
""" Resource for finding repositories. """
@parse_args
@query_param('query', 'The prefix to use when querying for repositories.', type=str, default='')
@require_scope(scopes.READ_REPO)
@nickname('findRepos')
def get(self, args):
""" Get a list of repositories that match the specified prefix query. """
prefix = args['query']
def repo_view(repo):
return {
'namespace': repo.namespace,
'name': repo.name,
'description': repo.description
}
username = None
user = get_authenticated_user()
if user is not None:
username = user.username
matching = model.get_matching_repositories(prefix, username)
return {
'repositories': [repo_view(repo) for repo in matching
if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())]
}

View file

@ -0,0 +1,98 @@
import logging
import stripe
from endpoints.api import request_error, log_action, NotFound
from endpoints.common import check_repository_usage
from data import model
from data.plans import PLANS
logger = logging.getLogger(__name__)
def carderror_response(exc):
return {'carderror': exc.message}, 402
def subscription_view(stripe_subscription, used_repos):
return {
'currentPeriodStart': stripe_subscription.current_period_start,
'currentPeriodEnd': stripe_subscription.current_period_end,
'plan': stripe_subscription.plan.id,
'usedPrivateRepos': used_repos,
}
def subscribe(user, plan, token, require_business_plan):
plan_found = None
for plan_obj in PLANS:
if plan_obj['stripeId'] == plan:
plan_found = plan_obj
if not plan_found or plan_found['deprecated']:
logger.warning('Plan not found or deprecated: %s', plan)
raise NotFound()
if (require_business_plan and not plan_found['bus_features'] and not
plan_found['price'] == 0):
logger.warning('Business attempting to subscribe to personal plan: %s',
user.username)
raise request_error(message='No matching plan found')
private_repos = model.get_private_repo_count(user.username)
# This is the default response
response_json = {
'plan': plan,
'usedPrivateRepos': private_repos,
}
status_code = 200
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 customer and plan
# simultaneously
card = token
try:
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
user.stripe_id = cus.id
user.save()
check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
except stripe.CardError as e:
return carderror_response(e)
response_json = subscription_view(cus.subscription, private_repos)
status_code = 201
else:
# Change the plan
cus = stripe.Customer.retrieve(user.stripe_id)
if plan_found['price'] == 0:
if cus.subscription is not None:
# We only have to cancel the subscription if they actually have one
cus.cancel_subscription()
cus.save()
check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
else:
# User may have been a previous customer who is resubscribing
if token:
cus.card = token
cus.plan = plan
try:
cus.save()
except stripe.CardError as e:
return carderror_response(e)
response_json = subscription_view(cus.subscription, private_repos)
check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
return response_json, status_code

95
endpoints/api/tag.py Normal file
View file

@ -0,0 +1,95 @@
from flask import request
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, NotFound, validate_json_request)
from endpoints.api.image import image_view
from data import model
from auth.auth_context import get_authenticated_user
@resource('/v1/repository/<repopath:repository>/tag/<tag>')
class RepositoryTag(RepositoryParamResource):
""" Resource for managing repository tags. """
schemas = {
'MoveTag': {
'id': 'MoveTag',
'type': 'object',
'description': 'Description of to which image a new or existing tag should point',
'required': [
'image',
],
'properties': {
'image': {
'type': 'string',
'description': 'Image identifier to which the tag should point',
},
},
},
}
@require_repo_write
@nickname('changeTagImage')
@validate_json_request('MoveTag')
def put(self, namespace, repository, tag):
""" Change which image a tag points to or create a new tag."""
image_id = request.get_json()['image']
image = model.get_repo_image(namespace, repository, image_id)
if not image:
raise NotFound()
original_image_id = None
try:
original_tag_image = model.get_tag_image(namespace, repository, tag)
if original_tag_image:
original_image_id = original_tag_image.docker_image_id
except model.DataModelException:
# This is a new tag.
pass
model.create_or_update_tag(namespace, repository, tag, image_id)
model.garbage_collect_repository(namespace, repository)
username = get_authenticated_user().username
log_action('move_tag' if original_image_id else 'create_tag', namespace,
{ 'username': username, 'repo': repository, 'tag': tag,
'image': image_id, 'original_image': original_image_id },
repo=model.get_repository(namespace, repository))
return 'Updated', 201
@require_repo_write
@nickname('deleteFullTag')
def delete(self, namespace, repository, tag):
""" Delete the specified repository tag. """
model.delete_tag(namespace, repository, tag)
model.garbage_collect_repository(namespace, repository)
username = get_authenticated_user().username
log_action('delete_tag', namespace,
{'username': username, 'repo': repository, 'tag': tag},
repo=model.get_repository(namespace, repository))
return 'Deleted', 204
@resource('/v1/repository/<repopath:repository>/tag/<tag>/images')
class RepositoryTagImages(RepositoryParamResource):
""" Resource for listing the images in a specific repository tag. """
@require_repo_read
@nickname('listTagImages')
def get(self, namespace, repository, tag):
""" List the images for the specified repository tag. """
try:
tag_image = model.get_tag_image(namespace, repository, tag)
except model.DataModelException:
raise NotFound()
parent_images = model.get_parent_images(tag_image)
parents = list(parent_images)
parents.reverse()
all_images = [tag_image] + parents
return {
'images': [image_view(image) for image in all_images]
}

179
endpoints/api/team.py Normal file
View file

@ -0,0 +1,179 @@
from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, Unauthorized, NotFound, internal_only)
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
from auth.auth_context import get_authenticated_user
from data import model
def team_view(orgname, team):
view_permission = ViewTeamPermission(orgname, team.name)
role = model.get_team_org_role(team).name
return {
'id': team.id,
'name': team.name,
'description': team.description,
'can_view': view_permission.can(),
'role': role
}
def member_view(member):
return {
'name': member.username,
'kind': 'user',
'is_robot': member.robot,
}
@resource('/v1/organization/<orgname>/team/<teamname>')
@internal_only
class OrganizationTeam(ApiResource):
""" Resource for manging an organization's teams. """
schemas = {
'TeamDescription': {
'id': 'TeamDescription',
'type': 'object',
'description': 'Description of a team',
'required': [
'role',
],
'properties': {
'role': {
'type': 'string',
'description': 'Org wide permissions that should apply to the team',
'enum': [
'member',
'creator',
'admin',
],
},
'description': {
'type': 'string',
'description': 'Markdown description for the team',
},
},
},
}
@nickname('updateOrganizationTeam')
@validate_json_request('TeamDescription')
def put(self, orgname, teamname):
""" Update the org-wide permission for the specified team. """
edit_permission = AdministerOrganizationPermission(orgname)
if edit_permission.can():
team = None
details = request.get_json()
is_existing = False
try:
team = model.get_organization_team(orgname, teamname)
is_existing = True
except model.InvalidTeamException:
# Create the new team.
description = details['description'] if 'description' in details else ''
role = details['role'] if 'role' in details else 'member'
org = model.get_organization(orgname)
team = model.create_team(teamname, org, role, description)
log_action('org_create_team', orgname, {'team': teamname})
if is_existing:
if ('description' in details and
team.description != details['description']):
team.description = details['description']
team.save()
log_action('org_set_team_description', orgname,
{'team': teamname, 'description': team.description})
if 'role' in details:
role = model.get_team_org_role(team).name
if role != details['role']:
team = model.set_team_org_permission(team, details['role'],
get_authenticated_user().username)
log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']})
return team_view(orgname, team), 200
raise Unauthorized()
@nickname('deleteOrganizationTeam')
def delete(self, orgname, teamname):
""" Delete the specified team. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.remove_team(orgname, teamname, get_authenticated_user().username)
log_action('org_delete_team', orgname, {'team': teamname})
return 'Deleted', 204
raise Unauthorized()
@resource('/v1/organization/<orgname>/team/<teamname>/members')
@internal_only
class TeamMemberList(ApiResource):
""" Resource for managing the list of members for a team. """
@nickname('getOrganizationTeamMembers')
def get(self, orgname, teamname):
""" Retrieve the list of members for the specified team. """
view_permission = ViewTeamPermission(orgname, teamname)
edit_permission = AdministerOrganizationPermission(orgname)
if view_permission.can():
team = None
try:
team = model.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
members = model.get_organization_team_members(team.id)
return {
'members': {m.username : member_view(m) for m in members},
'can_edit': edit_permission.can()
}
raise Unauthorized()
@resource('/v1/organization/<orgname>/team/<teamname>/members/<membername>')
@internal_only
class TeamMember(ApiResource):
""" Resource for managing individual members of a team. """
@nickname('updateOrganizationTeamMember')
def put(self, orgname, teamname, membername):
""" Add a member to an existing team. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
team = None
user = None
# Find the team.
try:
team = model.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
# Find the user.
user = model.get_user(membername)
if not user:
raise request_error(message='Unknown user')
# Add the user to the team.
model.add_user_to_team(user, team)
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
return member_view(user)
raise Unauthorized()
@nickname('deleteOrganizationTeamMember')
def delete(self, orgname, teamname, membername):
""" Delete an existing member of a team. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
# Remote the user from the team.
invoking_user = get_authenticated_user().username
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
return 'Deleted', 204
raise Unauthorized()

267
endpoints/api/trigger.py Normal file
View file

@ -0,0 +1,267 @@
import json
import logging
from flask import request, url_for
from urllib import quote
from urlparse import urlunparse
from app import app
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, request_error, query_param, parse_args, internal_only,
validate_json_request, api, Unauthorized, NotFound, InvalidRequest)
from endpoints.api.build import (build_status_view, trigger_view, RepositoryBuildStatus,
get_trigger_config)
from endpoints.common import start_build
from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException,
TriggerActivationException, EmptyRepositoryException)
from data import model
from auth.permissions import UserAdminPermission
logger = logging.getLogger(__name__)
def _prepare_webhook_url(scheme, username, password, hostname, path):
auth_hostname = '%s:%s@%s' % (quote(username), quote(password), hostname)
return urlunparse((scheme, auth_hostname, path, '', '', ''))
@resource('/v1/repository/<repopath:repository>/trigger/')
class BuildTriggerList(RepositoryParamResource):
""" Resource for listing repository build triggers. """
@require_repo_admin
@nickname('listBuildTriggers')
def get(self, namespace, repository):
""" List the triggers for the specified repository. """
triggers = model.list_build_triggers(namespace, repository)
return {
'triggers': [trigger_view(trigger) for trigger in triggers]
}
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>')
class BuildTrigger(RepositoryParamResource):
""" Resource for managing specific build triggers. """
@require_repo_admin
@nickname('getBuildTrigger')
def get(self, namespace, repository, trigger_uuid):
""" Get information for the specified build trigger. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
return trigger_view(trigger)
@require_repo_admin
@nickname('deleteBuildTrigger')
def delete(self, namespace, repository, trigger_uuid):
""" Delete the specified build trigger. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
config_dict = get_trigger_config(trigger)
if handler.is_active(config_dict):
try:
handler.deactivate(trigger.auth_token, config_dict)
except TriggerDeactivationException as ex:
# We are just going to eat this error
logger.warning('Trigger deactivation problem: %s', ex)
log_action('delete_repo_trigger', namespace,
{'repo': repository, 'trigger_id': trigger_uuid,
'service': trigger.service.name, 'config': config_dict},
repo=model.get_repository(namespace, repository))
trigger.delete_instance(recursive=True)
return 'No Content', 204
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/subdir')
@internal_only
class BuildTriggerSubdirs(RepositoryParamResource):
""" Custom verb for fetching the subdirs which are buildable for a trigger. """
schemas = {
'BuildTriggerSubdirRequest': {
'id': 'BuildTriggerSubdirRequest',
'type': 'object',
'description': 'Arbitrary json.',
},
}
@require_repo_admin
@nickname('listBuildTriggerSubdirs')
@validate_json_request('BuildTriggerSubdirRequest')
def post(self, namespace, repository, trigger_uuid):
""" List the subdirectories available for the specified build trigger and source. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
user_permission = UserAdminPermission(trigger.connected_user.username)
if user_permission.can():
new_config_dict = request.get_json()
try:
subdirs = handler.list_build_subdirs(trigger.auth_token, new_config_dict)
return {
'subdir': subdirs,
'status': 'success'
}
except EmptyRepositoryException as exc:
return {
'status': 'error',
'message': exc.msg
}
else:
raise Unauthorized()
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/activate')
@internal_only
class BuildTriggerActivate(RepositoryParamResource):
""" Custom verb for activating a build trigger once all required information has been collected.
"""
schemas = {
'BuildTriggerActivateRequest': {
'id': 'BuildTriggerActivateRequest',
'type': 'object',
'description': 'Arbitrary json.',
},
}
@require_repo_admin
@nickname('activateBuildTrigger')
@validate_json_request('BuildTriggerActivateRequest')
def post(self, namespace, repository, trigger_uuid):
""" Activate the specified build trigger. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
existing_config_dict = get_trigger_config(trigger)
if handler.is_active(existing_config_dict):
raise InvalidRequest('Trigger config is not sufficient for activation.')
user_permission = UserAdminPermission(trigger.connected_user.username)
if user_permission.can():
new_config_dict = request.get_json()
token_name = 'Build Trigger: %s' % trigger.service.name
token = model.create_delegate_token(namespace, repository, token_name,
'write')
try:
repository_path = '%s/%s' % (trigger.repository.namespace,
trigger.repository.name)
path = url_for('webhooks.build_trigger_webhook',
repository=repository_path, trigger_uuid=trigger.uuid)
authed_url = _prepare_webhook_url(app.config['URL_SCHEME'], '$token',
token.code, app.config['URL_HOST'],
path)
final_config = handler.activate(trigger.uuid, authed_url,
trigger.auth_token, new_config_dict)
except TriggerActivationException as exc:
token.delete_instance()
raise request_error(message=exc.message)
# Save the updated config.
trigger.config = json.dumps(final_config)
trigger.write_token = token
trigger.save()
# Log the trigger setup.
repo = model.get_repository(namespace, repository)
log_action('setup_repo_trigger', namespace,
{'repo': repository, 'namespace': namespace,
'trigger_id': trigger.uuid, 'service': trigger.service.name,
'config': final_config}, repo=repo)
return trigger_view(trigger)
else:
raise Unauthorized()
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/start')
class ActivateBuildTrigger(RepositoryParamResource):
""" Custom verb to manually activate a build trigger. """
@require_repo_admin
@nickname('manuallyStartBuildTrigger')
def post(self, namespace, repository, trigger_uuid):
""" Manually start a build from the specified trigger. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
config_dict = get_trigger_config(trigger)
if not handler.is_active(config_dict):
raise InvalidRequest('Trigger is not active.')
specs = handler.manual_start(trigger.auth_token, config_dict)
dockerfile_id, tags, name, subdir = specs
repo = model.get_repository(namespace, repository)
build_request = start_build(repo, dockerfile_id, tags, name, subdir, True)
resp = build_status_view(build_request, True)
repo_string = '%s/%s' % (namespace, repository)
headers = {
'Location': api.url_for(RepositoryBuildStatus, repository=repo_string,
build_uuid=build_request.uuid),
}
return resp, 201, headers
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/builds')
class TriggerBuildList(RepositoryParamResource):
""" Resource to represent builds that were activated from the specified trigger. """
@require_repo_admin
@parse_args
@query_param('limit', 'The maximum number of builds to return', type=int, default=5)
@nickname('listTriggerRecentBuilds')
def get(self, args, namespace, repository, trigger_uuid):
""" List the builds started by the specified trigger. """
limit = args['limit']
builds = list(model.list_trigger_builds(namespace, repository,
trigger_uuid, limit))
return {
'builds': [build_status_view(build, True) for build in builds]
}
@resource('/v1/repository/<repopath:repository>/trigger/<trigger_uuid>/sources')
@internal_only
class BuildTriggerSources(RepositoryParamResource):
""" Custom verb to fetch the list of build sources for the trigger config. """
@require_repo_admin
@nickname('listTriggerBuildSources')
def get(self, namespace, repository, trigger_uuid):
""" List the build sources for the trigger configuration thus far. """
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
user_permission = UserAdminPermission(trigger.connected_user.username)
if user_permission.can():
trigger_handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name)
return {
'sources': trigger_handler.list_build_sources(trigger.auth_token)
}
else:
raise Unauthorized()

450
endpoints/api/user.py Normal file
View file

@ -0,0 +1,450 @@
import logging
import stripe
import json
from flask import request
from flask.ext.login import logout_user
from flask.ext.principal import identity_changed, AnonymousIdentity
from app import app
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin,
InvalidToken, require_scope, format_date)
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
from data import model
from data.plans import get_plan
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
UserAdminPermission, UserReadPermission)
from auth.auth_context import get_authenticated_user
from auth import scopes
from util.gravatar import compute_hash
from util.email import (send_confirmation_email, send_recovery_email,
send_change_email)
logger = logging.getLogger(__name__)
def user_view(user):
def org_view(o):
admin_org = AdministerOrganizationPermission(o.username)
return {
'name': o.username,
'gravatar': compute_hash(o.email),
'is_org_admin': admin_org.can(),
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(),
'preferred_namespace': not (o.stripe_id is None)
}
organizations = model.get_user_organizations(user.username)
def login_view(login):
return {
'service': login.service.name,
'service_identifier': login.service_ident,
}
logins = model.list_federated_logins(user)
user_response = {
'verified': user.verified,
'anonymous': False,
'username': user.username,
'email': user.email,
'gravatar': compute_hash(user.email),
}
user_admin = UserAdminPermission(user.username)
if user_admin.can():
user_response.update({
'organizations': [org_view(o) for o in organizations],
'logins': [login_view(login) for login in logins],
'can_create_repo': True,
'invoice_email': user.invoice_email,
'preferred_namespace': not (user.stripe_id is None),
})
return user_response
def notification_view(notification):
return {
'organization': notification.target.username if notification.target.organization else None,
'kind': notification.kind.name,
'created': format_date(notification.created),
'metadata': json.loads(notification.metadata_json),
}
@resource('/v1/user/')
class User(ApiResource):
""" Operations related to users. """
schemas = {
'NewUser': {
'id': 'NewUser',
'type': 'object',
'description': 'Fields which must be specified for a new user.',
'required': [
'username',
'password',
'email',
],
'properties': {
'username': {
'type': 'string',
'description': 'The user\'s username',
},
'password': {
'type': 'string',
'description': 'The user\'s password',
},
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
}
},
'UpdateUser': {
'id': 'UpdateUser',
'type': 'object',
'description': 'Fields which can be updated in a user.',
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
'invoice_email': {
'type': 'boolean',
'description': 'Whether the user desires to receive an invoice email.',
},
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
},
},
}
@require_scope(scopes.READ_USER)
@nickname('getLoggedInUser')
def get(self):
""" Get user information for the authenticated user. """
user = get_authenticated_user()
if user is None or user.organization or not UserReadPermission(user.username).can():
raise InvalidToken("Requires authentication", payload={'session_required': False})
return user_view(user)
@require_user_admin
@nickname('changeUserDetails')
@internal_only
@validate_json_request('UpdateUser')
def put(self):
""" Update a users details such as password or email. """
user = get_authenticated_user()
user_data = request.get_json()
try:
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', 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'])
if 'email' in user_data and user_data['email'] != user.email:
new_email = user_data['email']
if model.find_user_by_email(new_email):
# Email already used.
raise request_error(message='E-mail address already used')
logger.debug('Sending email to change email address for user: %s',
user.username)
code = model.create_confirm_email_code(user, new_email=new_email)
send_change_email(user.username, user_data['email'], code.code)
except model.InvalidPasswordException, ex:
raise request_error(exception=ex)
return user_view(user)
@nickname('createNewUser')
@internal_only
@validate_json_request('NewUser')
def post(self):
""" Create a new user. """
user_data = request.get_json()
existing_user = model.get_user(user_data['username'])
if existing_user:
raise request_error(message='The username already exists')
try:
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 'Created', 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource('/v1/user/private')
@internal_only
class PrivateRepositories(ApiResource):
""" Operations dealing with the available count of private repositories. """
@require_user_admin
@nickname('getUserPrivateAllowed')
def get(self):
""" Get the number of private repos this user has, and whether they are allowed to create more.
"""
user = get_authenticated_user()
private_repos = model.get_private_repo_count(user.username)
repos_allowed = 0
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan['privateRepos']
return {
'privateCount': private_repos,
'privateAllowed': (private_repos < repos_allowed)
}
def conduct_signin(username_or_email, password):
needs_email_verification = False
invalid_credentials = False
verified = model.verify_user(username_or_email, password)
if verified:
if common_login(verified):
return {'success': True}
else:
needs_email_verification = True
else:
invalid_credentials = True
return {
'needsEmailVerification': needs_email_verification,
'invalidCredentials': invalid_credentials,
}, 403
@resource('/v1/user/convert')
@internal_only
class ConvertToOrganization(ApiResource):
""" Operations for converting a user to an organization. """
schemas = {
'ConvertUser': {
'id': 'ConvertUser',
'type': 'object',
'description': 'Information required to convert a user to an organization.',
'required': [
'adminUser',
'adminPassword',
'plan',
],
'properties': {
'adminUser': {
'type': 'string',
'description': 'The user who will become an org admin\'s username',
},
'adminPassword': {
'type': 'string',
'description': 'The user who will become an org admin\'s password',
},
'plan': {
'type': 'string',
'description': 'The plan to which the organizatino should be subscribed',
},
},
},
}
@require_user_admin
@nickname('convertUserToOrganization')
@validate_json_request('ConvertUser')
def post(self):
""" Convert the user to an organization. """
user = get_authenticated_user()
convert_data = request.get_json()
# Ensure that the new admin user is the not user being converted.
admin_username = convert_data['adminUser']
if admin_username == user.username:
raise request_error(reason='invaliduser',
message='The admin user is not valid')
# Ensure that the sign in credentials work.
admin_password = convert_data['adminPassword']
if not model.verify_user(admin_username, admin_password):
raise request_error(reason='invaliduser',
message='The admin user credentials are not valid')
# Subscribe the organization to the new plan.
plan = convert_data['plan']
subscribe(user, plan, None, True) # Require business plans
# Convert the user to an organization.
model.convert_user_to_organization(user, model.get_user(admin_username))
log_action('account_convert', user.username)
# And finally login with the admin credentials.
return conduct_signin(admin_username, admin_password)
@resource('/v1/signin')
@internal_only
class Signin(ApiResource):
""" Operations for signing in the user. """
schemas = {
'SigninUser': {
'id': 'SigninUser',
'type': 'object',
'description': 'Information required to sign in a user.',
'required': [
'username',
'password',
],
'properties': {
'username': {
'type': 'string',
'description': 'The user\'s username',
},
'password': {
'type': 'string',
'description': 'The user\'s password',
},
},
},
}
@nickname('signinUser')
@validate_json_request('SigninUser')
def post(self):
""" Sign in the user with the specified credentials. """
signin_data = request.get_json()
if not signin_data:
raise NotFound()
username = signin_data['username']
password = signin_data['password']
return conduct_signin(username, password)
@resource('/v1/signout')
@internal_only
class Signout(ApiResource):
""" Resource for signing out users. """
@nickname('logout')
def post(self):
""" Request that the current user be signed out. """
logout_user()
identity_changed.send(app, identity=AnonymousIdentity())
return {'success': True}
@resource("/v1/recovery")
@internal_only
class Recovery(ApiResource):
""" Resource for requesting a password recovery email. """
schemas = {
'RequestRecovery': {
'id': 'RequestRecovery',
'type': 'object',
'description': 'Information required to sign in a user.',
'required': [
'email',
],
'properties': {
'email': {
'type': 'string',
'description': 'The user\'s email address',
},
},
},
}
@nickname('requestRecoveryEmail')
@validate_json_request('RequestRecovery')
def post(self):
""" Request a password recovery email."""
email = request.get_json()['email']
code = model.create_reset_password_email_code(email)
send_recovery_email(email, code.code)
return 'Created', 201
@resource('/v1/user/notifications')
@internal_only
class UserNotificationList(ApiResource):
@require_user_admin
@nickname('listUserNotifications')
def get(self):
notifications = model.list_notifications(get_authenticated_user())
return {
'notifications': [notification_view(notification) for notification in notifications]
}
def authorization_view(access_token):
oauth_app = access_token.application
return {
'application': {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email),
'organization': {
'name': oauth_app.organization.username,
'gravatar': compute_hash(oauth_app.organization.email)
}
},
'scopes': scopes.get_scope_information(access_token.scope),
'uuid': access_token.uuid
}
@resource('/v1/user/authorizations')
@internal_only
class UserAuthorizationList(ApiResource):
@require_user_admin
@nickname('listUserAuthorizations')
def get(self):
access_tokens = model.oauth.list_access_tokens_for_user(get_authenticated_user())
return {
'authorizations': [authorization_view(token) for token in access_tokens]
}
@resource('/v1/user/authorizations/<access_token_uuid>')
@internal_only
class UserAuthorization(ApiResource):
@require_user_admin
@nickname('getUserAuthorization')
def get(self, access_token_uuid):
access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(),
access_token_uuid)
if not access_token:
raise NotFound()
return authorization_view(access_token)
@require_user_admin
@nickname('deleteUserAuthorization')
def delete(self, access_token_uuid):
access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(),
access_token_uuid)
if not access_token:
raise NotFound()
access_token.delete_instance(recursive=True, delete_nullable=True)
return 'Deleted', 204

77
endpoints/api/webhook.py Normal file
View file

@ -0,0 +1,77 @@
import json
from flask import request
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, validate_json_request, api, NotFound)
from data import model
def webhook_view(webhook):
return {
'public_id': webhook.public_id,
'parameters': json.loads(webhook.parameters),
}
@resource('/v1/repository/<repopath:repository>/webhook/')
class WebhookList(RepositoryParamResource):
""" Resource for dealing with listing and creating webhooks. """
schemas = {
'WebhookCreateRequest': {
'id': 'WebhookCreateRequest',
'type': 'object',
'description': 'Arbitrary json.',
},
}
@require_repo_admin
@nickname('createWebhook')
@validate_json_request('WebhookCreateRequest')
def post(self, namespace, repository):
""" Create a new webhook for the specified repository. """
repo = model.get_repository(namespace, repository)
webhook = model.create_webhook(repo, request.get_json())
resp = webhook_view(webhook)
repo_string = '%s/%s' % (namespace, repository)
headers = {
'Location': api.url_for(Webhook, repository=repo_string, public_id=webhook.public_id),
}
log_action('add_repo_webhook', namespace,
{'repo': repository, 'webhook_id': webhook.public_id},
repo=repo)
return resp, 201, headers
@require_repo_admin
@nickname('listWebhooks')
def get(self, namespace, repository):
""" List the webhooks for the specified repository. """
webhooks = model.list_webhooks(namespace, repository)
return {
'webhooks': [webhook_view(webhook) for webhook in webhooks]
}
@resource('/v1/repository/<repopath:repository>/webhook/<public_id>')
class Webhook(RepositoryParamResource):
""" Resource for dealing with specific webhooks. """
@require_repo_admin
@nickname('getWebhook')
def get(self, namespace, repository, public_id):
""" Get information for the specified webhook. """
try:
webhook = model.get_webhook(namespace, repository, public_id)
except model.InvalidWebhookException:
raise NotFound()
return webhook_view(webhook)
@require_repo_admin
@nickname('deleteWebhook')
def delete(self, namespace, repository, public_id):
""" Delete the specified webhook. """
model.delete_webhook(namespace, repository, public_id)
log_action('delete_repo_webhook', namespace,
{'repo': repository, 'webhook_id': public_id},
repo=model.get_repository(namespace, repository))
return 'No Content', 204

133
endpoints/callbacks.py Normal file
View file

@ -0,0 +1,133 @@
import logging
from flask import request, redirect, url_for, Blueprint
from flask.ext.login import current_user
from endpoints.common import render_page_template, common_login
from app import app, mixpanel
from data import model
from util.names import parse_repository_name
from util.http import abort
from auth.permissions import AdministerRepositoryPermission
from auth.auth import require_session_login
logger = logging.getLogger(__name__)
client = app.config['HTTPCLIENT']
callback = Blueprint('callback', __name__)
def exchange_github_code_for_token(code):
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 = client.post(app.config['GITHUB_TOKEN_URL'],
params=payload, headers=headers)
token = get_access_token.json()['access_token']
return token
def get_github_user(token):
token_param = {
'access_token': token,
}
get_user = client.get(app.config['GITHUB_USER_URL'], params=token_param)
return get_user.json()
@callback.route('/github/callback', methods=['GET'])
def github_oauth_callback():
error = request.args.get('error', None)
if error:
return render_page_template('githuberror.html', error_message=error)
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
username = user_data['login']
github_id = user_data['id']
v3_media_type = {
'Accept': 'application/vnd.github.v3'
}
token_param = {
'access_token': token,
}
get_email = client.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
try:
to_login = model.create_federated_user(username, found_email, 'github',
github_id)
# Success, tell mixpanel
mixpanel.track(to_login.username, 'register', {'service': 'github'})
state = request.args.get('state', None)
if state:
logger.debug('Aliasing with state: %s' % state)
mixpanel.alias(to_login.username, state)
except model.DataModelException, ex:
return render_page_template('githuberror.html', error_message=ex.message)
if common_login(to_login):
return redirect(url_for('web.index'))
return render_page_template('githuberror.html')
@callback.route('/github/callback/attach', methods=['GET'])
@require_session_login
def github_oauth_attach():
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
github_id = user_data['id']
user_obj = current_user.db_user()
model.attach_federated_login(user_obj, 'github', github_id)
return redirect(url_for('web.user'))
@callback.route('/github/callback/trigger/<path:repository>', methods=['GET'])
@require_session_login
@parse_repository_name
def attach_github_build_trigger(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
token = exchange_github_code_for_token(request.args.get('code'))
repo = model.get_repository(namespace, repository)
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace, repository)
abort(404, message=msg)
trigger = model.create_build_trigger(repo, 'github', token, current_user.db_user())
admin_path = '%s/%s/%s' % (namespace, repository, 'admin')
full_url = '%s%s%s' % (url_for('web.repository', path=admin_path), '?tab=trigger&new_trigger=',
trigger.uuid)
logger.debug('Redirecting to full url: %s' % full_url)
return redirect(full_url)
abort(403)

View file

@ -1,18 +1,41 @@
import logging import logging
import os import urlparse
import base64 import json
import string
from flask import request, abort, session, make_response from flask import make_response, render_template, request
from flask.ext.login import login_user, UserMixin from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed from flask.ext.principal import identity_changed
from random import SystemRandom
from data import model from data import model
from data.queue import dockerfile_build_queue
from app import app, login_manager from app import app, login_manager
from auth.permissions import QuayDeferredPermissionUser from auth.permissions import QuayDeferredPermissionUser
from auth import scopes
from endpoints.api.discovery import swagger_route_data
from werkzeug.routing import BaseConverter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
route_data = None
class RepoPathConverter(BaseConverter):
regex = '[\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+'
weight = 200
app.url_map.converters['repopath'] = RepoPathConverter
def get_route_data():
global route_data
if route_data:
return route_data
route_data = swagger_route_data(include_internal=True, compact=True)
return route_data
def truthy_param(param): def truthy_param(param):
return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'} return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'}
@ -20,9 +43,10 @@ def truthy_param(param):
@login_manager.user_loader @login_manager.user_loader
def load_user(username): def load_user(username):
logger.debug('Loading user: %s' % username) logger.debug('User loader loading deferred user: %s' % username)
return _LoginWrappedDBUser(username) return _LoginWrappedDBUser(username)
class _LoginWrappedDBUser(UserMixin): class _LoginWrappedDBUser(UserMixin):
def __init__(self, db_username, db_user=None): def __init__(self, db_username, db_user=None):
@ -47,7 +71,7 @@ class _LoginWrappedDBUser(UserMixin):
def common_login(db_user): def common_login(db_user):
if login_user(_LoginWrappedDBUser(db_user.username, db_user)): if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
logger.debug('Successfully signed in as: %s' % db_user.username) logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username') new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
return True return True
else: else:
@ -58,19 +82,68 @@ def common_login(db_user):
@app.errorhandler(model.DataModelException) @app.errorhandler(model.DataModelException)
def handle_dme(ex): def handle_dme(ex):
logger.exception(ex) logger.exception(ex)
return make_response(ex.message, 400) return make_response(json.dumps({'message': ex.message}), 400)
@app.errorhandler(KeyError) def random_string():
def handle_dme_key_error(ex): random = SystemRandom()
logger.exception(ex) return ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(8)])
return make_response('Invalid key: %s' % ex.message, 400)
def render_page_template(name, **kwargs):
resp = make_response(render_template(name, route_data=json.dumps(get_route_data()),
cache_buster=random_string(), **kwargs))
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp
def generate_csrf_token(): def check_repository_usage(user_or_org, plan_found):
if '_csrf_token' not in session: private_repos = model.get_private_repo_count(user_or_org.username)
session['_csrf_token'] = base64.b64encode(os.urandom(48)) repos_allowed = plan_found['privateRepos']
return session['_csrf_token'] if private_repos > repos_allowed:
model.create_notification('over_private_usage', user_or_org, {'namespace': user_or_org.username})
else:
model.delete_notifications_by_kind(user_or_org, 'over_private_usage')
app.jinja_env.globals['csrf_token'] = generate_csrf_token
def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
trigger=None):
host = urlparse.urlparse(request.url).netloc
repo_path = '%s/%s/%s' % (host, repository.namespace, repository.name)
token = model.create_access_token(repository, 'write')
logger.debug('Creating build %s with repo %s tags %s and dockerfile_id %s',
build_name, repo_path, tags, dockerfile_id)
job_config = {
'docker_tags': tags,
'repository': repo_path,
'build_subdir': subdir,
}
build_request = model.create_repository_build(repository, token, job_config,
dockerfile_id, build_name,
trigger)
dockerfile_build_queue.put(json.dumps({
'build_uuid': build_request.uuid,
'namespace': repository.namespace,
'repository': repository.name,
}), retries_remaining=1)
metadata = {
'repo': repository.name,
'namespace': repository.namespace,
'fileid': dockerfile_id,
'manual': manual,
}
if trigger:
metadata['trigger_id'] = trigger.uuid
metadata['config'] = json.loads(trigger.config)
metadata['service'] = trigger.service.name
model.log_action('build_dockerfile', repository.namespace,
ip=request.remote_addr, metadata=metadata,
repository=repository)
return build_request

40
endpoints/csrf.py Normal file
View file

@ -0,0 +1,40 @@
import logging
import os
import base64
from flask import session, request
from functools import wraps
from app import app
from auth.auth_context import get_validated_oauth_token
from util.http import abort
logger = logging.getLogger(__name__)
def generate_csrf_token():
if '_csrf_token' not in session:
session['_csrf_token'] = base64.b64encode(os.urandom(48))
return session['_csrf_token']
def csrf_protect(func):
@wraps(func)
def wrapper(*args, **kwargs):
oauth_token = get_validated_oauth_token()
if oauth_token is None and request.method != "GET" and request.method != "HEAD":
token = session.get('_csrf_token', None)
found_token = request.values.get('_csrf_token', None)
if not token or token != found_token:
msg = 'CSRF Failure. Session token was %s and request token was %s'
logger.error(msg, token, found_token)
abort(403, message='CSRF token was invalid or missing.')
return func(*args, **kwargs)
return wrapper
app.jinja_env.globals['csrf_token'] = generate_csrf_token

View file

@ -6,16 +6,16 @@ from flask import request, make_response, jsonify, session, Blueprint
from functools import wraps from functools import wraps
from collections import OrderedDict from collections import OrderedDict
from data import model, userevent from data import model
from data.model import oauth
from data.queue import webhook_queue from data.queue import webhook_queue
from app import mixpanel, app from app import mixpanel, app
from auth.auth import process_auth from auth.auth import process_auth
from auth.auth_context import get_authenticated_user, get_validated_token from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
from util.names import parse_repository_name from util.names import parse_repository_name
from util.email import send_confirmation_email from util.email import send_confirmation_email
from auth.permissions import (ModifyRepositoryPermission, UserPermission, from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
ReadRepositoryPermission, ReadRepositoryPermission, CreateRepositoryPermission)
CreateRepositoryPermission)
from util.http import abort from util.http import abort
@ -79,6 +79,13 @@ def create_user():
except model.InvalidTokenException: except model.InvalidTokenException:
abort(400, 'Invalid access token.', issue='invalid-access-token') abort(400, 'Invalid access token.', issue='invalid-access-token')
elif username == '$oauthtoken':
validated = oauth.validate_access_token(password)
if validated is not None:
return success
else:
abort(400, 'Invalid oauth access token.', issue='invalid-oauth-access-token')
elif '+' in username: elif '+' in username:
try: try:
model.verify_robot(username, password) model.verify_robot(username, password)
@ -115,7 +122,12 @@ def create_user():
@index.route('/users/', methods=['GET']) @index.route('/users/', methods=['GET'])
@process_auth @process_auth
def get_user(): def get_user():
if get_authenticated_user(): if get_validated_oauth_token():
return jsonify({
'username': '$oauthtoken',
'email': None,
})
elif get_authenticated_user():
return jsonify({ return jsonify({
'username': get_authenticated_user().username, 'username': get_authenticated_user().username,
'email': get_authenticated_user().email, 'email': get_authenticated_user().email,
@ -131,7 +143,7 @@ def get_user():
@index.route('/users/<username>/', methods=['PUT']) @index.route('/users/<username>/', methods=['PUT'])
@process_auth @process_auth
def update_user(username): def update_user(username):
permission = UserPermission(username) permission = UserAdminPermission(username)
if permission.can(): if permission.can():
update_request = request.get_json() update_request = request.get_json()
@ -214,7 +226,14 @@ def create_repository(namespace, repository):
'namespace': namespace 'namespace': namespace
} }
if get_authenticated_user(): if get_validated_oauth_token():
mixpanel.track(username, 'push_repo', extra_params)
oauth_token = get_validated_oauth_token()
metadata['oauth_token_id'] = oauth_token.id
metadata['oauth_token_application_id'] = oauth_token.application.client_id
metadata['oauth_token_application'] = oauth_token.application.name
elif get_authenticated_user():
username = get_authenticated_user().username username = get_authenticated_user().username
mixpanel.track(username, 'push_repo', extra_params) mixpanel.track(username, 'push_repo', extra_params)
@ -230,7 +249,7 @@ def create_repository(namespace, repository):
event = app.config['USER_EVENTS'].get_event(username) event = app.config['USER_EVENTS'].get_event(username)
event.publish_event_data('docker-cli', user_data) event.publish_event_data('docker-cli', user_data)
else: elif get_validated_token():
mixpanel.track(get_validated_token().code, 'push_repo', extra_params) mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
metadata['token'] = get_validated_token().friendly_name metadata['token'] = get_validated_token().friendly_name
metadata['token_code'] = get_validated_token().code metadata['token_code'] = get_validated_token().code
@ -333,7 +352,13 @@ def get_repository_images(namespace, repository):
'repo': repository, 'repo': repository,
'namespace': namespace, 'namespace': namespace,
} }
if get_authenticated_user():
if get_validated_oauth_token():
oauth_token = get_validated_oauth_token()
metadata['oauth_token_id'] = oauth_token.id
metadata['oauth_token_application_id'] = oauth_token.application.client_id
metadata['oauth_token_application'] = oauth_token.application.name
elif get_authenticated_user():
metadata['username'] = get_authenticated_user().username metadata['username'] = get_authenticated_user().username
elif get_validated_token(): elif get_validated_token():
metadata['token'] = get_validated_token().friendly_name metadata['token'] = get_validated_token().friendly_name

View file

@ -1,71 +1,52 @@
import logging import logging
import redis
import json import json
from functools import wraps from flask import request, Blueprint, abort, Response
from flask import request, make_response, Blueprint, abort, Response from flask.ext.login import current_user
from flask.ext.login import current_user, logout_user from data import userevent
from data import model, userevent from auth.auth import require_session_login
from app import app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
realtime = Blueprint('realtime', __name__) realtime = Blueprint('realtime', __name__)
def api_login_required(f):
@wraps(f)
def decorated_view(*args, **kwargs):
if not current_user.is_authenticated():
abort(401)
if (current_user and current_user.db_user() and
current_user.db_user().organization):
abort(401)
if (current_user and current_user.db_user() and
current_user.db_user().robot):
abort(401)
return f(*args, **kwargs)
return decorated_view
@realtime.route("/user/") @realtime.route("/user/")
@api_login_required @require_session_login
def index(): def index():
debug_template = """ debug_template = """
<html> <html>
<head> <head>
</head> </head>
<body> <body>
<h1>Server sent events</h1> <h1>Server sent events</h1>
<div id="event"></div> <div id="event"></div>
<script type="text/javascript"> <script type="text/javascript">
var eventOutputContainer = document.getElementById("event"); var eventOutputContainer = document.getElementById("event");
var evtSrc = new EventSource("/realtime/user/subscribe?events=docker-cli"); var evtSrc = new EventSource("/realtime/user/subscribe?events=docker-cli");
evtSrc.onmessage = function(e) { evtSrc.onmessage = function(e) {
console.log(e.data); console.log(e.data);
eventOutputContainer.innerHTML = e.data; eventOutputContainer.innerHTML = e.data;
}; };
</script> </script>
</body> </body>
</html> </html>
""" """
return(debug_template) return(debug_template)
@realtime.route("/user/test") @realtime.route("/user/test")
@api_login_required @require_session_login
def user_test(): def user_test():
evt = userevent.UserEvent('logs.quay.io', current_user.db_user().username) evt = userevent.UserEvent('logs.quay.io', current_user.db_user().username)
evt.publish_event_data('test', {'foo': 2}) evt.publish_event_data('test', {'foo': 2})
return 'OK' return 'OK'
@realtime.route("/user/subscribe") @realtime.route("/user/subscribe")
@api_login_required @require_session_login
def user_subscribe(): def user_subscribe():
def wrapper(listener): def wrapper(listener):
for event_id, data in listener.event_stream(): for event_id, data in listener.event_stream():

View file

@ -2,7 +2,7 @@ import logging
import json import json
from flask import (make_response, request, session, Response, redirect, from flask import (make_response, request, session, Response, redirect,
Blueprint) Blueprint, abort as flask_abort)
from functools import wraps from functools import wraps
from datetime import datetime from datetime import datetime
from time import time from time import time
@ -259,7 +259,7 @@ def get_image_json(namespace, repository, image_id, headers):
data = store.get_content(store.image_json_path(namespace, repository, data = store.get_content(store.image_json_path(namespace, repository,
image_id, uuid)) image_id, uuid))
except IOError: except IOError:
abort(404, message='Image data not found') flask_abort(404)
try: try:
size = store.get_size(store.image_layer_path(namespace, repository, size = store.get_size(store.image_layer_path(namespace, repository,

286
endpoints/trigger.py Normal file
View file

@ -0,0 +1,286 @@
import logging
import io
import os.path
import zipfile
from github import Github, UnknownObjectException, GithubException
from tempfile import SpooledTemporaryFile
from app import app
user_files = app.config['USERFILES']
client = app.config['HTTPCLIENT']
logger = logging.getLogger(__name__)
ZIPBALL = 'application/zip'
CHUNK_SIZE = 512 * 1024
class BuildArchiveException(Exception):
pass
class InvalidServiceException(Exception):
pass
class TriggerActivationException(Exception):
pass
class TriggerDeactivationException(Exception):
pass
class ValidationRequestException(Exception):
pass
class EmptyRepositoryException(Exception):
pass
class BuildTrigger(object):
def __init__(self):
pass
def list_build_sources(self, auth_token):
"""
Take the auth information for the specific trigger type and load the
list of build sources(repositories).
"""
raise NotImplementedError
def list_build_subdirs(self, auth_token, config):
"""
Take the auth information and the specified config so far and list all of
the possible subdirs containing dockerfiles.
"""
raise NotImplementedError
def handle_trigger_request(self, request, auth_token, config):
"""
Transform the incoming request data into a set of actions. Returns a tuple
of usefiles resource id, docker tags, build name, and resource subdir.
"""
raise NotImplementedError
def is_active(self, config):
"""
Returns True if the current build trigger is active. Inactive means further
setup is needed.
"""
raise NotImplementedError
def activate(self, trigger_uuid, standard_webhook_url, auth_token, config):
"""
Activates the trigger for the service, with the given new configuration.
Returns new configuration that should be stored if successful.
"""
raise NotImplementedError
def deactivate(self, auth_token, config):
"""
Deactivates the trigger for the service, removing any hooks installed in
the remote service. Returns the new config that should be stored if this
trigger is going to be re-activated.
"""
raise NotImplementedError
def manual_start(self, auth_token, config):
"""
Manually creates a repository build for this trigger.
"""
raise NotImplementedError
@classmethod
def service_name(cls):
"""
Particular service implemented by subclasses.
"""
raise NotImplementedError
@classmethod
def get_trigger_for_service(cls, service):
for subc in cls.__subclasses__():
if subc.service_name() == service:
return subc()
raise InvalidServiceException('Unable to find service: %s' % service)
def raise_unsupported():
raise io.UnsupportedOperation
class GithubBuildTrigger(BuildTrigger):
@staticmethod
def _get_client(auth_token):
return Github(auth_token, client_id=app.config['GITHUB_CLIENT_ID'],
client_secret=app.config['GITHUB_CLIENT_SECRET'])
@classmethod
def service_name(cls):
return 'github'
def is_active(self, config):
return 'hook_id' in config
def activate(self, trigger_uuid, standard_webhook_url, auth_token, config):
new_build_source = config['build_source']
gh_client = self._get_client(auth_token)
try:
to_add_webhook = gh_client.get_repo(new_build_source)
except UnknownObjectException:
msg = 'Unable to find GitHub repository for source: %s'
raise TriggerActivationException(msg % new_build_source)
webhook_config = {
'url': standard_webhook_url,
'content_type': 'json',
}
try:
hook = to_add_webhook.create_hook('web', webhook_config)
config['hook_id'] = hook.id
config['master_branch'] = to_add_webhook.master_branch
except GithubException:
msg = 'Unable to create webhook on repository: %s'
raise TriggerActivationException(msg % new_build_source)
return config
def deactivate(self, auth_token, config):
gh_client = self._get_client(auth_token)
try:
repo = gh_client.get_repo(config['build_source'])
to_delete = repo.get_hook(config['hook_id'])
to_delete.delete()
except GithubException:
msg = 'Unable to remove hook: %s' % config['hook_id']
raise TriggerDeactivationException(msg)
config.pop('hook_id', None)
return config
def list_build_sources(self, auth_token):
gh_client = self._get_client(auth_token)
usr = gh_client.get_user()
personal = {
'personal': True,
'repos': [repo.full_name for repo in usr.get_repos()],
'info': {
'name': usr.login,
'avatar_url': usr.avatar_url,
}
}
repos_by_org = [personal]
for org in usr.get_orgs():
repo_list = []
for repo in org.get_repos(type='member'):
repo_list.append(repo.full_name)
repos_by_org.append({
'personal': False,
'repos': repo_list,
'info': {
'name': org.name,
'avatar_url': org.avatar_url
}
})
return repos_by_org
def list_build_subdirs(self, auth_token, config):
gh_client = self._get_client(auth_token)
source = config['build_source']
try:
repo = gh_client.get_repo(source)
default_commit = repo.get_branch(repo.master_branch or 'master').commit
commit_tree = repo.get_git_tree(default_commit.sha, recursive=True)
return [os.path.dirname(elem.path) for elem in commit_tree.tree
if (elem.type == u'blob' and
os.path.basename(elem.path) == u'Dockerfile')]
except GithubException:
msg = 'Unable to list contents of repository: %s' % source
raise EmptyRepositoryException(msg)
@staticmethod
def _prepare_build(config, repo, commit_sha, build_name, ref):
# Prepare the download and upload URLs
archive_link = repo.get_archive_link('zipball', commit_sha)
download_archive = client.get(archive_link, stream=True)
zipball_subdir = ''
with SpooledTemporaryFile(CHUNK_SIZE) as zipball:
for chunk in download_archive.iter_content(CHUNK_SIZE):
zipball.write(chunk)
# Pull out the name of the subdir that GitHub generated
with zipfile.ZipFile(zipball) as archive:
zipball_subdir = archive.namelist()[0]
dockerfile_id = user_files.store_file(zipball, ZIPBALL)
logger.debug('Successfully prepared job')
# compute the tag(s)
branch = ref.split('/')[-1]
tags = {branch}
if branch == repo.master_branch:
tags.add('latest')
logger.debug('Pushing to tags: %s' % tags)
# compute the subdir
repo_subdir = config['subdir']
joined_subdir = os.path.join(zipball_subdir, repo_subdir)
logger.debug('Final subdir: %s' % joined_subdir)
return dockerfile_id, list(tags), build_name, joined_subdir
@staticmethod
def get_display_name(sha):
return sha[0:7]
def handle_trigger_request(self, request, auth_token, config):
payload = request.get_json()
if 'zen' in payload:
raise ValidationRequestException()
logger.debug('Payload %s', payload)
ref = payload['ref']
commit_sha = payload['head_commit']['id']
short_sha = GithubBuildTrigger.get_display_name(commit_sha)
gh_client = self._get_client(auth_token)
repo_full_name = '%s/%s' % (payload['repository']['owner']['name'],
payload['repository']['name'])
repo = gh_client.get_repo(repo_full_name)
logger.debug('Github repo: %s', repo)
return GithubBuildTrigger._prepare_build(config, repo, commit_sha,
short_sha, ref)
def manual_start(self, auth_token, config):
source = config['build_source']
subdir = config['subdir']
gh_client = self._get_client(auth_token)
repo = gh_client.get_repo(source)
master = repo.get_branch(repo.master_branch)
master_sha = master.commit.sha
short_sha = GithubBuildTrigger.get_display_name(master_sha)
ref = 'refs/heads/%s' % repo.master_branch
return self._prepare_build(config, repo, master_sha, short_sha, ref)

View file

@ -1,43 +1,44 @@
import logging import logging
import requests
import stripe import stripe
import os
from flask import (abort, redirect, request, url_for, render_template, from flask import (abort, redirect, request, url_for, make_response, Response,
make_response, Response, Blueprint) Blueprint)
from flask.ext.login import login_required, current_user from flask.ext.login import current_user
from urlparse import urlparse from urlparse import urlparse
from data import model from data import model
from app import app, mixpanel from data.model.oauth import DatabaseAuthorizationProvider
from app import app
from auth.permissions import AdministerOrganizationPermission from auth.permissions import AdministerOrganizationPermission
from util.invoice import renderInvoiceToPdf from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot from util.seo import render_snapshot
from util.cache import no_cache from util.cache import no_cache
from endpoints.api import get_route_data from endpoints.common import common_login, render_page_template
from endpoints.common import common_login from endpoints.csrf import csrf_protect, generate_csrf_token
from util.names import parse_repository_name
from util.gravatar import compute_hash
from auth import scopes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
web = Blueprint('web', __name__) web = Blueprint('web', __name__)
STATUS_TAGS = app.config['STATUS_TAGS']
def render_page_template(name, **kwargs):
resp = make_response(render_template(name, route_data=get_route_data(),
**kwargs))
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp
@web.route('/', methods=['GET'], defaults={'path': ''}) @web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET'])
@web.route('/organization/<path:path>', methods=['GET']) @web.route('/organization/<path:path>', methods=['GET'])
@no_cache @no_cache
def index(path): def index(path):
return render_page_template('index.html') return render_page_template('index.html')
@web.route('/500', methods=['GET'])
def internal_error_display():
return render_page_template('500.html')
@web.route('/snapshot', methods=['GET']) @web.route('/snapshot', methods=['GET'])
@web.route('/snapshot/', methods=['GET']) @web.route('/snapshot/', methods=['GET'])
@web.route('/snapshot/<path:path>', methods=['GET']) @web.route('/snapshot/<path:path>', methods=['GET'])
@ -106,9 +107,10 @@ def new():
return index('') return index('')
@web.route('/repository/') @web.route('/repository/', defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET'])
@no_cache @no_cache
def repository(): def repository(path):
return index('') return index('')
@ -179,97 +181,6 @@ def receipt():
abort(404) abort(404)
def exchange_github_code_for_token(code):
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']
return token
def get_github_user(token):
token_param = {
'access_token': token,
}
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param)
return get_user.json()
@web.route('/oauth2/github/callback', methods=['GET'])
def github_oauth_callback():
error = request.args.get('error', None)
if error:
return render_page_template('githuberror.html', error_message=error)
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
username = user_data['login']
github_id = user_data['id']
v3_media_type = {
'Accept': 'application/vnd.github.v3'
}
token_param = {
'access_token': token,
}
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
try:
to_login = model.create_federated_user(username, found_email, 'github',
github_id)
# Success, tell mixpanel
mixpanel.track(to_login.username, 'register', {'service': 'github'})
state = request.args.get('state', None)
if state:
logger.debug('Aliasing with state: %s' % state)
mixpanel.alias(to_login.username, state)
except model.DataModelException, ex:
return render_page_template('githuberror.html', error_message=ex.message)
if common_login(to_login):
return redirect(url_for('web.index'))
return render_page_template('githuberror.html')
@web.route('/oauth2/github/callback/attach', methods=['GET'])
@login_required
def github_oauth_attach():
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
github_id = user_data['id']
user_obj = current_user.db_user()
model.attach_federated_login(user_obj, 'github', github_id)
return redirect(url_for('web.user'))
@web.route('/confirm', methods=['GET']) @web.route('/confirm', methods=['GET'])
def confirm_email(): def confirm_email():
code = request.values['code'] code = request.values['code']
@ -297,3 +208,134 @@ def confirm_recovery():
return redirect(url_for('web.user')) return redirect(url_for('web.user'))
else: else:
abort(403) abort(403)
@web.route('/repository/<path:repository>/status', methods=['GET'])
@parse_repository_name
@no_cache
def build_status_badge(namespace, repository):
token = request.args.get('token', None)
is_public = model.repository_is_public(namespace, repository)
if not is_public:
repo = model.get_repository(namespace, repository)
if not repo or token != repo.badge_token:
abort(404)
# Lookup the tags for the repository.
tags = model.list_repository_tags(namespace, repository)
is_empty = len(list(tags)) == 0
build = model.get_recent_repository_build(namespace, repository)
if not is_empty and (not build or build.phase == 'complete'):
status_name = 'ready'
elif build and build.phase == 'error':
status_name = 'failed'
elif build and build.phase != 'complete':
status_name = 'building'
else:
status_name = 'none'
response = make_response(STATUS_TAGS[status_name])
response.content_type = 'image/svg+xml'
return response
class FlaskAuthorizationProvider(DatabaseAuthorizationProvider):
def get_authorized_user(self):
return current_user.db_user()
def _make_response(self, body='', headers=None, status_code=200):
return make_response(body, status_code, headers)
@web.route('/oauth/authorizeapp', methods=['POST'])
@csrf_protect
def authorize_application():
if not current_user.is_authenticated():
abort(401)
return
provider = FlaskAuthorizationProvider()
client_id = request.form.get('client_id', None)
redirect_uri = request.form.get('redirect_uri', None)
scope = request.form.get('scope', None)
# Add the access token.
return provider.get_token_response('token', client_id, redirect_uri, scope=scope)
@web.route('/oauth/denyapp', methods=['POST'])
@csrf_protect
def deny_application():
if not current_user.is_authenticated():
abort(401)
return
provider = FlaskAuthorizationProvider()
client_id = request.form.get('client_id', None)
redirect_uri = request.form.get('redirect_uri', None)
scope = request.form.get('scope', None)
# Add the access token.
return provider.get_auth_denied_response('token', client_id, redirect_uri, scope=scope)
@web.route('/oauth/authorize', methods=['GET'])
@no_cache
def request_authorization_code():
provider = FlaskAuthorizationProvider()
response_type = request.args.get('response_type', 'code')
client_id = request.args.get('client_id', None)
redirect_uri = request.args.get('redirect_uri', None)
scope = request.args.get('scope', None)
if (not current_user.is_authenticated() or
not provider.validate_has_scopes(client_id, current_user.db_user().username, scope)):
if not provider.validate_redirect_uri(client_id, redirect_uri):
current_app = provider.get_application_for_client_id(client_id)
if not current_app:
abort(404)
return provider._make_redirect_error_response(current_app.redirect_uri, 'redirect_uri_mismatch')
# Load the scope information.
scope_info = scopes.get_scope_information(scope)
if not scope_info:
abort(404)
return
# Load the application information.
oauth_app = provider.get_application_for_client_id(client_id)
oauth_app_view = {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'organization': {
'name': oauth_app.organization.username,
'gravatar': compute_hash(oauth_app.organization.email)
}
}
# Show the authorization page.
return render_page_template('oauthorize.html', scopes=scope_info, application=oauth_app_view,
enumerate=enumerate, client_id=client_id, redirect_uri=redirect_uri,
scope=scope, csrf_token_val=generate_csrf_token())
if response_type == 'token':
return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope)
else:
return provider.get_authorization_code(response_type, client_id, redirect_uri, scope=scope)
@web.route('/oauth/access_token', methods=['POST'])
@no_cache
def exchange_code_for_token():
grant_type = request.form.get('grant_type', None)
client_id = request.form.get('client_id', None)
client_secret = request.form.get('client_secret', None)
redirect_uri = request.form.get('redirect_uri', None)
code = request.form.get('code', None)
scope = request.form.get('scope', None)
provider = FlaskAuthorizationProvider()
return provider.get_token(grant_type, client_id, client_secret, redirect_uri, code, scope=scope)

View file

@ -1,18 +1,26 @@
import logging import logging
import stripe import stripe
import json
from flask import request, make_response, Blueprint from flask import request, make_response, Blueprint
from data import model from data import model
from app import app from data.queue import dockerfile_build_queue
from auth.auth import process_auth
from auth.permissions import ModifyRepositoryPermission
from util.invoice import renderInvoiceToHtml from util.invoice import renderInvoiceToHtml
from util.email import send_invoice_email from util.email import send_invoice_email
from util.names import parse_repository_name
from util.http import abort
from endpoints.trigger import BuildTrigger, ValidationRequestException
from endpoints.common import start_build
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
webhooks = Blueprint('webhooks', __name__) webhooks = Blueprint('webhooks', __name__)
@webhooks.route('/stripe', methods=['POST']) @webhooks.route('/stripe', methods=['POST'])
def stripe_webhook(): def stripe_webhook():
request_data = request.get_json() request_data = request.get_json()
@ -36,3 +44,38 @@ def stripe_webhook():
send_invoice_email(user.email, invoice_html) send_invoice_email(user.email, invoice_html)
return make_response('Okay') return make_response('Okay')
@webhooks.route('/push/<path:repository>/trigger/<trigger_uuid>',
methods=['POST'])
@process_auth
@parse_repository_name
def build_trigger_webhook(namespace, repository, trigger_uuid):
logger.debug('Webhook received for %s/%s with uuid %s', namespace,
repository, trigger_uuid)
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
abort(404)
handler = BuildTrigger.get_trigger_for_service(trigger.service.name)
logger.debug('Passing webhook request to handler %s', handler)
config_dict = json.loads(trigger.config)
try:
specs = handler.handle_trigger_request(request, trigger.auth_token,
config_dict)
dockerfile_id, tags, name, subdir = specs
except ValidationRequestException:
# This was just a validation request, we don't need to build anything
return make_response('Okay')
repo = model.get_repository(namespace, repository)
start_build(repo, dockerfile_id, tags, name, subdir, False, trigger)
return make_response('Okay')
abort(403)

View file

@ -9,6 +9,7 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables,
from data.database import * from data.database import *
from data import model from data import model
from data.model import oauth
from app import app from app import app
@ -183,6 +184,8 @@ def initialize_database():
LoginService.create(name='github') LoginService.create(name='github')
LoginService.create(name='quayrobot') LoginService.create(name='quayrobot')
BuildTriggerService.create(name='github')
LogEntryKind.create(name='account_change_plan') LogEntryKind.create(name='account_change_plan')
LogEntryKind.create(name='account_change_cc') LogEntryKind.create(name='account_change_cc')
LogEntryKind.create(name='account_change_password') LogEntryKind.create(name='account_change_password')
@ -207,6 +210,7 @@ def initialize_database():
LogEntryKind.create(name='add_repo_webhook') LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook') LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='set_repo_description') LogEntryKind.create(name='set_repo_description')
LogEntryKind.create(name='build_dockerfile') LogEntryKind.create(name='build_dockerfile')
LogEntryKind.create(name='org_create_team') LogEntryKind.create(name='org_create_team')
@ -220,6 +224,19 @@ def initialize_database():
LogEntryKind.create(name='modify_prototype_permission') LogEntryKind.create(name='modify_prototype_permission')
LogEntryKind.create(name='delete_prototype_permission') LogEntryKind.create(name='delete_prototype_permission')
LogEntryKind.create(name='setup_repo_trigger')
LogEntryKind.create(name='delete_repo_trigger')
LogEntryKind.create(name='create_application')
LogEntryKind.create(name='update_application')
LogEntryKind.create(name='delete_application')
LogEntryKind.create(name='reset_application_client_secret')
NotificationKind.create(name='password_required')
NotificationKind.create(name='over_private_usage')
NotificationKind.create(name='test_notification')
def wipe_database(): def wipe_database():
logger.debug('Wiping all data from the DB.') logger.debug('Wiping all data from the DB.')
@ -257,6 +274,9 @@ def populate_database():
new_user_4.verified = True new_user_4.verified = True
new_user_4.save() new_user_4.save()
new_user_5 = model.create_user('unverified', 'password', 'no5@thanks.com')
new_user_5.save()
reader = model.create_user('reader', 'password', 'no1@thanks.com') reader = model.create_user('reader', 'password', 'no1@thanks.com')
reader.verified = True reader.verified = True
reader.save() reader.save()
@ -265,6 +285,8 @@ def populate_database():
outside_org.verified = True outside_org.verified = True
outside_org.save() outside_org.save()
model.create_notification('test_notification', new_user_1, metadata={'some': 'value'})
__generate_repository(new_user_4, 'randomrepo', 'Random repo repository.', False, __generate_repository(new_user_4, 'randomrepo', 'Random repo repository.', False,
[], (4, [], ['latest', 'prod'])) [], (4, [], ['latest', 'prod']))
@ -308,9 +330,24 @@ def populate_database():
False, [], (0, [], None)) False, [], (0, [], None))
token = model.create_access_token(building, 'write') token = model.create_access_token(building, 'write')
tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
build = model.create_repository_build(building, token, '701dcc3724fb4f2ea6c31400528343cd', trigger = model.create_build_trigger(building, 'github', '123authtoken',
tag, 'build-name') new_user_1)
trigger.config = json.dumps({
'build_source': 'jakedt/testconnect',
'subdir': '',
})
trigger.save()
repo = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
job_config = {
'repository': repo,
'docker_tags': ['latest'],
'build_subdir': '',
}
build = model.create_repository_build(building, token, job_config,
'701dcc3724fb4f2ea6c31400528343cd',
'build-name', trigger)
build.uuid = 'deadbeef-dead-beef-dead-beefdeadbeef' build.uuid = 'deadbeef-dead-beef-dead-beefdeadbeef'
build.save() build.save()
@ -319,6 +356,15 @@ def populate_database():
org.stripe_id = TEST_STRIPE_ID org.stripe_id = TEST_STRIPE_ID
org.save() org.save()
oauth.create_application(org, 'Some Test App', 'http://localhost:8000', 'http://localhost:8000/o2c.html',
client_id='deadbeef')
oauth.create_application(org, 'Some Other Test App', 'http://quay.io', 'http://localhost:8000/o2c.html',
client_id='deadpork',
description = 'This is another test application')
model.oauth.create_access_token_for_testing(new_user_1, 'deadbeef', 'repo:admin')
model.create_robot('neworgrobot', org) model.create_robot('neworgrobot', org)
owners = model.get_organization_team('buynlarge', 'owners') owners = model.get_organization_team('buynlarge', 'owners')
@ -423,6 +469,12 @@ def populate_database():
timestamp=today, timestamp=today,
metadata={'token_code': 'somecode', 'repo': 'orgrepo'}) metadata={'token_code': 'somecode', 'repo': 'orgrepo'})
model.log_action('build_dockerfile', new_user_1.username, repository=building,
timestamp=today,
metadata={'repo': 'building', 'namespace': new_user_1.username,
'trigger_id': trigger.uuid, 'config': json.loads(trigger.config),
'service': trigger.service.name})
if __name__ == '__main__': if __name__ == '__main__':
app.config['LOGGING_CONFIG']() app.config['LOGGING_CONFIG']()
initialize_database() initialize_database()

View file

@ -23,3 +23,7 @@ redis
hiredis hiredis
git+https://github.com/dotcloud/docker-py.git git+https://github.com/dotcloud/docker-py.git
loremipsum loremipsum
pygithub
flask-restful
jsonschema
git+https://github.com/NateFerrero/oauth2lib.git

View file

@ -1,45 +1,51 @@
APScheduler==2.1.2 APScheduler==2.1.2
Flask==0.10.1 Flask==0.10.1
Flask-Login==0.2.9 Flask-Login==0.2.10
Flask-Mail==0.9.0 Flask-Mail==0.9.0
Flask-Principal==0.4.0 Flask-Principal==0.4.0
Flask-RESTful==0.2.12
Jinja2==2.7.2 Jinja2==2.7.2
MarkupSafe==0.18 MarkupSafe==0.19
Pillow==2.3.0 Pillow==2.3.1
PyGithub==1.24.1
PyMySQL==0.6.1 PyMySQL==0.6.1
Werkzeug==0.9.4 Werkzeug==0.9.4
aniso8601==0.82
argparse==1.2.1 argparse==1.2.1
beautifulsoup4==4.3.2 beautifulsoup4==4.3.2
blinker==1.3 blinker==1.3
boto==2.24.0 boto==2.27.0
distribute==0.6.34 distribute==0.6.34
git+https://github.com/dotcloud/docker-py.git git+https://github.com/dotcloud/docker-py.git
ecdsa==0.10 ecdsa==0.11
gevent==1.0 gevent==1.0
greenlet==0.4.2 greenlet==0.4.2
gunicorn==18.0 gunicorn==18.0
hiredis==0.1.2 hiredis==0.1.2
html5lib==1.0b3 html5lib==1.0b3
itsdangerous==0.23 itsdangerous==0.23
jsonschema==2.3.0
lockfile==0.9.1 lockfile==0.9.1
logstash-formatter==0.5.8 logstash-formatter==0.5.8
loremipsum==1.0.2 loremipsum==1.0.2
marisa-trie==0.5.1 marisa-trie==0.6
mixpanel-py==3.1.1 mixpanel-py==3.1.2
mock==1.0.1 mock==1.0.1
paramiko==1.12.1 git+https://github.com/NateFerrero/oauth2lib.git
peewee==2.2.0 paramiko==1.13.0
peewee==2.2.2
py-bcrypt==0.4 py-bcrypt==0.4
pyPdf==1.13 pyPdf==1.13
pycrypto==2.6.1 pycrypto==2.6.1
python-daemon==1.6 python-daemon==1.6
python-dateutil==2.2 python-dateutil==2.2
python-digitalocean==0.6 python-digitalocean==0.7
pytz==2014.2
redis==2.9.1 redis==2.9.1
reportlab==2.7 reportlab==2.7
requests==2.2.1 requests==2.2.1
six==1.5.2 six==1.6.1
stripe==1.12.0 stripe==1.12.2
websocket-client==0.11.0 websocket-client==0.11.0
wsgiref==0.1.2 wsgiref==0.1.2
xhtml2pdf==0.0.5 xhtml2pdf==0.0.5

View file

@ -15,8 +15,9 @@ var isDebug = !!options['d'];
var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/'; var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/';
var repo = isDebug ? 'complex' : 'r0'; var repo = isDebug ? 'complex' : 'r0';
var org = isDebug ? 'buynlarge' : 'quay' var org = isDebug ? 'buynlarge' : 'devtable'
var orgrepo = 'orgrepo' var orgrepo = isDebug ? 'buynlarge/orgrepo' : 'quay/testconnect2';
var buildrepo = isDebug ? 'devtable/building' : 'quay/testconnect2';
var outputDir = "screenshots/"; var outputDir = "screenshots/";
@ -32,8 +33,16 @@ casper.on("page.error", function(msg, trace) {
}); });
casper.start(rootUrl + 'signin', function () { casper.start(rootUrl + 'signin', function () {
this.wait(1000);
});
casper.thenClick('.accordion-toggle[data-target="#collapseSignin"]', function() {
this.wait(1000);
});
casper.then(function () {
this.fill('.form-signin', { this.fill('.form-signin', {
'username': 'devtable', 'username': isDebug ? 'devtable' : 'quaydemo',
'password': isDebug ? 'password': 'C>K98%y"_=54x"<', 'password': isDebug ? 'password': 'C>K98%y"_=54x"<',
}, false); }, false);
}); });
@ -43,6 +52,7 @@ casper.thenClick('.form-signin button[type=submit]', function() {
}); });
casper.then(function() { casper.then(function() {
this.waitForSelector('.fa-lock');
this.log('Generating user home screenshot.'); this.log('Generating user home screenshot.');
}); });
@ -150,12 +160,25 @@ casper.then(function() {
this.log('Generating oganization repository admin screenshot.'); this.log('Generating oganization repository admin screenshot.');
}); });
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() { casper.thenOpen(rootUrl + 'repository/' + orgrepo + '/admin', function() {
this.waitForText('outsideorg') this.waitForText('Robot Account')
}); });
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'org-repo-admin.png'); this.capture(outputDir + 'org-repo-admin.png');
}); });
casper.then(function() {
this.log('Generating build history screenshot.');
});
casper.thenOpen(rootUrl + 'repository/' + buildrepo + '/build', function() {
this.waitForText('Starting');
});
casper.then(function() {
this.capture(outputDir + 'build-history.png');
});
casper.run(); casper.run();

View file

@ -9,7 +9,74 @@
} }
} }
.notification-view-element {
cursor: pointer;
margin-bottom: 10px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
position: relative;
max-width: 320px;
}
.notification-view-element .orginfo {
margin-top: 8px;
float: left;
}
.notification-view-element .orginfo .orgname {
font-size: 12px;
color: #aaa;
}
.notification-view-element .circle {
position: absolute;
top: 14px;
left: 0px;
width: 12px;
height: 12px;
display: inline-block;
border-radius: 50%;
}
.notification-view-element .datetime {
margin-top: 16px;
font-size: 12px;
color: #aaa;
text-align: right;
}
.notification-view-element .message {
margin-bottom: 4px;
}
.notification-view-element .container {
padding: 10px;
border-radius: 6px;
margin-left: 16px;
}
.notification-view-element .container:hover {
background: rgba(66, 139, 202, 0.1);
}
.dockerfile-path {
margin-top: 10px;
padding: 20px;
padding-bottom: 0px;
font-family: Consolas, "Lucida Console", Monaco, monospace;
font-size: 14px;
}
.dockerfile-path:before {
content: "\f15b";
font-family: FontAwesome;
margin-right: 8px;
font-size: 18px;
}
.dockerfile-view { .dockerfile-view {
margin-top: 10px;
margin: 20px; margin: 20px;
padding: 20px; padding: 20px;
background: #F7F6F6; background: #F7F6F6;
@ -282,21 +349,6 @@ i.toggle-icon:hover {
vertical-align: middle; vertical-align: middle;
} }
#copyClipboard {
cursor: pointer;
}
#copyClipboard.zeroclipboard-is-hover {
background: #428bca;
color: white;
}
#clipboardCopied.hovering {
position: absolute;
right: 0px;
top: 40px;
}
.content-container { .content-container {
padding-bottom: 70px; padding-bottom: 70px;
} }
@ -506,7 +558,22 @@ i.toggle-icon:hover {
min-width: 200px; min-width: 200px;
} }
.user-notification { .notification-primary {
background: #428bca;
color: white;
}
.notification-info {
color: black;
background: #d9edf7;
}
.notification-warning {
color: #8a6d3b;
background: #fcf8e3;
}
.notification-error {
background: red; background: red;
} }
@ -775,11 +842,20 @@ i.toggle-icon:hover {
margin-bottom: 16px; margin-bottom: 16px;
} }
.new-repo .section-title {
float: right;
color: #aaa;
}
.new-repo .repo-option { .new-repo .repo-option {
margin: 6px; margin: 6px;
margin-top: 16px; margin-top: 16px;
} }
.new-repo .repo-option label {
font-weight: normal;
}
.new-repo .repo-option i { .new-repo .repo-option i {
font-size: 18px; font-size: 18px;
padding-left: 10px; padding-left: 10px;
@ -1596,10 +1672,17 @@ p.editable:hover i {
} }
.repo .empty-description { .repo .empty-description {
max-width: 600px;
padding: 6px; padding: 6px;
} }
.repo .empty-description pre:last-child {
margin-bottom: 0px;
}
.repo .empty-description .panel-default {
margin-top: 20px;
}
.repo dl.dl-horizontal dt { .repo dl.dl-horizontal dt {
width: 80px; width: 80px;
padding-right: 10px; padding-right: 10px;
@ -1710,7 +1793,38 @@ p.editable:hover i {
margin-top: 28px; margin-top: 28px;
} }
#clipboardCopied { .copy-box-element {
position: relative;
}
.global-zeroclipboard-container embed {
cursor: pointer;
}
#copyClipboard.zeroclipboard-is-hover, .copy-box-element .zeroclipboard-is-hover {
background: #428bca;
color: white;
cursor: pointer !important;
}
#clipboardCopied.hovering, .copy-box-element .hovering {
position: absolute;
right: 0px;
top: 40px;
pointer-events: none;
z-index: 100;
}
.copy-box-element .id-container {
display: inline-block;
vertical-align: middle;
}
.copy-box-element input {
background-color: white !important;
}
#clipboardCopied, .clipboard-copied-message {
font-size: 0.8em; font-size: 0.8em;
display: inline-block; display: inline-block;
margin-right: 10px; margin-right: 10px;
@ -1721,7 +1835,7 @@ p.editable:hover i {
border-radius: 4px; border-radius: 4px;
} }
#clipboardCopied.animated { #clipboardCopied.animated, .clipboard-copied-message {
-webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards; -webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards;
-moz-animation: fadeOut 4s ease-in-out 0s 1 forwards; -moz-animation: fadeOut 4s ease-in-out 0s 1 forwards;
-ms-animation: fadeOut 4s ease-in-out 0s 1 forwards; -ms-animation: fadeOut 4s ease-in-out 0s 1 forwards;
@ -2037,6 +2151,13 @@ p.editable:hover i {
left: 4px; left: 4px;
} }
.repo-admin .right-controls {
text-align: right;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
.repo-admin .right-info { .repo-admin .right-info {
font-size: 11px; font-size: 11px;
margin-top: 10px; margin-top: 10px;
@ -2205,16 +2326,16 @@ p.editable:hover i {
padding-right: 6px; padding-right: 6px;
} }
.delete-ui { .delete-ui-element {
outline: none; outline: none;
} }
.delete-ui i { .delete-ui-element i {
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
} }
.delete-ui .delete-ui-button { .delete-ui-element .delete-ui-button {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
color: white; color: white;
@ -2230,15 +2351,15 @@ p.editable:hover i {
transition: width 500ms ease-in-out; transition: width 500ms ease-in-out;
} }
.delete-ui .delete-ui-button button { .delete-ui-element .delete-ui-button button {
padding: 4px; padding: 4px;
} }
.delete-ui:focus i { .delete-ui-element:focus i {
visibility: hidden; visibility: hidden;
} }
.delete-ui:focus .delete-ui-button { .delete-ui-element:focus .delete-ui-button {
width: 60px; width: 60px;
} }
@ -2812,7 +2933,7 @@ p.editable:hover i {
margin-bottom: 10px; margin-bottom: 10px;
} }
.create-org .step-container .description { .form-group .description {
margin-top: 10px; margin-top: 10px;
display: block; display: block;
color: #888; color: #888;
@ -2820,7 +2941,7 @@ p.editable:hover i {
margin-left: 10px; margin-left: 10px;
} }
.create-org .form-group input { .form-group.nested input {
margin-top: 10px; margin-top: 10px;
margin-left: 10px; margin-left: 10px;
} }
@ -2927,9 +3048,10 @@ p.editable:hover i {
.tt-suggestion { .tt-suggestion {
display: block; display: block;
padding: 3px 20px; padding: 3px 20px;
cursor: pointer;
} }
.tt-suggestion.tt-is-under-cursor { .tt-suggestion.tt-cursor {
color: #fff; color: #fff;
background-color: #0081c2; background-color: #0081c2;
background-image: -moz-linear-gradient(top, #0088cc, #0077b3); background-image: -moz-linear-gradient(top, #0088cc, #0077b3);
@ -2941,10 +3063,17 @@ p.editable:hover i {
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0) filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0)
} }
.tt-suggestion.tt-is-under-cursor a { .tt-suggestion.tt-cursor a {
color: #fff; color: #fff;
} }
.tt-empty {
padding: 10px;
font-size: 12px;
color: #aaa;
white-space: nowrap;
}
.tt-suggestion p { .tt-suggestion p {
margin: 0; margin: 0;
} }
@ -3381,3 +3510,215 @@ pre.command:before {
.label.MAINTAINER { .label.MAINTAINER {
border-color: #aaa !important; border-color: #aaa !important;
} }
.dropdown-select {
margin: 10px;
position: relative;
}
.dropdown-select .dropdown-select-icon {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
display: none;
}
.dropdown-select .dropdown-select-icon.fa {
top: 10px;
left: 8px;
font-size: 20px;
}
.dropdown-select .dropdown-select-icon.none-icon {
color: #ccc;
display: inline;
}
.dropdown-select.has-item .dropdown-select-icon {
display: inline;
}
.dropdown-select.has-item .dropdown-select-icon.none-icon {
display: none;
}
.dropdown-select .lookahead-input {
padding-left: 32px;
}
.dropdown-select .twitter-typeahead {
display: block !important;
}
.dropdown-select .twitter-typeahead .tt-hint {
padding-left: 32px;
}
.dropdown-select .dropdown {
position: absolute;
right: 0px;
top: 0px;
}
.dropdown-select .dropdown button.dropdown-toggle {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.trigger-setup-github-element .github-org-icon {
width: 20px;
margin-right: 8px;
vertical-align: middle;
}
.trigger-setup-github-element li.github-repo-listing i {
margin-right: 10px;
margin-left: 6px;
}
.trigger-setup-github-element li.github-org-header {
padding-left: 6px;
}
.slideinout {
-webkit-transition:0.5s all;
transition:0.5s linear all;
opacity: 1;
position: relative;
height: 100px;
opacity: 1;
}
.slideinout.ng-hide {
opacity: 0;
height: 0px;
}
.slideinout.ng-hide-add, .slideinout.ng-hide-remove {
display: block !important;
}
.auth-header > img {
float: left;
margin-top: 8px;
margin-right: 20px;
}
.auth-header {
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.auth-scopes .reason {
margin-top: 20px;
margin-bottom: 20px;
font-size: 18px;
}
.auth-scopes ul {
margin-top: 10px;
list-style: none;
}
.auth-scopes li {
display: block;
}
.auth-scopes .scope {
max-width: 500px;
}
.auth-scopes .scope-container:last-child {
border-bottom: 0px;
}
.auth-scopes .panel-default {
border: 0px;
margin-bottom: 0px;
padding-bottom: 10px;
box-shadow: none;
}
.auth-scopes .panel-default:last-child {
border-bottom: 0px;
}
.auth-scopes .panel-heading {
border: 0px;
background: transparent;
}
.auth-scopes .scope .title {
min-width: 300px;
cursor: pointer;
display: inline-block;
}
.auth-scopes .scope .title a {
color: #444;
}
.auth-scopes .scope .description {
padding: 10px;
}
.auth-scopes .scope i {
margin-right: 10px;
margin-top: 2px;
}
.auth-scopes .scope i.fa-lg {
font-size: 24px;
}
.auth-scopes .title i.arrow:before {
content: "\f0d7";
}
.auth-scopes .title.collapsed i.arrow:before {
content: "\f0da" !important;
}
.auth-container .button-bar form {
display: inline-block;
}
.auth-container .button-bar {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
.auth-container .button-bar button {
margin: 6px;
}
.manage-application #oauth td {
padding: 6px;
padding-bottom: 20px;
}
.manage-application .button-bar {
margin-top: 10px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.auth-info .by:before {
content: "by";
margin-right: 4px;
}
.auth-info .by {
color: #aaa;
font-size: 12px;
}
.auth-info .scope {
cursor: pointer;
margin-right: 4px;
}

View file

@ -0,0 +1,12 @@
<div class="application-info-element" style="padding-bottom: 18px">
<div class="auth-header">
<img src="//www.gravatar.com/avatar/{{ application.gravatar }}?s=48&d=identicon">
<h2><a href="{{ application.url }}" target="_blank">{{ application.name }}</a></h2>
<h4>
{{ application.organization.name }}
</h4>
</div>
<div style="padding-top: 10px">
{{ application.description || '(No Description)' }}
</div>
</div>

View file

@ -0,0 +1,24 @@
<div class="application-manager-element">
<div class="quay-spinner" ng-show="loading"></div>
<div class="container" ng-show="!loading">
<div class="side-controls">
<span class="popup-input-button" placeholder="'Application Name'" submitted="createApplication(value)">
<i class="fa fa-plus"></i> Create New Application
</span>
</div>
<table class="table">
<thead>
<th>Application Name</th>
<th>Application URI</th>
</thead>
<tr ng-repeat="app in applications">
<td><a href="/organization/{{ organization.name }}/application/{{ app.client_id }}">{{ app.name }}</a></td>
<td><a href="{{ app.application_uri }}" ng-if="app.application_uri" target="_blank">{{ app.application_uri }}</a></td>
</tr>
</table>
</div>
</div>

View file

@ -0,0 +1,15 @@
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="padding: 4px; padding-left: 20px;">
<button type="button" class="close" ng-click="$hide()" style="padding: 4px;">
&times;
</button>
<div class="application-info" application="applicationInfo"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-click="$hide()">Close</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,4 @@
<span class="application-reference-element">
<i class="fa fa-cloud"></i>
<a href="javascript:void(0)" ng-click="showAppDetails()">{{ title }}</a>
</span>

View file

@ -0,0 +1,14 @@
<div class="copy-box-element">
<div class="id-container">
<div class="input-group">
<input type="text" class="form-control" value="{{ value }}" readonly>
<span class="input-group-addon" title="Copy to Clipboard">
<i class="fa fa-copy"></i>
</span>
</div>
</div>
<div class="clipboard-copied-message" ng-class="hoveringMessage ? 'hovering' : ''" style="display: none">
Copied to clipboard
</div>
</div>

View file

@ -0,0 +1,4 @@
<span class="delete-ui-element" ng-click="focus()">
<span class="delete-ui-button" ng-click="performDelete()"><button class="btn btn-danger">{{ buttonTitleInternal }}</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="left" title="{{ deleteTitle }}"></i>
</span>

View file

@ -0,0 +1 @@
<ng-transclude>

View file

@ -0,0 +1 @@
<ul class="dropdown-menu" ng-transclude></ul>

View file

@ -0,0 +1,13 @@
<div class="dropdown-select-element" ng-class="selectedItem ? 'has-item' : ''">
<div class="current-item">
<div class="dropdown-select-icon-transclude"></div>
<input type="text" class="lookahead-input form-control" placeholder="{{ placeholder }}"></input>
</div>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<span class="caret"></span>
</button>
<div class="dropdown-select-menu-transclude"></div>
</div>
<div class="transcluded" ng-transclude>
</div>

View file

@ -1,10 +1,7 @@
<!-- Quay --> <!-- Quay -->
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex1-collapse"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex1-collapse" style="padding: 0px; padding-left: 4px; padding-right: 4px;">
<span class="sr-only">Toggle navigation</span> <span style="font-size: 24px">&equiv;</span>
<span class="fa-bar"></span>
<span class="fa-bar"></span>
<span class="fa-bar"></span>
</button> </button>
<a class="navbar-brand" href="/" target="{{ appLinkTarget() }}"> <a class="navbar-brand" href="/" target="{{ appLinkTarget() }}">
<img src="/static/img/quay-logo.png"> <img src="/static/img/quay-logo.png">
@ -39,11 +36,14 @@
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown"> <a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" /> <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
{{ user.username }} {{ user.username }}
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan" <span class="badge user-notification notification-animated"
bs-tooltip="(user.askForPassword ? 'A password is needed for this account<br>' : '') + (overPlan ? 'You are using more private repositories than your plan allows' : '')" ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses"
bs-tooltip=""
title="User Notifications"
data-placement="left" data-placement="left"
data-container="body"> data-container="body">
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }} {{ notificationService.notifications.length }}
</span> </span>
<b class="caret"></b> <b class="caret"></b>
</a> </a>
@ -51,8 +51,16 @@
<li> <li>
<a href="/user/" target="{{ appLinkTarget() }}"> <a href="/user/" target="{{ appLinkTarget() }}">
Account Settings Account Settings
<span class="badge user-notification" ng-show="user.askForPassword || overPlan"> </a>
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }} </li>
<li ng-if="notificationService.notifications.length">
<a href="javascript:void(0)" data-template="/static/directives/notification-bar.html"
data-animation="am-slide-right" bs-aside="aside" data-container="body">
Notifications
<span class="badge user-notification"
ng-class="notificationService.notificationClasses"
ng-show="notificationService.notifications.length">
{{ notificationService.notifications.length }}
</span> </span>
</a> </a>
</li> </li>

View file

@ -6,9 +6,9 @@
<span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span> <span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span>
<span id="logs-range" class="mini"> <span id="logs-range" class="mini">
From From
<input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-date-format="mm/dd/yyyy" bs-datepicker/> <input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-max-date="{{ logEndDate }}" data-container="body" bs-datepicker/>
<span class="add-on">to</span> <span class="add-on">to</span>
<input type="text" class="logs-date-picker input-sm" name="end" ng-model="logEndDate" data-date-format="mm/dd/yyyy" bs-datepicker/> <input type="text" class="logs-date-picker input-sm" name="end" ng-model="logEndDate" data-min-date="{{ logStartDate }}" bs-datepicker/>
</span> </span>
</span> </span>
<span class="right"> <span class="right">
@ -42,7 +42,7 @@
<thead> <thead>
<th>Description</th> <th>Description</th>
<th style="min-width: 226px">Date/Time</th> <th style="min-width: 226px">Date/Time</th>
<th>User/Token</th> <th>User/Token/App</th>
</thead> </thead>
<tbody> <tbody>
@ -53,14 +53,24 @@
</td> </td>
<td>{{ log.datetime }}</td> <td>{{ log.datetime }}</td>
<td> <td>
<span class="log-performer" ng-show="log.performer"> <span class="log-performer" ng-if="log.metadata.oauth_token_application">
<div>
<span class="application-reference" title="log.metadata.oauth_token_application"
client-id="log.metadata.oauth_token_application_id"></span>
</div>
<div style="text-align: center; font-size: 12px; color: #aaa; padding: 4px;">on behalf of</div>
<div>
<span class="entity-reference" entity="log.performer" namespace="organization.name"></span>
</div>
</span>
<span class="log-performer" ng-if="!log.metadata.oauth_token_application && log.performer">
<span class="entity-reference" entity="log.performer" namespace="organization.name"></span> <span class="entity-reference" entity="log.performer" namespace="organization.name"></span>
</span> </span>
<span class="log-performer" ng-show="!log.performer && log.metadata.token"> <span class="log-performer" ng-if="!log.performer && log.metadata.token">
<i class="fa fa-key"></i> <i class="fa fa-key"></i>
<span>{{ log.metadata.token }}</span> <span>{{ log.metadata.token }}</span>
</span> </span>
<span ng-show="!log.performer && !log.metadata.token"> <span ng-if="!log.performer && !log.metadata.token">
(anonymous) (anonymous)
</span> </span>
</td> </td>

View file

@ -0,0 +1,15 @@
<div class="aside" tabindex="-1" role="dialog">
<div class="aside-dialog">
<div class="aside-content">
<div class="aside-header">
<button type="button" class="close" ng-click="$hide()">&times;</button>
<h4 class="aside-title">Notifications</h4>
</div>
<div class="aside-body">
<div ng-repeat="notification in notificationService.notifications">
<div class="notification-view" notification="notification" parent="this"></div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div class="notification-view-element">
<div class="container" ng-click="showNotification();">
<div class="circle" ng-class="getClass(notification)"></div>
<div class="message" ng-bind-html="getMessage(notification)"></div>
<div class="orginfo" ng-if="notification.organization">
<img src="//www.gravatar.com/avatar/{{ getGravatar(notification.organization) }}?s=24&d=identicon" />
<span class="orgname">{{ notification.organization }}</span>
</div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
<button class="btn btn-success" data-trigger="click" bs-popover="'static/directives/popup-input-dialog.html'" <button class="btn btn-success" data-trigger="click"
data-placement="bottom" ng-click="popupShown()"> data-content-template="static/directives/popup-input-dialog.html"
data-placement="bottom" ng-click="popupShown()" bs-popover>
<span ng-transclude></span> <span ng-transclude></span>
</button> </button>

View file

@ -1,4 +1,4 @@
<form name="popupinput" ng-submit="inputSubmit(); hide()" novalidate> <form name="popupinput" ng-submit="inputSubmit(); $hide()" novalidate>
<input id="input-box" type="text form-control" placeholder="{{ placeholder }}" ng-blur="hide()" <input id="input-box" type="text form-control" placeholder="{{ placeholder }}" ng-blur="$hide()"
ng-pattern="getRegexp(pattern)" ng-model="inputValue" ng-trim="false" ng-minlength="2" required> ng-pattern="getRegexp(pattern)" ng-model="inputValue" ng-trim="false" ng-minlength="2" required>
</form> </form>

View file

@ -48,10 +48,7 @@
<span class="role-group" current-role="prototype.role" role-changed="setRole(role, prototype)" roles="roles"></span> <span class="role-group" current-role="prototype.role" role-changed="setRole(role, prototype)" roles="roles"></span>
</td> </td>
<td> <td>
<span class="delete-ui" tabindex="0"> <span class="delete-ui" delete-title="'Delete Permission'" perform-delete="deletePrototype(prototype)"></span>
<span class="delete-ui-button" ng-click="deletePrototype(prototype)"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
</span>
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -1,7 +1,5 @@
<div class="resource-view-element"> <div class="resource-view-element">
<div class="resource-spinner" ng-class="resource.loading ? 'visible' : ''"> <div class="quay-spinner" ng-show="resource.loading"></div>
<div class="small-spinner"></div>
</div>
<div class="resource-error" ng-show="!resource.loading && resource.hasError"> <div class="resource-error" ng-show="!resource.loading && resource.hasError">
{{ errorMessage }} {{ errorMessage }}
</div> </div>

View file

@ -24,10 +24,7 @@
</a> </a>
</td> </td>
<td> <td>
<span class="delete-ui" tabindex="0"> <span class="delete-ui" delete-title="'Delete Robot Account'" perform-delete="deleteRobot(robotInfo)"></span>
<span class="delete-ui-button" ng-click="deleteRobot(robotInfo)"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Robot Account"></i>
</span>
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -5,7 +5,8 @@
ng-class="getImageListingClasses(image)"> ng-class="getImageListingClasses(image)">
<span class="image-listing-circle"></span> <span class="image-listing-circle"></span>
<span class="image-listing-line"></span> <span class="image-listing-line"></span>
<span class="context-tooltip image-listing-id" bs-tooltip="getFirstTextLine(image.comment)"> <span class="context-tooltip image-listing-id" bs-tooltip="" title="getFirstTextLine(image.comment)"
data-html="true">
{{ image.id.substr(0, 12) }} {{ image.id.substr(0, 12) }}
</span> </span>
</div> </div>

View file

@ -0,0 +1,23 @@
<span class="trigger-description-element" ng-switch on="trigger.service">
<span ng-switch-when="github">
<i class="fa fa-github fa-lg" style="margin-right: 6px" title="GitHub" bs-tooltip="tooltip.title"></i>
Push to GitHub repository <a href="https://github.com/{{ trigger.config.build_source }}" target="_new">{{ trigger.config.build_source }}</a>
<div style="margin-top: 4px; margin-left: 26px; font-size: 12px; color: gray;" ng-if="trigger.config.subdir">
<span>Dockerfile:
<a href="https://github.com/{{ trigger.config.build_source }}/tree/{{ trigger.config.master_branch || 'master' }}/{{ trigger.config.subdir }}/Dockerfile" target="_blank">
//{{ trigger.config.subdir }}/Dockerfile
</a>
</span>
</div>
<div style="margin-top: 4px; margin-left: 26px; font-size: 12px; color: gray;" ng-if="!trigger.config.subdir && !short">
<span>Dockerfile:
<a href="https://github.com/{{ trigger.config.build_source }}/tree/{{ trigger.config.master_branch || 'master' }}/Dockerfile" target="_blank">
//Dockerfile
</a>
</span>
</div>
</span>
<span ng-switch-default>
Unknown
</span>
</span>

View file

@ -0,0 +1,61 @@
<div class="trigger-setup-github-element">
<div ng-show="loading">
<span class="quay-spinner" style="vertical-align: middle; margin-right: 10px"></span>
Loading Repository List
</div>
<div ng-show="!loading">
<div style="margin-bottom: 18px">Please choose the GitHub repository that will trigger the build:</div>
<!-- Repository select -->
<div class="dropdown-select" placeholder="'Select a repository'" selected-item="currentRepo"
lookahead-items="repoLookahead">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-github fa-lg"></i>
<img class="dropdown-select-icon github-org-icon" ng-src="{{ currentRepo.avatar_url ? currentRepo.avatar_url : '//www.gravatar.com/avatar/' }}">
<!-- Dropdown menu -->
<ul class="dropdown-select-menu" role="menu">
<li ng-repeat-start="org in orgs" role="presentation" class="dropdown-header github-org-header">
<img ng-src="{{ org.info.avatar_url }}" class="github-org-icon">{{ org.info.name }}
</li>
<li ng-repeat="repo in org.repos" class="github-repo-listing">
<a href="javascript:void(0)" ng-click="selectRepo(repo, org)"><i class="fa fa-github fa-lg"></i> {{ repo }}</a>
</li>
<li role="presentation" class="divider" ng-repeat-end ng-show="$index < orgs.length - 1"></li>
</ul>
</div>
<!-- Dockerfile folder select -->
<div class="slideinout" ng-show="currentRepo">
<div style="margin-top: 10px">Dockerfile Location:</div>
<div class="dropdown-select" placeholder="'(Repository Root)'" selected-item="currentLocation"
lookahead-items="locations" handle-input="handleLocationInput(input)" handle-item-selected="handleLocationSelected(datum)">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg" ng-show="isInvalidLocation"></i>
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;" ng-show="!isInvalidLocation"></i>
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu" role="menu">
<li ng-repeat="location in locations">
<a href="javascript:void(0)" ng-click="setLocation(location)" ng-if="!location"><i class="fa fa-github fa-lg"></i> Repository Root</a>
<a href="javascript:void(0)" ng-click="setLocation(location)" ng-if="location"><i class="fa fa-folder fa-lg"></i> {{ location }}</a>
</li>
<li class="dropdown-header" role="presentation" ng-show="!locations.length">No Dockerfiles found in repository</li>
</ul>
</div>
<div class="quay-spinner" ng-show="!locations && !locationError"></div>
<div class="alert alert-warning" ng-show="locations && !locations.length">
Warning: No Dockerfiles were found in {{ currentRepo.repo }}
</div>
<div class="alert alert-warning" ng-show="locationError">
{{ locationError }}
</div>
<div class="alert alert-info" ng-show="locations.length && isInvalidLocation">
Note: The folder does not currently exist or contain a Dockerfile
</div>
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title accordion-title"> <h4 class="panel-title accordion-title">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseSignin"> <a id="signinToggle" class="accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseSignin">
Sign In Sign In
</a> </a>
</h4> </h4>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="453.54px" height="453.54px" viewBox="0 0 453.54 453.54" enable-background="new 0 0 453.54 453.54" xml:space="preserve">
<rect fill="#00A9D3" width="453.54" height="453.54"/>
<g>
<path fill="#CAD5DA" d="M88.553,86.368L119.628,34l16.113,6.042l21.004,17.264c0,0,14.963,16.688,15.826,16.977
c0.863,0.288,18.415-12.373,18.415-12.373l9.783-1.726c0,0,10.934,46.038,11.221,48.339s32.515,54.958,32.803,56.972
c0.287,2.014-0.288,72.222-2.302,73.085c-2.015,0.863-108.189,9.494-108.189,9.494l-59.561-4.604V120.608L88.553,86.368z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="111.6489" y1="52.2725" x2="107.9083" y2="201.6065">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<polygon fill="url(#SVGID_1_)" points="120.204,36.59 91.143,86.943 78.194,120.32 83.374,199.735 139.482,208.655 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="155.4292" y1="35.3975" x2="192.2593" y2="175.8124">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<polygon fill="url(#SVGID_2_)" points="122.218,36.59 146.1,190.24 240.478,169.235 151.147,111.113 "/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="180.3018" y1="100.7939" x2="155.2686" y2="32.0245">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<polygon fill="url(#SVGID_3_)" points="125.383,38.316 154.156,107.085 206.812,107.085 198.899,63.493 178.039,86.368
169.695,80.9 155.883,59.896 136.029,42.632 "/>
</g>
<path fill="#CAD5DA" d="M50.447,81.587L59.739,77l2.667,8.333L69.573,95.5l6.667,41.5c0,0-20,18.333-20.667,18.5s-19.167-5-19.167-5
l5.833-35.5l1.91-18.329L50.447,81.587z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="54.2388" y1="80.3335" x2="54.2388" y2="118.3335">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<polygon fill="url(#SVGID_4_)" points="51.406,82 45.906,96 44.072,115.585 64.406,118.333 56.322,80.333 "/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

99
static/img/500/ship.svg Normal file
View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="90px" height="453.54px" viewBox="0 0 90 453.54" enable-background="new 0 0 453.54 453.54" xml:space="preserve">
<g>
<rect x="79.797" y="74.656" fill="#231F20" width="0.561" height="1.094"/>
<rect x="80.756" y="74.656" fill="#231F20" width="0.561" height="1.094"/>
<g>
<polygon fill="#4B5059" points="79.16,77.125 79.16,76.188 81.723,76.188 81.723,75.25 79.098,75.25 77.848,80.812 81.723,80.812
81.723,78.5 79.16,78.5 79.16,77.562 81.723,77.562 81.723,77.125 "/>
<rect x="79.16" y="76.188" fill="#A73D37" width="2.562" height="0.938"/>
<rect x="79.16" y="77.562" fill="#A73D37" width="2.562" height="0.938"/>
</g>
<rect x="72.41" y="79.625" fill="#9BA9B2" width="10.5" height="7.75"/>
<polygon fill="#E8E5D1" points="80.598,86.062 78.41,81.125 71.098,81.125 71.098,81.844 69.504,81.844 69.504,84.156
71.098,84.156 71.098,93.711 84.598,94.25 84.598,86.062 "/>
<polyline fill="#E8E5D1" points="2.41,90.819 2.41,85.562 2.973,85.562 3.66,90.819 "/>
<g>
<g>
<rect x="31.306" y="91.138" fill="#707E68" width="5.821" height="1.972"/>
<rect x="25.159" y="91.138" fill="#BFA176" width="5.821" height="1.972"/>
<rect x="37.451" y="91.138" fill="#B07959" width="5.821" height="1.972"/>
<rect x="43.597" y="91.138" fill="#9A6B50" width="5.821" height="1.972"/>
<rect x="49.742" y="91.138" fill="#7E3F20" width="5.821" height="1.972"/>
<rect x="55.887" y="91.138" fill="#D6543B" width="5.822" height="1.972"/>
<rect x="62.033" y="91.138" fill="#B07959" width="5.821" height="1.972"/>
</g>
<g>
<g>
<rect x="62.033" y="88.847" fill="#707E68" width="5.821" height="1.972"/>
<rect x="55.887" y="88.847" fill="#B07959" width="5.822" height="1.972"/>
<rect x="49.742" y="88.847" fill="#D2B48C" width="5.821" height="1.972"/>
<rect x="43.597" y="88.847" fill="#7E3F20" width="5.821" height="1.972"/>
<rect x="37.451" y="88.847" fill="#D6543B" width="5.821" height="1.972"/>
<rect x="31.306" y="88.847" fill="#DEA374" width="5.821" height="1.972"/>
</g>
<g>
<rect x="25.159" y="88.847" fill="#707E68" width="5.821" height="1.972"/>
<rect x="19.014" y="88.847" fill="#B07959" width="5.821" height="1.972"/>
<rect x="12.868" y="88.847" fill="#D2B48C" width="5.822" height="1.972"/>
<rect x="6.723" y="88.847" fill="#7E3F20" width="5.821" height="1.972"/>
</g>
</g>
<g>
<rect x="37.45" y="84.266" fill="#707E68" width="5.821" height="1.972"/>
<rect x="43.597" y="84.266" fill="#BFA176" width="5.821" height="1.972"/>
<rect x="31.305" y="84.266" fill="#B07959" width="5.821" height="1.972"/>
<rect x="25.159" y="84.266" fill="#9A6B50" width="5.821" height="1.972"/>
<rect x="19.014" y="84.266" fill="#7E3F20" width="5.821" height="1.972"/>
<rect x="12.868" y="84.266" fill="#D6543B" width="5.822" height="1.972"/>
<rect x="6.723" y="84.266" fill="#B07959" width="5.821" height="1.972"/>
</g>
<g>
<g>
<rect x="6.723" y="86.557" fill="#707E68" width="5.821" height="1.972"/>
<rect x="12.868" y="86.557" fill="#B07959" width="5.822" height="1.972"/>
<rect x="19.014" y="86.557" fill="#D2B48C" width="5.821" height="1.972"/>
<rect x="25.159" y="86.557" fill="#7E3F20" width="5.821" height="1.972"/>
<rect x="31.305" y="86.557" fill="#D6543B" width="5.821" height="1.972"/>
<rect x="37.45" y="86.557" fill="#DEA374" width="5.821" height="1.972"/>
</g>
<g>
<rect x="43.597" y="86.557" fill="#707E68" width="5.821" height="1.972"/>
<rect x="49.742" y="86.557" fill="#B07959" width="5.821" height="1.972"/>
<rect x="55.887" y="86.557" fill="#D2B48C" width="5.822" height="1.972"/>
<rect x="62.033" y="86.557" fill="#7E3F20" width="5.821" height="1.972"/>
</g>
</g>
</g>
<polygon fill="#A82526" points="87.473,92.819 24.348,92.819 24.348,90.069 6.848,90.069 0,90.069 1.721,94.688 87.473,94.688 "/>
<path fill="#59565F" d="M1.721,94.688l0.502,1.347c-5,2.965,0,5.61,0,5.61s83.5,0.799,83.677,0s1.573-3.076,1.573-3.076v-3.881
H1.721z"/>
<g>
<rect x="69.91" y="82.312" fill="#636B62" width="1.156" height="0.844"/>
<rect x="71.551" y="82.312" fill="#636B62" width="1.156" height="0.844"/>
<rect x="73.191" y="82.312" fill="#636B62" width="1.156" height="0.844"/>
<rect x="74.832" y="82.312" fill="#636B62" width="1.156" height="0.844"/>
<rect x="76.473" y="82.312" fill="#636B62" width="1.156" height="0.844"/>
</g>
<g>
<rect x="77.246" y="87.163" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="78.422" y="87.163" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="79.598" y="87.163" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="80.773" y="87.163" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="81.949" y="87.163" fill="#9BA9B2" width="0.828" height="0.844"/>
</g>
<g>
<rect x="77.309" y="89.225" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="78.484" y="89.225" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="79.66" y="89.225" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="80.836" y="89.225" fill="#9BA9B2" width="0.828" height="0.844"/>
<rect x="82.012" y="89.225" fill="#9BA9B2" width="0.828" height="0.844"/>
</g>
<circle fill="#5F3F2B" cx="4.191" cy="92.281" r="0.531"/>
<rect x="80.186" y="82.312" fill="#808080" width="0.828" height="0.844"/>
<rect x="81.361" y="82.312" fill="#808080" width="0.828" height="0.844"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

39
static/img/500/water.svg Normal file
View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="453.54px" height="453.54px" viewBox="0 0 453.54 453.54" enable-background="new 0 0 453.54 453.54" xml:space="preserve">
<g>
<path fill="#608DA2" d="M35.218,327.768c0,0,6.968-3.055,15.596-5.071c8.628-2.019,32.723-5.351,38.561-6.217
s9.791-1.617,9.791-1.617s-1.682-3.031,3.623-6.049c0,0-1.65,5.794,6.934,6.829c0,0-1.543,3.026-6.945,1.529
c-5.403-1.498-23.869,18.74-50.771,15.825C52.005,332.997,35.635,333.734,35.218,327.768z"/>
<path fill="#608DA2" d="M52.425,331.592c0,0,1.417-0.072,2.878,2.231c1.462,2.306,2.81,4.634,4.659,3.272
c0,0,0.811-0.655-0.449-3.124s-1.633-2.268-3.502-3.169"/>
<path fill="#CCCCCC" d="M35.778,328.087c0,0,20.057-1.34,23.473-1.82c3.416-0.48,13.04-1.496,15.736-2.264
s23.476-6.962,25.139-7.27c1.664-0.308,7.606-0.167,7.836-0.738c0.229-0.572-6.029,0.688-5.465-6.043c0,0-3.349,0.708-3.042,4.812
l0.079,0.359c0,0-21.463,3.516-25.714,3.978C69.568,319.562,46.746,322.869,35.778,328.087z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="225.002" y1="450.7471" x2="228.5022" y2="101.2466">
<stop offset="0" style="stop-color:#2E3192"/>
<stop offset="1" style="stop-color:#0071BC"/>
</linearGradient>
<path opacity="0.8" fill="url(#SVGID_1_)" d="M450.2,93.711c-3.546,0-3.546,2.115-7.091,2.115c-3.546,0-3.546-2.115-7.092-2.115
s-3.546,2.115-7.09,2.115c-3.545,0-3.545-2.115-7.089-2.115c-3.545,0-3.545,2.115-7.089,2.115c-3.546,0-3.546-2.115-7.092-2.115
s-3.546,2.115-7.092,2.115c-3.545,0-3.545-2.115-7.091-2.115s-3.546,2.115-7.091,2.115c-3.544,0-3.544-2.115-7.09-2.115
c-3.545,0-3.545,2.115-7.089,2.115c-3.547,0-3.547-2.115-7.093-2.115c-3.547,0-3.547,2.115-7.093,2.115s-3.546-2.115-7.09-2.115
c-3.546,0-3.546,2.115-7.091,2.115s-3.545-2.115-7.091-2.115c-3.547,0-3.547,2.115-7.093,2.115s-3.546-2.115-7.093-2.115
c-3.546,0-3.546,2.115-7.09,2.115c-3.546,0-3.546-2.115-7.092-2.115c-3.547,0-3.547,2.115-7.093,2.115
c-3.547,0-3.547-2.115-7.093-2.115s-3.546,2.115-7.092,2.115c-3.545,0-3.545-2.115-7.092-2.115c-3.546,0-3.546,2.115-7.093,2.115
c-3.546,0-3.546-2.115-7.092-2.115c-3.547,0-3.547,2.115-7.092,2.115c-3.547,0-3.547-2.115-7.094-2.115
c-3.546,0-3.546,2.115-7.092,2.115c-3.547,0-3.547-2.115-7.094-2.115s-3.547,2.115-7.093,2.115c-3.545,0-3.545-2.115-7.093-2.115
c-3.547,0-3.547,2.115-7.092,2.115c-3.546,0-3.546-2.115-7.093-2.115s-3.547,2.115-7.095,2.115c-3.544,0-3.544-2.115-7.089-2.115
s-3.544,2.115-7.088,2.115c-3.544,0-3.544-2.115-7.091-2.115c-3.546,0-3.546,2.115-7.093,2.115c-3.545,0-3.545-2.115-7.089-2.115
c-3.546,0-3.546,2.115-7.091,2.115c-3.547,0-3.547-2.115-7.094-2.115c-3.544,0-3.544,2.115-7.091,2.115s-3.546-2.115-7.093-2.115
c-3.546,0-3.546,2.115-7.093,2.115c-3.547,0-3.547-2.115-7.093-2.115c-3.546,0-3.546,2.115-7.093,2.115
c-3.548,0-3.548-2.115-7.095-2.115s-3.547,2.115-7.093,2.115c-3.548,0-3.548-2.115-7.096-2.115s-3.548,2.115-7.096,2.115
c-3.547,0-3.547-2.115-7.096-2.115c-3.548,0-3.548,2.115-7.095,2.115c-3.548,0-3.548-2.115-7.096-2.115
c-3.549,0-3.549,2.115-7.098,2.115c-3.546,0-3.546-2.115-7.093-2.115s-3.547,2.115-7.095,2.115s-3.548-2.115-7.096-2.115
c-3.547,0-3.547,2.115-7.096,2.115c-3.549,0-3.549-2.115-7.098-2.115c-3.546,0-3.546,2.115-7.094,2.115
c-3.55,0-3.55-2.115-7.1-2.115c-3.553,0-3.553,2.115-7.105,2.115c-1.646,0-2.527-0.454-3.354-0.942V453.54h453.54V94.647
C452.718,94.162,451.837,93.711,450.2,93.711z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Before After
Before After

File diff suppressed because it is too large Load diff

View file

@ -638,10 +638,8 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$rootScope.description = jQuery(getFirstTextLine(repo.description)).text() || $rootScope.description = jQuery(getFirstTextLine(repo.description)).text() ||
'Visualization of images and tags for ' + kind + ' Docker repository: ' + qualifiedRepoName; 'Visualization of images and tags for ' + kind + ' Docker repository: ' + qualifiedRepoName;
// If the repository is marked as building, start monitoring it for changes. // Load the builds for this repository. If none are active it will cancel the poll.
if (repo.is_building) { startBuildInfoTimer(repo);
startBuildInfoTimer(repo);
}
$('#copyClipboard').clipboardCopy(); $('#copyClipboard').clipboardCopy();
}); });
@ -672,15 +670,19 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
}; };
ApiService.getRepoBuilds(null, params, true).then(function(resp) { ApiService.getRepoBuilds(null, params, true).then(function(resp) {
// Build a filtered list of the builds that are currently running.
var runningBuilds = []; var runningBuilds = [];
for (var i = 0; i < resp.builds.length; ++i) { for (var i = 0; i < resp.builds.length; ++i) {
var build = resp.builds[i]; var build = resp.builds[i];
if (build.status != 'complete') { if (build['phase'] != 'complete' && build['phase'] != 'error') {
runningBuilds.push(build); runningBuilds.push(build);
} }
} }
$scope.buildsInfo = runningBuilds; var existingBuilds = $scope.runningBuilds || [];
$scope.runningBuilds = runningBuilds;
$scope.buildHistory = resp.builds;
if (!runningBuilds.length) { if (!runningBuilds.length) {
// Cancel the build timer. // Cancel the build timer.
cancelBuildInfoTimer(); cancelBuildInfoTimer();
@ -688,8 +690,10 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
// Mark the repo as no longer building. // Mark the repo as no longer building.
$scope.repo.is_building = false; $scope.repo.is_building = false;
// Reload the repo information. // Reload the repo information if all of the builds recently finished.
loadViewInfo(); if (existingBuilds.length > 0) {
loadViewInfo();
}
} }
}); });
}; };
@ -798,9 +802,23 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
// itself (should) be the Dockerfile. // itself (should) be the Dockerfile.
if (zipFiles && Object.keys(zipFiles).length) { if (zipFiles && Object.keys(zipFiles).length) {
// Load the dockerfile contents. // Load the dockerfile contents.
var dockerfile = zip.file('Dockerfile'); var dockerfilePath = 'Dockerfile';
if ($scope.repobuild['job_config']) {
var dockerfileFolder = ($scope.repobuild['job_config']['build_subdir'] || '');
if (dockerfileFolder[0] == '/') {
dockerfileFolder = dockerfileFolder.substr(1);
}
if (dockerfileFolder && dockerfileFolder[dockerfileFolder.length - 1] != '/') {
dockerfileFolder += '/';
}
dockerfilePath = dockerfileFolder + 'Dockerfile';
}
var dockerfile = zip.file(dockerfilePath);
if (dockerfile) { if (dockerfile) {
$scope.dockerFileContents = dockerfile.asText(); $scope.dockerFileContents = dockerfile.asText();
$scope.dockerFilePath = dockerfilePath;
} }
// Build the zip file tree. // Build the zip file tree.
@ -815,21 +833,17 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
}); });
} else { } else {
$scope.dockerFileContents = response; $scope.dockerFileContents = response;
$scope.dockerFilePath = 'Dockerfile';
} }
$scope.loaded = true; $scope.loaded = true;
}; };
var downloadBuildPack = function() { var downloadBuildPack = function(url) {
$scope.downloadProgress = 0; $scope.downloadProgress = 0;
$scope.downloading = true; $scope.downloading = true;
ApiService.getRepoBuildArchiveUrl(null, params).then(function(resp) { startDownload(url);
startDownload(resp['url']);
}, function() {
$scope.downloading = false;
$scope.downloadError = true;
});
}; };
var startDownload = function(url) { var startDownload = function(url) {
@ -880,7 +894,7 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
'name': name 'name': name
}; };
downloadBuildPack(); downloadBuildPack(resp['archive_url']);
return resp; return resp;
}); });
}; };
@ -888,7 +902,8 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
getBuildInfo(); getBuildInfo();
} }
function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize, ansi2html) { function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize,
ansi2html, AngularViewArray) {
var namespace = $routeParams.namespace; var namespace = $routeParams.namespace;
var name = $routeParams.name; var name = $routeParams.name;
var pollTimerHandle = null; var pollTimerHandle = null;
@ -904,7 +919,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
} }
}); });
$scope.builds = []; $scope.builds = null;
$scope.polling = false; $scope.polling = false;
$scope.buildDialogShowCounter = 0; $scope.buildDialogShowCounter = 0;
@ -914,12 +929,16 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
}; };
$scope.handleBuildStarted = function(newBuild) { $scope.handleBuildStarted = function(newBuild) {
$scope.builds.push(newBuild); $scope.builds.unshift(newBuild);
$scope.setCurrentBuild(newBuild['id'], true); $scope.setCurrentBuild(newBuild['id'], true);
}; };
$scope.adjustLogHeight = function() { $scope.adjustLogHeight = function() {
$('.build-logs').height($(window).height() - 415); var triggerOffset = 0;
if ($scope.currentBuild && $scope.currentBuild.trigger) {
triggerOffset = 85;
}
$('.build-logs').height($(window).height() - 415 - triggerOffset);
}; };
$scope.askRestartBuild = function(build) { $scope.askRestartBuild = function(build) {
@ -929,8 +948,14 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
$scope.restartBuild = function(build) { $scope.restartBuild = function(build) {
$('#confirmRestartBuildModal').modal('hide'); $('#confirmRestartBuildModal').modal('hide');
var subdirectory = '';
if (build['job_config']) {
subdirectory = build['job_config']['build_subdir'] || '';
}
var data = { var data = {
'file_id': build['resource_key'] 'file_id': build['resource_key'],
'subdirectory': subdirectory
}; };
var params = { var params = {
@ -938,26 +963,18 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
}; };
ApiService.requestRepoBuild(data, params).then(function(newBuild) { ApiService.requestRepoBuild(data, params).then(function(newBuild) {
$scope.builds.push(newBuild); $scope.builds.unshift(newBuild);
$scope.setCurrentBuild(newBuild['id'], true); $scope.setCurrentBuild(newBuild['id'], true);
}); });
}; };
$scope.hasLogs = function(container) { $scope.hasLogs = function(container) {
return ((container.logs && container.logs.length) || (container._logs && container._logs.length)); return container.logs.hasEntries;
};
$scope.toggleLogs = function(container) {
if (container._logs) {
container.logs = container._logs;
container._logs = null;
} else {
container._logs = container.logs;
container.logs = null;
}
}; };
$scope.setCurrentBuild = function(buildId, opt_updateURL) { $scope.setCurrentBuild = function(buildId, opt_updateURL) {
if (!$scope.builds) { return; }
// Find the build. // Find the build.
for (var i = 0; i < $scope.builds.length; ++i) { for (var i = 0; i < $scope.builds.length; ++i) {
if ($scope.builds[i].id == buildId) { if ($scope.builds[i].id == buildId) {
@ -1042,17 +1059,13 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
var entry = logs[i]; var entry = logs[i];
var type = entry['type'] || 'entry'; var type = entry['type'] || 'entry';
if (type == 'command' || type == 'phase' || type == 'error') { if (type == 'command' || type == 'phase' || type == 'error') {
entry['_logs'] = []; entry['logs'] = AngularViewArray.create();
entry['index'] = startIndex + i; entry['index'] = startIndex + i;
$scope.logEntries.push(entry); $scope.logEntries.push(entry);
$scope.currentParentEntry = entry; $scope.currentParentEntry = entry;
} else if ($scope.currentParentEntry) { } else if ($scope.currentParentEntry) {
if ($scope.currentParentEntry['logs']) { $scope.currentParentEntry['logs'].push(entry);
$scope.currentParentEntry['logs'].push(entry);
} else {
$scope.currentParentEntry['_logs'].push(entry);
}
} }
} }
}; };
@ -1120,7 +1133,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
if ($location.search().current) { if ($location.search().current) {
$scope.setCurrentBuild($location.search().current, false); $scope.setCurrentBuild($location.search().current, false);
} else if ($scope.builds.length > 0) { } else if ($scope.builds.length > 0) {
$scope.setCurrentBuild($scope.builds[$scope.builds.length - 1].id, true); $scope.setCurrentBuild($scope.builds[0].id, true);
} }
}); });
}; };
@ -1128,7 +1141,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
fetchRepository(); fetchRepository();
} }
function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope) { function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope, $location) {
var namespace = $routeParams.namespace; var namespace = $routeParams.namespace;
var name = $routeParams.name; var name = $routeParams.name;
@ -1138,6 +1151,33 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
$scope.permissionCache = {}; $scope.permissionCache = {};
$scope.githubRedirectUri = KeyService.githubRedirectUri;
$scope.githubClientId = KeyService.githubClientId;
$scope.getBadgeFormat = function(format, repo) {
if (!repo) { return; }
var imageUrl = 'https://quay.io/repository/' + namespace + '/' + name + '/status';
if (!$scope.repo.is_public) {
imageUrl += '?token=' + $scope.repo.status_token;
}
var linkUrl = 'https://quay.io/repository/' + namespace + '/' + name;
switch (format) {
case 'svg':
return imageUrl;
case 'md':
return '[![Docker Repository on Quay.io](' + imageUrl + ' "Docker Repository on Quay.io")](' + linkUrl + ')';
case 'asciidoc':
return 'image:' + imageUrl + '["Docker Repository on Quay.io", link="' + linkUrl + '"]';
}
return '';
};
$scope.buildEntityForPermission = function(name, permission, kind) { $scope.buildEntityForPermission = function(name, permission, kind) {
var key = name + ':' + kind; var key = name + ':' + kind;
if ($scope.permissionCache[key]) { if ($scope.permissionCache[key]) {
@ -1196,7 +1236,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
}; };
var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionPost.customPOST(permission).then(function(result) { permissionPost.customPUT(permission).then(function(result) {
$scope.permissions[kind][entityName] = result; $scope.permissions[kind][entityName] = result;
}, function(result) { }, function(result) {
$('#cannotchangeModal').modal({}); $('#cannotchangeModal').modal({});
@ -1358,6 +1398,130 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
}); });
}; };
$scope.showBuild = function(buildInfo) {
$location.path('/repository/' + namespace + '/' + name + '/build');
$location.search('current', buildInfo.id);
};
$scope.loadTriggerBuildHistory = function(trigger) {
trigger.$loadingHistory = true;
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id,
'limit': 3
};
ApiService.listTriggerRecentBuilds(null, params).then(function(resp) {
trigger.$builds = resp['builds'];
trigger.$loadingHistory = false;
});
};
$scope.loadTriggers = function() {
var params = {
'repository': namespace + '/' + name
};
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
$scope.triggers = resp.triggers;
// Check to see if we need to setup any trigger.
var newTriggerId = $routeParams.new_trigger;
if (newTriggerId) {
for (var i = 0; i < $scope.triggers.length; ++i) {
var trigger = $scope.triggers[i];
if (trigger['id'] == newTriggerId && !trigger['is_active']) {
$scope.setupTrigger(trigger);
break;
}
}
}
return $scope.triggers;
});
};
$scope.setupTrigger = function(trigger) {
$scope.triggerSetupReady = false;
$scope.currentSetupTrigger = trigger;
$('#setupTriggerModal').modal({});
$('#setupTriggerModal').on('hidden.bs.modal', function () {
$scope.$apply(function() {
$scope.cancelSetupTrigger();
});
});
};
$scope.finishSetupTrigger = function(trigger) {
$('#setupTriggerModal').modal('hide');
$scope.currentSetupTrigger = null;
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
ApiService.activateBuildTrigger(trigger['config'], params).then(function(resp) {
trigger['is_active'] = true;
}, function(resp) {
$scope.triggers.splice($scope.triggers.indexOf(trigger), 1);
bootbox.dialog({
"message": resp['data']['message'] || 'The build trigger setup could not be completed',
"title": "Could not activate build trigger",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.cancelSetupTrigger = function() {
if (!$scope.currentSetupTrigger) { return; }
$('#setupTriggerModal').modal('hide');
$scope.deleteTrigger($scope.currentSetupTrigger);
$scope.currentSetupTrigger = null;
};
$scope.startTrigger = function(trigger) {
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
ApiService.manuallyStartBuildTrigger(null, params).then(function(resp) {
window.console.log(resp);
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url;
}, function(resp) {
bootbox.dialog({
"message": resp['message'] || 'The build could not be started',
"title": "Could not start build",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.deleteTrigger = function(trigger) {
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
ApiService.deleteBuildTrigger(null, params).then(function(resp) {
$scope.triggers.splice($scope.triggers.indexOf(trigger), 1);
});
};
var fetchTokens = function() { var fetchTokens = function() {
var params = { var params = {
'repository': namespace + '/' + name 'repository': namespace + '/' + name
@ -1421,7 +1585,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
} }
UserService.updateUserIn($scope, function(user) { UserService.updateUserIn($scope, function(user) {
$scope.askForPassword = user.askForPassword;
$scope.cuser = jQuery.extend({}, user); $scope.cuser = jQuery.extend({}, user);
for (var i = 0; i < $scope.cuser.logins.length; i++) { for (var i = 0; i < $scope.cuser.logins.length; i++) {
@ -1447,12 +1610,42 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.org = {}; $scope.org = {};
$scope.githubRedirectUri = KeyService.githubRedirectUri; $scope.githubRedirectUri = KeyService.githubRedirectUri;
$scope.githubClientId = KeyService.githubClientId; $scope.githubClientId = KeyService.githubClientId;
$scope.authorizedApps = null;
$('.form-change').popover(); $('.form-change').popover();
$scope.logsShown = 0; $scope.logsShown = 0;
$scope.invoicesShown = 0; $scope.invoicesShown = 0;
$scope.loadAuthedApps = function() {
if ($scope.authorizedApps) { return; }
ApiService.listUserAuthorizations().then(function(resp) {
$scope.authorizedApps = resp['authorizations'];
});
};
$scope.deleteAccess = function(accessTokenInfo) {
var params = {
'access_token_uuid': accessTokenInfo['uuid']
};
ApiService.deleteUserAuthorization(null, params).then(function(resp) {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not revoke authorization',
"title": "Cannot revoke authorization",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.loadLogs = function() { $scope.loadLogs = function() {
if (!$scope.hasPaidBusinessPlan) { return; } if (!$scope.hasPaidBusinessPlan) { return; }
$scope.logsShown++; $scope.logsShown++;
@ -1518,7 +1711,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.sentEmail = $scope.cuser.email; $scope.sentEmail = $scope.cuser.email;
// Reset the form. // Reset the form.
$scope.cuser.repeatEmail = ''; delete $scope.cuser['repeatEmail'];
$scope.changeEmailForm.$setPristine(); $scope.changeEmailForm.$setPristine();
}, function(result) { }, function(result) {
$scope.updatingUser = false; $scope.updatingUser = false;
@ -1540,8 +1734,9 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.changePasswordSuccess = true; $scope.changePasswordSuccess = true;
// Reset the form // Reset the form
$scope.cuser.password = ''; delete $scope.cuser['password']
$scope.cuser.repeatPassword = ''; delete $scope.cuser['repeatPassword']
$scope.changePasswordForm.$setPristine(); $scope.changePasswordForm.$setPristine();
// Reload the user. // Reload the user.
@ -1614,6 +1809,16 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
}, 10); }, 10);
}; };
var fetchRepository = function() {
var params = {
'repository': namespace + '/' + name
};
ApiService.getRepoAsResource(params).get(function(repo) {
$scope.repo = repo;
});
};
var fetchImage = function() { var fetchImage = function() {
var params = { var params = {
'repository': namespace + '/' + name, 'repository': namespace + '/' + name,
@ -1621,10 +1826,13 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
}; };
$scope.image = ApiService.getImageAsResource(params).get(function(image) { $scope.image = ApiService.getImageAsResource(params).get(function(image) {
$scope.repo = { if (!$scope.repo) {
'name': name, $scope.repo = {
'namespace': namespace 'name': name,
}; 'namespace': namespace,
'is_public': true
};
}
$rootScope.title = 'View Image - ' + image.id; $rootScope.title = 'View Image - ' + image.id;
$rootScope.description = 'Viewing docker image ' + image.id + ' under repository ' + namespace + '/' + name + $rootScope.description = 'Viewing docker image ' + image.id + ' under repository ' + namespace + '/' + name +
@ -1665,6 +1873,9 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
}); });
}; };
// Fetch the repository.
fetchRepository();
// Fetch the image. // Fetch the image.
fetchImage(); fetchImage();
} }
@ -1673,13 +1884,16 @@ function V1Ctrl($scope, $location, UserService) {
UserService.updateUserIn($scope); UserService.updateUserIn($scope);
} }
function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService) { function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, KeyService) {
UserService.updateUserIn($scope); UserService.updateUserIn($scope);
$scope.githubRedirectUri = KeyService.githubRedirectUri;
$scope.githubClientId = KeyService.githubClientId;
$scope.repo = { $scope.repo = {
'is_public': 1, 'is_public': 1,
'description': '', 'description': '',
'initialize': false 'initialize': ''
}; };
// Watch the namespace on the repo. If it changes, we update the plan and the public/private // Watch the namespace on the repo. If it changes, we update the plan and the public/private
@ -1691,37 +1905,14 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
var isUserNamespace = (namespace == $scope.user.username); var isUserNamespace = (namespace == $scope.user.username);
$scope.checkingPlan = true;
$scope.planRequired = null; $scope.planRequired = null;
$scope.isUserNamespace = isUserNamespace; $scope.isUserNamespace = isUserNamespace;
if (isUserNamespace) { // Determine whether private repositories are allowed for the namespace.
// Load the user's subscription information in case they want to create a private checkPrivateAllowed();
// repository.
ApiService.getUserPrivateCount().then(function(resp) {
if (resp.privateCount + 1 > resp.reposAllowed) {
PlanService.getMinimumPlan(resp.privateCount + 1, false, function(minimum) {
$scope.planRequired = minimum;
});
}
$scope.checkingPlan = false; // Default to private repos for organizations.
}, function() { $scope.repo.is_public = isUserNamespace ? '1' : '0';
$scope.planRequired = {};
$scope.checkingPlan = false;
});
} else {
ApiService.getOrganizationPrivateAllowed(null, {'orgname': namespace}).then(function(resp) {
$scope.planRequired = resp.privateAllowed ? null : {};
$scope.checkingPlan = false;
}, function() {
$scope.planRequired = {};
$scope.checkingPlan = false;
});
// Auto-set to private repo.
$scope.repo.is_public = '0';
}
}); });
$scope.changeNamespace = function(namespace) { $scope.changeNamespace = function(namespace) {
@ -1771,12 +1962,20 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
$scope.creating = false; $scope.creating = false;
$scope.created = created; $scope.created = created;
// Repository created. Start the upload process if applicable. // Start the upload process if applicable.
if ($scope.repo.initialize) { if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') {
$scope.createdForBuild = created; $scope.createdForBuild = created;
return; return;
} }
// Conduct the Github redirect if applicable.
if ($scope.repo.initialize == 'github') {
window.location = 'https://github.com/login/oauth/authorize?client_id=' + $scope.githubClientId +
'&scope=repo,user:email&redirect_uri=' + $scope.githubRedirectUri + '/trigger/' +
repo.namespace + '/' + repo.name;
return;
}
// Otherwise, redirect to the repo page. // Otherwise, redirect to the repo page.
$location.path('/repository/' + created.namespace + '/' + created.name); $location.path('/repository/' + created.namespace + '/' + created.name);
}, function(result) { }, function(result) {
@ -1800,7 +1999,35 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
} }
}; };
PlanService.changePlan($scope, null, $scope.planRequired.stripeId, callbacks); var namespace = $scope.isUserNamespace ? null : $scope.repo.namespace;
PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks);
};
var checkPrivateAllowed = function() {
if (!$scope.repo || !$scope.repo.namespace) { return; }
$scope.checkingPlan = true;
var isUserNamespace = $scope.isUserNamespace;
ApiService.getPrivateAllowed(isUserNamespace ? null : $scope.repo.namespace).then(function(resp) {
$scope.checkingPlan = false;
if (resp['privateAllowed']) {
$scope.planRequired = null;
return;
}
if (resp['privateCount'] == null) {
// Organization where we are not the admin.
$scope.planRequired = {};
return;
}
// Otherwise, lookup the matching plan.
PlanService.getMinimumPlan(resp['privateCount'] + 1, !isUserNamespace, function(minimum) {
$scope.planRequired = minimum;
});
});
}; };
var subscribedToPlan = function(sub) { var subscribedToPlan = function(sub) {
@ -1810,16 +2037,7 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
PlanService.getPlan(sub.plan, function(subscribedPlan) { PlanService.getPlan(sub.plan, function(subscribedPlan) {
$scope.subscribedPlan = subscribedPlan; $scope.subscribedPlan = subscribedPlan;
$scope.planRequired = null; $scope.planRequired = null;
checkPrivateAllowed();
// Check to see if the current plan allows for an additional private repository to
// be created.
var privateAllowed = $scope.subscription.usedPrivateRepos < $scope.subscribedPlan.privateRepos;
if (!privateAllowed) {
// If not, find the minimum repository that does.
PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos + 1, !$scope.isUserNamespace, function(minimum) {
$scope.planRequired = minimum;
});
}
}); });
}; };
} }
@ -1933,12 +2151,17 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
$scope.invoiceLoading = true; $scope.invoiceLoading = true;
$scope.logsShown = 0; $scope.logsShown = 0;
$scope.invoicesShown = 0; $scope.invoicesShown = 0;
$scope.applicationsShown = 0;
$scope.changingOrganization = false; $scope.changingOrganization = false;
$scope.loadLogs = function() { $scope.loadLogs = function() {
$scope.logsShown++; $scope.logsShown++;
}; };
$scope.loadApplications = function() {
$scope.applicationsShown++;
};
$scope.loadInvoices = function() { $scope.loadInvoices = function() {
$scope.invoicesShown++; $scope.invoicesShown++;
}; };
@ -2224,3 +2447,131 @@ function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangul
loadOrganization(); loadOrganization();
loadMemberInfo(); loadMemberInfo();
} }
function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $timeout, ApiService) {
var orgname = $routeParams.orgname;
var clientId = $routeParams.clientid;
$scope.updating = false;
$scope.askResetClientSecret = function() {
$('#resetSecretModal').modal({});
};
$scope.askDelete = function() {
$('#deleteAppModal').modal({});
};
$scope.deleteApplication = function() {
var params = {
'orgname': orgname,
'client_id': clientId
};
$('#deleteAppModal').modal('hide');
ApiService.deleteOrganizationApplication(null, params).then(function(resp) {
$timeout(function() {
$location.path('/organization/' + orgname + '/admin');
}, 500);
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not delete application',
"title": "Cannot delete application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.updateApplication = function() {
$scope.updating = true;
var params = {
'orgname': orgname,
'client_id': clientId
};
if (!$scope.application['description']) {
delete $scope.application['description'];
}
if (!$scope.application['gravatar_email']) {
delete $scope.application['gravatar_email'];
}
ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) {
$scope.application = resp;
$scope.updating = false;
}, function(resp) {
$scope.updating = false;
bootbox.dialog({
"message": resp.message || 'Could not update application',
"title": "Cannot update application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.resetClientSecret = function() {
var params = {
'orgname': orgname,
'client_id': clientId
};
$('#resetSecretModal').modal('hide');
ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) {
$scope.application = resp;
}, function(resp) {
bootbox.dialog({
"message": resp.message || 'Could not reset client secret',
"title": "Cannot reset client secret",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
var loadOrganization = function() {
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
$scope.organization = org;
return org;
});
};
var loadApplicationInfo = function() {
var params = {
'orgname': orgname,
'client_id': clientId
};
$scope.appResource = ApiService.getOrganizationApplicationAsResource(params).get(function(resp) {
$scope.application = resp;
$rootScope.title = 'Manage Application ' + $scope.application.name + ' (' + $scope.orgname + ')';
$rootScope.description = 'Manage the details of application ' + $scope.application.name +
' under organization ' + $scope.orgname;
return resp;
});
};
// Load the organization and application info.
loadOrganization();
loadApplicationInfo();
}

View file

@ -872,7 +872,8 @@ function FileTreeBase() {
* Calculates the dimensions of the tree. * Calculates the dimensions of the tree.
*/ */
FileTreeBase.prototype.calculateDimensions_ = function(container) { FileTreeBase.prototype.calculateDimensions_ = function(container) {
var cw = document.getElementById(container).clientWidth; var containerElm = document.getElementById(container);
var cw = containerElm ? containerElm.clientWidth : 1200;
var barHeight = 20; var barHeight = 20;
var ch = (this.getNodesHeight() * barHeight) + 40; var ch = (this.getNodesHeight() * barHeight) + 40;
@ -1470,7 +1471,7 @@ function LogUsageChart(titleMap) {
* Builds the D3-representation of the data. * Builds the D3-representation of the data.
*/ */
LogUsageChart.prototype.buildData_ = function(logs) { LogUsageChart.prototype.buildData_ = function(logs) {
var parseDate = d3.time.format("%a, %d %b %Y %H:%M:%S GMT").parse var parseDate = d3.time.format("%a, %d %b %Y %H:%M:%S %Z").parse
// Build entries for each kind of event that occurred, on each day. We have one // Build entries for each kind of event that occurred, on each day. We have one
// entry per {kind, day} pair. // entry per {kind, day} pair.

8
static/lib/angular-motion.min.css vendored Normal file

File diff suppressed because one or more lines are too long

3543
static/lib/angular-strap.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

9
static/lib/angular-strap.tpl.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
static/lib/typeahead.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -37,8 +37,9 @@
<div class="tab-content"> <div class="tab-content">
<!-- Dockerfile view --> <!-- Dockerfile view -->
<div class="tab-pane active" id="dockerfile"> <div class="tab-pane active" id="dockerfile">
<div class="dockerfile-view" contents="dockerFileContents"></div> <div class="dockerfile-path" ng-if="dockerFileContents">{{ dockerFilePath }}</div>
<span ng-show="!dockerFileContents">No Dockerfile found in the build pack</span> <div class="dockerfile-view" contents="dockerFileContents" ng-if="dockerFileContents"></div>
<span ng-if="!dockerFileContents">No Dockerfile found in the build pack</span>
</div> </div>
<!-- File tree --> <!-- File tree -->

View file

@ -17,20 +17,7 @@
<dl class="dl-normal"> <dl class="dl-normal">
<dt>Full Image ID</dt> <dt>Full Image ID</dt>
<dd> <dd>
<div> <div class="copy-box" value="image.value.id"></div>
<div class="id-container">
<div class="input-group">
<input id="full-id" type="text" class="form-control" value="{{ image.value.id }}" readonly>
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
<i class="fa fa-copy"></i>
</span>
</div>
</div>
<div id="clipboardCopied" style="display: none">
Copied to clipboard
</div>
</div>
</dd> </dd>
<dt>Created</dt> <dt>Created</dt>
<dd am-time-ago="parseDate(image.value.created)"></dd> <dd am-time-ago="parseDate(image.value.created)"></dd>

Some files were not shown because too many files have changed in this diff Show more