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
|
@ -55,6 +55,13 @@ running the tests:
|
|||
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:
|
||||
|
||||
```
|
||||
|
|
7
app.py
|
@ -8,7 +8,7 @@ from flask.ext.login import LoginManager
|
|||
from flask.ext.mail import Mail
|
||||
|
||||
from config import (ProductionConfig, DebugConfig, LocalHostedConfig,
|
||||
TestConfig)
|
||||
TestConfig, StagingConfig)
|
||||
from util import analytics
|
||||
|
||||
|
||||
|
@ -20,6 +20,9 @@ stack = os.environ.get('STACK', '').strip().lower()
|
|||
if stack.startswith('prod'):
|
||||
logger.info('Running with production config.')
|
||||
config = ProductionConfig()
|
||||
elif stack.startswith('staging'):
|
||||
logger.info('Running with staging config on production data.')
|
||||
config = StagingConfig()
|
||||
elif stack.startswith('localhosted'):
|
||||
logger.info('Running with debug config on production data.')
|
||||
config = LocalHostedConfig()
|
||||
|
@ -32,7 +35,7 @@ else:
|
|||
|
||||
app.config.from_object(config)
|
||||
|
||||
Principal(app, use_sessions=True)
|
||||
Principal(app, use_sessions=False)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
|
|
|
@ -9,22 +9,24 @@ application.config['LOGGING_CONFIG']()
|
|||
# Turn off debug logging for boto
|
||||
logging.getLogger('boto').setLevel(logging.CRITICAL)
|
||||
|
||||
from endpoints.api import api
|
||||
from endpoints.api import api_bp
|
||||
from endpoints.index import index
|
||||
from endpoints.web import web
|
||||
from endpoints.tags import tags
|
||||
from endpoints.registry import registry
|
||||
from endpoints.webhooks import webhooks
|
||||
from endpoints.realtime import realtime
|
||||
from endpoints.callbacks import callback
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
application.register_blueprint(web)
|
||||
application.register_blueprint(callback, url_prefix='/oauth2')
|
||||
application.register_blueprint(index, url_prefix='/v1')
|
||||
application.register_blueprint(tags, 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(realtime, url_prefix='/realtime')
|
||||
|
||||
|
@ -37,9 +39,5 @@ def close_db(exc):
|
|||
|
||||
application.teardown_request(close_db)
|
||||
|
||||
|
||||
# Remove this for prod config
|
||||
application.debug = True
|
||||
|
||||
if __name__ == '__main__':
|
||||
application.run(port=5000, debug=True, threaded=True, host='0.0.0.0')
|
||||
|
|
137
auth/auth.py
|
@ -1,20 +1,69 @@
|
|||
import logging
|
||||
|
||||
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.login import current_user
|
||||
from base64 import b64decode
|
||||
|
||||
import scopes
|
||||
|
||||
from data import model
|
||||
from data.model import oauth
|
||||
from app import app
|
||||
from permissions import QuayDeferredPermissionUser
|
||||
|
||||
from util.names import parse_namespace_repository
|
||||
from auth_context import (set_authenticated_user, set_validated_token,
|
||||
set_authenticated_user_deferred, set_validated_oauth_token)
|
||||
from util.http import abort
|
||||
|
||||
|
||||
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):
|
||||
normalized = [part.strip() for part in auth.split(' ') if part]
|
||||
if normalized[0].lower() != 'basic' or len(normalized) != 2:
|
||||
|
@ -31,8 +80,7 @@ def process_basic_auth(auth):
|
|||
try:
|
||||
token = model.load_token_data(credentials[1])
|
||||
logger.debug('Successfully validated token: %s' % credentials[1])
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = token
|
||||
set_validated_token(token)
|
||||
|
||||
identity_changed.send(app, identity=Identity(token.code, 'token'))
|
||||
return
|
||||
|
@ -40,16 +88,21 @@ def process_basic_auth(auth):
|
|||
except model.DataModelException:
|
||||
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]:
|
||||
logger.debug('Trying robot auth with credentials %s' % str(credentials))
|
||||
# Use as robot auth
|
||||
try:
|
||||
robot = model.verify_robot(credentials[0], credentials[1])
|
||||
logger.debug('Successfully validated robot: %s' % credentials[0])
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.authenticated_user = robot
|
||||
set_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
|
||||
except model.InvalidRobotException:
|
||||
logger.debug('Invalid robot or password for robot: %s' % credentials[0])
|
||||
|
@ -59,11 +112,10 @@ def process_basic_auth(auth):
|
|||
|
||||
if authenticated:
|
||||
logger.debug('Successfully validated user: %s' % authenticated.username)
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.authenticated_user = authenticated
|
||||
set_authenticated_user(authenticated)
|
||||
|
||||
new_identity = QuayDeferredPermissionUser(authenticated.username,
|
||||
'username')
|
||||
new_identity = QuayDeferredPermissionUser(authenticated.username, 'username',
|
||||
{scopes.DIRECT_LOGIN})
|
||||
identity_changed.send(app, identity=new_identity)
|
||||
return
|
||||
|
||||
|
@ -81,31 +133,49 @@ def process_token(auth):
|
|||
|
||||
if len(token_details) != 1:
|
||||
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
|
||||
(detail.split('=') for detail in token_details)}
|
||||
if 'signature' not in token_vals:
|
||||
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:
|
||||
token_data = model.load_token_data(token_vals['signature'])
|
||||
|
||||
except model.InvalidTokenException:
|
||||
logger.warning('Token could not be validated: %s' %
|
||||
token_vals['signature'])
|
||||
abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token', auth=auth)
|
||||
logger.warning('Token could not be validated: %s', token_vals['signature'])
|
||||
abort(401, message='Token could not be validated: %(auth)s', issue='invalid-auth-token',
|
||||
auth=auth)
|
||||
|
||||
logger.debug('Successfully validated token: %s' % token_data.code)
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = token_data
|
||||
logger.debug('Successfully validated token: %s', token_data.code)
|
||||
set_validated_token(token_data)
|
||||
|
||||
identity_changed.send(app, identity=Identity(token_data.code, 'token'))
|
||||
|
||||
|
||||
def process_auth(f):
|
||||
@wraps(f)
|
||||
def process_oauth(func):
|
||||
@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):
|
||||
auth = request.headers.get('authorization', '')
|
||||
|
||||
|
@ -116,17 +186,26 @@ def process_auth(f):
|
|||
else:
|
||||
logger.debug('No auth header.')
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def extract_namespace_repo_from_session(f):
|
||||
@wraps(f)
|
||||
def require_session_login(func):
|
||||
@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):
|
||||
if 'namespace' not in session or 'repository' not in session:
|
||||
logger.error('Unable to load namespace or repository from session: %s' %
|
||||
session)
|
||||
abort(400, message="Missing namespace in request")
|
||||
logger.error('Unable to load namespace or repository from session: %s' % session)
|
||||
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
|
||||
|
|
|
@ -1,7 +1,53 @@
|
|||
import logging
|
||||
|
||||
from flask import _request_ctx_stack
|
||||
from data import model
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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():
|
||||
return getattr(_request_ctx_stack.top, 'validated_token', None)
|
||||
|
||||
|
||||
def set_validated_token(token):
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = token
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import logging
|
||||
|
||||
from flask.ext.principal import (identity_loaded, UserNeed, Permission,
|
||||
Identity, identity_changed)
|
||||
from collections import namedtuple
|
||||
from flask.ext.principal import identity_loaded, Permission, Identity, identity_changed
|
||||
from collections import namedtuple, defaultdict
|
||||
from functools import partial
|
||||
|
||||
import scopes
|
||||
|
||||
from data import model
|
||||
from app import app
|
||||
|
||||
|
@ -14,44 +15,117 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role'])
|
||||
_RepositoryNeed = partial(_ResourceNeed, 'repository')
|
||||
_OrganizationNeed = namedtuple('organization', ['orgname', 'role'])
|
||||
_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role'])
|
||||
_NamespaceWideNeed = namedtuple('namespacewide', ['type', 'namespace', '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):
|
||||
def __init__(self, id, auth_type=None):
|
||||
def __init__(self, id, auth_type, scopes):
|
||||
super(QuayDeferredPermissionUser, self).__init__(id, auth_type)
|
||||
|
||||
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):
|
||||
if not self._permissions_loaded:
|
||||
logger.debug('Loading user permissions after deferring.')
|
||||
user_object = model.get_user(self.id)
|
||||
|
||||
# Add the user specific permissions
|
||||
user_grant = UserNeed(user_object.username)
|
||||
# Add the user specific permissions, only for non-oauth permission
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
for perm in model.get_all_user_permissions(user_object):
|
||||
grant = _RepositoryNeed(perm.repository.namespace,
|
||||
perm.repository.name, perm.role.name)
|
||||
logger.debug('User added permission: {0}'.format(grant))
|
||||
self.provides.add(grant)
|
||||
repo_grant = _RepositoryNeed(perm.repository.namespace, perm.repository.name,
|
||||
self._repo_role_for_scopes(perm.role.name))
|
||||
logger.debug('User added permission: {0}'.format(repo_grant))
|
||||
self.provides.add(repo_grant)
|
||||
|
||||
# Add namespace permissions derived
|
||||
for team in model.get_org_wide_permissions(user_object):
|
||||
grant = _OrganizationNeed(team.organization.username, team.role.name)
|
||||
logger.debug('Organization team added permission: {0}'.format(grant))
|
||||
self.provides.add(grant)
|
||||
team_org_grant = _OrganizationNeed(team.organization.username,
|
||||
self._team_role_for_scopes(team.role.name))
|
||||
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.role.name)
|
||||
self._team_role_for_scopes(team.role.name))
|
||||
logger.debug('Team added permission: {0}'.format(team_grant))
|
||||
self.provides.add(team_grant)
|
||||
|
||||
|
@ -64,9 +138,10 @@ class ModifyRepositoryPermission(Permission):
|
|||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need,
|
||||
org_admin_need)
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
org_write_need = _OrganizationRepoNeed(namespace, 'write')
|
||||
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, org_admin_need,
|
||||
org_write_need)
|
||||
|
||||
|
||||
class ReadRepositoryPermission(Permission):
|
||||
|
@ -74,15 +149,17 @@ class ReadRepositoryPermission(Permission):
|
|||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
read_need = _RepositoryNeed(namespace, name, 'read')
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
super(ReadRepositoryPermission, self).__init__(admin_need, write_need,
|
||||
read_need, org_admin_need)
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
org_write_need = _OrganizationRepoNeed(namespace, 'write')
|
||||
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):
|
||||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
org_admin_need = _OrganizationRepoNeed(namespace, 'admin')
|
||||
super(AdministerRepositoryPermission, self).__init__(admin_need,
|
||||
org_admin_need)
|
||||
|
||||
|
@ -95,10 +172,17 @@ class CreateRepositoryPermission(Permission):
|
|||
create_repo_org)
|
||||
|
||||
|
||||
class UserPermission(Permission):
|
||||
class UserAdminPermission(Permission):
|
||||
def __init__(self, username):
|
||||
user_need = UserNeed(username)
|
||||
super(UserPermission, self).__init__(user_need)
|
||||
user_admin = _UserNeed(username, 'admin')
|
||||
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):
|
||||
|
@ -132,14 +216,15 @@ def on_identity_loaded(sender, identity):
|
|||
# We have verified an identity, load in all of the permissions
|
||||
|
||||
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':
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
repo_grant = _RepositoryNeed(token_data.repository.namespace,
|
||||
|
@ -149,4 +234,4 @@ def on_identity_loaded(sender, identity):
|
|||
identity.provides.add(repo_grant)
|
||||
|
||||
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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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 |
67
config.py
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
import logstash_formatter
|
||||
import requests
|
||||
import os.path
|
||||
|
||||
from peewee import MySQLDatabase, SqliteDatabase
|
||||
from storage.s3 import S3Storage
|
||||
|
@ -18,6 +20,7 @@ class FlaskConfig(object):
|
|||
SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884'
|
||||
JSONIFY_PRETTYPRINT_REGULAR = False
|
||||
|
||||
|
||||
class FlaskProdConfig(FlaskConfig):
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
|
@ -44,7 +47,8 @@ class RealTransactions(object):
|
|||
class SQLiteDB(RealTransactions):
|
||||
DB_NAME = 'test/data/test.db'
|
||||
DB_CONNECTION_ARGS = {
|
||||
'threadlocals': True
|
||||
'threadlocals': True,
|
||||
'autorollback': True,
|
||||
}
|
||||
DB_DRIVER = SqliteDatabase
|
||||
|
||||
|
@ -76,6 +80,7 @@ class RDSMySQL(RealTransactions):
|
|||
'user': 'fluxmonkey',
|
||||
'passwd': '8eifM#uoZ85xqC^',
|
||||
'threadlocals': True,
|
||||
'autorollback': True,
|
||||
}
|
||||
DB_DRIVER = MySQLDatabase
|
||||
|
||||
|
@ -154,6 +159,11 @@ class GitHubTestConfig(object):
|
|||
GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails'
|
||||
|
||||
|
||||
class GitHubStagingConfig(GitHubTestConfig):
|
||||
GITHUB_CLIENT_ID = '4886304accbc444f0471'
|
||||
GITHUB_CLIENT_SECRET = '27d8a5d99af02dda821eb10883bcb2e785e70a62'
|
||||
|
||||
|
||||
class GitHubProdConfig(GitHubTestConfig):
|
||||
GITHUB_CLIENT_ID = '5a8c08b06c48d89d4d1e'
|
||||
GITHUB_CLIENT_SECRET = 'f89d8bb28ea3bd4e1c68808500d185a816be53b1'
|
||||
|
@ -185,36 +195,79 @@ def logs_init_builder(level=logging.DEBUG,
|
|||
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,
|
||||
FakeAnalytics, StripeTestConfig, RedisBuildLogs,
|
||||
UserEventConfig):
|
||||
UserEventConfig, LargePoolHttpClient, StatusTagConfig):
|
||||
LOGGING_CONFIG = logs_init_builder(logging.WARN)
|
||||
POPULATE_DB_TEST_DATA = True
|
||||
TESTING = True
|
||||
URL_SCHEME = 'http'
|
||||
URL_HOST = 'localhost:5000'
|
||||
|
||||
|
||||
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
||||
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
|
||||
DigitalOceanConfig, BuildNodeConfig, S3Userfiles,
|
||||
UserEventConfig, TestBuildLogs):
|
||||
UserEventConfig, TestBuildLogs, LargePoolHttpClient,
|
||||
StatusTagConfig):
|
||||
LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter())
|
||||
SEND_FILE_MAX_AGE_DEFAULT = 0
|
||||
POPULATE_DB_TEST_DATA = True
|
||||
URL_SCHEME = 'http'
|
||||
URL_HOST = 'ci.devtable.com:5000'
|
||||
|
||||
|
||||
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
||||
StripeLiveConfig, MixpanelTestConfig,
|
||||
GitHubProdConfig, DigitalOceanConfig,
|
||||
BuildNodeConfig, S3Userfiles, RedisBuildLogs,
|
||||
UserEventConfig):
|
||||
LOGGING_CONFIG = logs_init_builder()
|
||||
UserEventConfig, LargePoolHttpClient,
|
||||
StatusTagConfig):
|
||||
LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter())
|
||||
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,
|
||||
StripeLiveConfig, MixpanelProdConfig,
|
||||
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
|
||||
S3Userfiles, RedisBuildLogs, UserEventConfig):
|
||||
|
||||
S3Userfiles, RedisBuildLogs, UserEventConfig,
|
||||
LargePoolHttpClient, StatusTagConfig):
|
||||
LOGGING_CONFIG = logs_init_builder()
|
||||
SEND_FILE_MAX_AGE_DEFAULT = 0
|
||||
URL_SCHEME = 'https'
|
||||
URL_HOST = 'quay.io'
|
||||
|
|
|
@ -40,9 +40,12 @@ class BuildLogs(object):
|
|||
Returns a tuple of the current length of the list and an iterable of the
|
||||
requested log entries.
|
||||
"""
|
||||
llen = self._redis.llen(self._logs_key(build_id))
|
||||
log_entries = self._redis.lrange(self._logs_key(build_id), start_index, -1)
|
||||
return (llen, (json.loads(entry) for entry in log_entries))
|
||||
try:
|
||||
llen = self._redis.llen(self._logs_key(build_id))
|
||||
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
|
||||
def _status_key(build_id):
|
||||
|
@ -59,5 +62,9 @@ class BuildLogs(object):
|
|||
"""
|
||||
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
|
||||
|
|
|
@ -101,6 +101,7 @@ class Repository(BaseModel):
|
|||
name = CharField()
|
||||
visibility = ForeignKeyField(Visibility)
|
||||
description = TextField(null=True)
|
||||
badge_token = CharField(default=uuid_generator)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
@ -163,6 +164,20 @@ class AccessToken(BaseModel):
|
|||
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):
|
||||
code = CharField(default=random_string_generator(), unique=True, index=True)
|
||||
user = ForeignKeyField(User)
|
||||
|
@ -223,11 +238,12 @@ class RepositoryBuild(BaseModel):
|
|||
uuid = CharField(default=uuid_generator, index=True)
|
||||
repository = ForeignKeyField(Repository, index=True)
|
||||
access_token = ForeignKeyField(AccessToken)
|
||||
resource_key = CharField()
|
||||
tag = CharField()
|
||||
resource_key = CharField(index=True)
|
||||
job_config = TextField()
|
||||
phase = CharField(default='waiting')
|
||||
started = DateTimeField(default=datetime.now)
|
||||
display_name = CharField()
|
||||
trigger = ForeignKeyField(RepositoryBuildTrigger, null=True, index=True)
|
||||
|
||||
|
||||
class QueueItem(BaseModel):
|
||||
|
@ -255,8 +271,52 @@ class LogEntry(BaseModel):
|
|||
metadata_json = TextField(default='{}')
|
||||
|
||||
|
||||
all_models = [User, Repository, Image, AccessToken, Role,
|
||||
RepositoryPermission, Visibility, RepositoryTag,
|
||||
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
|
||||
RepositoryBuild, Team, TeamMember, TeamRole, Webhook,
|
||||
LogEntryKind, LogEntry, PermissionPrototype, ImageStorage]
|
||||
class OAuthApplication(BaseModel):
|
||||
client_id = CharField(index=True, default=random_string_generator(length=20))
|
||||
client_secret = CharField(default=random_string_generator(length=40))
|
||||
redirect_uri = CharField()
|
||||
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
|
@ -0,0 +1 @@
|
|||
from data.model.legacy import *
|
|
@ -2,11 +2,10 @@ import bcrypt
|
|||
import logging
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import operator
|
||||
import json
|
||||
|
||||
|
||||
from database import *
|
||||
from data.database import *
|
||||
from util.validation import *
|
||||
from util.names import format_robot_username
|
||||
|
||||
|
@ -55,7 +54,11 @@ class InvalidWebhookException(DataModelException):
|
|||
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):
|
||||
raise InvalidEmailAddressException('Invalid email address: %s' % email)
|
||||
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,
|
||||
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
|
||||
except Exception as ex:
|
||||
raise DataModelException(ex.message)
|
||||
|
@ -97,7 +106,7 @@ def create_user(username, password, email):
|
|||
def create_organization(name, email, creating_user):
|
||||
try:
|
||||
# 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.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,
|
||||
limit=None, sort=False, namespace=None):
|
||||
query = _visible_repository_query(username=username,
|
||||
include_public=include_public, page=page,
|
||||
limit=limit, namespace=namespace)
|
||||
query = _visible_repository_query(username=username, include_public=include_public, page=page,
|
||||
limit=limit, namespace=namespace,
|
||||
select_models=[Repository, Visibility])
|
||||
|
||||
if sort:
|
||||
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,
|
||||
page=None, namespace=None):
|
||||
page=None, namespace=None, select_models=[]):
|
||||
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()
|
||||
.join(Visibility)
|
||||
.switch(Repository)
|
||||
|
@ -658,6 +667,9 @@ def change_password(user, new_password):
|
|||
user.password_hash = pw_hash
|
||||
user.save()
|
||||
|
||||
# Remove any password required notifications for the user.
|
||||
delete_notifications_by_kind(user, 'password_required')
|
||||
|
||||
|
||||
def change_invoice_email(user, 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):
|
||||
joined = Repository.select().join(RepositoryBuild)
|
||||
query = joined.where(RepositoryBuild.resource_key == resource_key).limit(1)
|
||||
result = list(query)
|
||||
if not result:
|
||||
try:
|
||||
return (Repository
|
||||
.select()
|
||||
.join(RepositoryBuild)
|
||||
.where(RepositoryBuild.resource_key == resource_key)
|
||||
.get())
|
||||
except Repository.DoesNotExist:
|
||||
return None
|
||||
|
||||
return result[0]
|
||||
|
||||
|
||||
def get_repository(namespace_name, repository_name):
|
||||
try:
|
||||
|
@ -1284,7 +1297,10 @@ def set_user_repo_permission(username, namespace_name, repository_name,
|
|||
if username == namespace_name:
|
||||
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,
|
||||
repository_name, role_name)
|
||||
|
||||
|
@ -1327,8 +1343,9 @@ def create_access_token(repository, role):
|
|||
return new_token
|
||||
|
||||
|
||||
def create_delegate_token(namespace_name, repository_name, friendly_name):
|
||||
read_only = Role.get(name='read')
|
||||
def create_delegate_token(namespace_name, repository_name, friendly_name,
|
||||
role='read'):
|
||||
read_only = Role.get(name=role)
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
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):
|
||||
joined = RepositoryBuild.select().join(Repository)
|
||||
fetched = list(joined.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
RepositoryBuild.uuid == build_uuid))
|
||||
try:
|
||||
query = list_repository_builds(namespace_name, repository_name, 1)
|
||||
return query.where(RepositoryBuild.uuid == build_uuid).get()
|
||||
|
||||
if not fetched:
|
||||
except RepositoryBuild.DoesNotExist:
|
||||
msg = 'Unable to locate a build by id: %s' % build_uuid
|
||||
raise InvalidRepositoryBuildException(msg)
|
||||
|
||||
return fetched[0]
|
||||
|
||||
|
||||
def list_repository_builds(namespace_name, repository_name,
|
||||
def list_repository_builds(namespace_name, repository_name, limit,
|
||||
include_inactive=True):
|
||||
joined = RepositoryBuild.select().join(Repository)
|
||||
filtered = joined
|
||||
query = (RepositoryBuild
|
||||
.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:
|
||||
filtered = filtered.where(RepositoryBuild.phase != 'error',
|
||||
RepositoryBuild.phase != 'complete')
|
||||
fetched = list(filtered.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name))
|
||||
return fetched
|
||||
query = query.where(RepositoryBuild.phase != 'error',
|
||||
RepositoryBuild.phase != 'complete')
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def create_repository_build(repo, access_token, resource_key, tag,
|
||||
display_name):
|
||||
def get_recent_repository_build(namespace_name, repository_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,
|
||||
resource_key=resource_key, tag=tag,
|
||||
display_name=display_name)
|
||||
job_config=json.dumps(job_config_obj),
|
||||
display_name=display_name, trigger=trigger,
|
||||
resource_key=dockerfile_id)
|
||||
|
||||
|
||||
def create_webhook(repo, params_obj):
|
||||
|
@ -1445,27 +1476,116 @@ def delete_webhook(namespace_name, repository_name, public_id):
|
|||
webhook = get_webhook(namespace_name, repository_name, public_id)
|
||||
webhook.delete_instance()
|
||||
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:
|
||||
joined = joined.where(LogEntry.performer == performer)
|
||||
|
||||
return joined.where(
|
||||
User.username == user_or_organization_name,
|
||||
LogEntry.datetime >= start_time,
|
||||
LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc())
|
||||
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)
|
||||
|
||||
def log_action(kind_name, user_or_organization_name, performer=None, repository=None,
|
||||
access_token=None, ip=None, metadata={}, timestamp=None):
|
||||
if performer:
|
||||
joined = joined.where(LogEntry.performer == performer)
|
||||
|
||||
return joined.where(
|
||||
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:
|
||||
timestamp = datetime.today()
|
||||
|
||||
kind = LogEntryKind.get(LogEntryKind.name == kind_name)
|
||||
account = User.get(User.username == user_or_organization_name)
|
||||
entry = LogEntry.create(kind=kind, account=account, performer=performer,
|
||||
repository=repository, access_token=access_token, ip=ip,
|
||||
metadata_json=json.dumps(metadata), datetime=timestamp)
|
||||
LogEntry.create(kind=kind, account=account, performer=performer,
|
||||
repository=repository, access_token=access_token, ip=ip,
|
||||
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
|
@ -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='')
|
|
@ -68,5 +68,5 @@ class WorkQueue(object):
|
|||
|
||||
|
||||
image_diff_queue = WorkQueue('imagediff')
|
||||
dockerfile_build_queue = WorkQueue('dockerfilebuild2')
|
||||
dockerfile_build_queue = WorkQueue('dockerfilebuild3')
|
||||
webhook_queue = WorkQueue('webhook')
|
||||
|
|
|
@ -40,14 +40,15 @@ class UserRequestFiles(object):
|
|||
encrypt_key=True)
|
||||
return (url, file_id)
|
||||
|
||||
def store_file(self, flask_file):
|
||||
def store_file(self, file_like_obj, content_type):
|
||||
self._initialize_s3()
|
||||
file_id = str(uuid4())
|
||||
full_key = os.path.join(self._prefix, file_id)
|
||||
k = Key(self._bucket, full_key)
|
||||
logger.debug('Setting s3 content type to: %s' % flask_file.content_type)
|
||||
k.set_metadata('Content-Type', flask_file.content_type)
|
||||
bytes_written = k.set_contents_from_file(flask_file, encrypt_key=True)
|
||||
logger.debug('Setting s3 content type to: %s' % content_type)
|
||||
k.set_metadata('Content-Type', content_type)
|
||||
bytes_written = k.set_contents_from_file(file_like_obj, encrypt_key=True,
|
||||
rewind=True)
|
||||
|
||||
if bytes_written == 0:
|
||||
raise S3FileWriteException('Unable to write file to S3')
|
||||
|
|
2309
endpoints/api.py
281
endpoints/api/__init__.py
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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()
|
520
endpoints/api/organization.py
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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())]
|
||||
}
|
98
endpoints/api/subscribe.py
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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)
|
|
@ -1,18 +1,41 @@
|
|||
import logging
|
||||
import os
|
||||
import base64
|
||||
import urlparse
|
||||
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.principal import identity_changed
|
||||
from random import SystemRandom
|
||||
|
||||
from data import model
|
||||
from data.queue import dockerfile_build_queue
|
||||
from app import app, login_manager
|
||||
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__)
|
||||
|
||||
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):
|
||||
return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'}
|
||||
|
@ -20,9 +43,10 @@ def truthy_param(param):
|
|||
|
||||
@login_manager.user_loader
|
||||
def load_user(username):
|
||||
logger.debug('Loading user: %s' % username)
|
||||
logger.debug('User loader loading deferred user: %s' % username)
|
||||
return _LoginWrappedDBUser(username)
|
||||
|
||||
|
||||
class _LoginWrappedDBUser(UserMixin):
|
||||
def __init__(self, db_username, db_user=None):
|
||||
|
||||
|
@ -47,7 +71,7 @@ class _LoginWrappedDBUser(UserMixin):
|
|||
def common_login(db_user):
|
||||
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
|
||||
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)
|
||||
return True
|
||||
else:
|
||||
|
@ -58,19 +82,68 @@ def common_login(db_user):
|
|||
@app.errorhandler(model.DataModelException)
|
||||
def handle_dme(ex):
|
||||
logger.exception(ex)
|
||||
return make_response(ex.message, 400)
|
||||
return make_response(json.dumps({'message': ex.message}), 400)
|
||||
|
||||
|
||||
@app.errorhandler(KeyError)
|
||||
def handle_dme_key_error(ex):
|
||||
logger.exception(ex)
|
||||
return make_response('Invalid key: %s' % ex.message, 400)
|
||||
def random_string():
|
||||
random = SystemRandom()
|
||||
return ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(8)])
|
||||
|
||||
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():
|
||||
if '_csrf_token' not in session:
|
||||
session['_csrf_token'] = base64.b64encode(os.urandom(48))
|
||||
def check_repository_usage(user_or_org, plan_found):
|
||||
private_repos = model.get_private_repo_count(user_or_org.username)
|
||||
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
|
@ -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
|
|
@ -6,16 +6,16 @@ from flask import request, make_response, jsonify, session, Blueprint
|
|||
from functools import wraps
|
||||
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 app import mixpanel, app
|
||||
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.email import send_confirmation_email
|
||||
from auth.permissions import (ModifyRepositoryPermission, UserPermission,
|
||||
ReadRepositoryPermission,
|
||||
CreateRepositoryPermission)
|
||||
from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
|
||||
ReadRepositoryPermission, CreateRepositoryPermission)
|
||||
|
||||
from util.http import abort
|
||||
|
||||
|
@ -79,6 +79,13 @@ def create_user():
|
|||
except model.InvalidTokenException:
|
||||
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:
|
||||
try:
|
||||
model.verify_robot(username, password)
|
||||
|
@ -115,7 +122,12 @@ def create_user():
|
|||
@index.route('/users/', methods=['GET'])
|
||||
@process_auth
|
||||
def get_user():
|
||||
if get_authenticated_user():
|
||||
if get_validated_oauth_token():
|
||||
return jsonify({
|
||||
'username': '$oauthtoken',
|
||||
'email': None,
|
||||
})
|
||||
elif get_authenticated_user():
|
||||
return jsonify({
|
||||
'username': get_authenticated_user().username,
|
||||
'email': get_authenticated_user().email,
|
||||
|
@ -131,7 +143,7 @@ def get_user():
|
|||
@index.route('/users/<username>/', methods=['PUT'])
|
||||
@process_auth
|
||||
def update_user(username):
|
||||
permission = UserPermission(username)
|
||||
permission = UserAdminPermission(username)
|
||||
|
||||
if permission.can():
|
||||
update_request = request.get_json()
|
||||
|
@ -214,7 +226,14 @@ def create_repository(namespace, repository):
|
|||
'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
|
||||
|
||||
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.publish_event_data('docker-cli', user_data)
|
||||
|
||||
else:
|
||||
elif get_validated_token():
|
||||
mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
|
||||
metadata['token'] = get_validated_token().friendly_name
|
||||
metadata['token_code'] = get_validated_token().code
|
||||
|
@ -333,7 +352,13 @@ def get_repository_images(namespace, repository):
|
|||
'repo': repository,
|
||||
'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
|
||||
elif get_validated_token():
|
||||
metadata['token'] = get_validated_token().friendly_name
|
||||
|
|
|
@ -1,71 +1,52 @@
|
|||
import logging
|
||||
import redis
|
||||
import json
|
||||
|
||||
from functools import wraps
|
||||
from flask import request, make_response, Blueprint, abort, Response
|
||||
from flask.ext.login import current_user, logout_user
|
||||
from data import model, userevent
|
||||
from app import app
|
||||
from flask import request, Blueprint, abort, Response
|
||||
from flask.ext.login import current_user
|
||||
from data import userevent
|
||||
from auth.auth import require_session_login
|
||||
|
||||
logger = logging.getLogger(__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/")
|
||||
@api_login_required
|
||||
@require_session_login
|
||||
def index():
|
||||
debug_template = """
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Server sent events</h1>
|
||||
<div id="event"></div>
|
||||
<script type="text/javascript">
|
||||
debug_template = """
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Server sent events</h1>
|
||||
<div id="event"></div>
|
||||
<script type="text/javascript">
|
||||
|
||||
var eventOutputContainer = document.getElementById("event");
|
||||
var evtSrc = new EventSource("/realtime/user/subscribe?events=docker-cli");
|
||||
var eventOutputContainer = document.getElementById("event");
|
||||
var evtSrc = new EventSource("/realtime/user/subscribe?events=docker-cli");
|
||||
|
||||
evtSrc.onmessage = function(e) {
|
||||
console.log(e.data);
|
||||
eventOutputContainer.innerHTML = e.data;
|
||||
};
|
||||
evtSrc.onmessage = function(e) {
|
||||
console.log(e.data);
|
||||
eventOutputContainer.innerHTML = e.data;
|
||||
};
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return(debug_template)
|
||||
return(debug_template)
|
||||
|
||||
|
||||
@realtime.route("/user/test")
|
||||
@api_login_required
|
||||
@require_session_login
|
||||
def user_test():
|
||||
evt = userevent.UserEvent('logs.quay.io', current_user.db_user().username)
|
||||
evt.publish_event_data('test', {'foo': 2})
|
||||
return 'OK'
|
||||
|
||||
@realtime.route("/user/subscribe")
|
||||
@api_login_required
|
||||
@require_session_login
|
||||
def user_subscribe():
|
||||
def wrapper(listener):
|
||||
for event_id, data in listener.event_stream():
|
||||
|
|
|
@ -2,7 +2,7 @@ import logging
|
|||
import json
|
||||
|
||||
from flask import (make_response, request, session, Response, redirect,
|
||||
Blueprint)
|
||||
Blueprint, abort as flask_abort)
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
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,
|
||||
image_id, uuid))
|
||||
except IOError:
|
||||
abort(404, message='Image data not found')
|
||||
flask_abort(404)
|
||||
|
||||
try:
|
||||
size = store.get_size(store.image_layer_path(namespace, repository,
|
||||
|
|
286
endpoints/trigger.py
Normal 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)
|
260
endpoints/web.py
|
@ -1,43 +1,44 @@
|
|||
import logging
|
||||
import requests
|
||||
import stripe
|
||||
import os
|
||||
|
||||
from flask import (abort, redirect, request, url_for, render_template,
|
||||
make_response, Response, Blueprint)
|
||||
from flask.ext.login import login_required, current_user
|
||||
from flask import (abort, redirect, request, url_for, make_response, Response,
|
||||
Blueprint)
|
||||
from flask.ext.login import current_user
|
||||
from urlparse import urlparse
|
||||
|
||||
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 util.invoice import renderInvoiceToPdf
|
||||
from util.seo import render_snapshot
|
||||
from util.cache import no_cache
|
||||
from endpoints.api import get_route_data
|
||||
from endpoints.common import common_login
|
||||
|
||||
from endpoints.common import common_login, render_page_template
|
||||
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__)
|
||||
|
||||
web = Blueprint('web', __name__)
|
||||
|
||||
|
||||
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
|
||||
STATUS_TAGS = app.config['STATUS_TAGS']
|
||||
|
||||
|
||||
@web.route('/', methods=['GET'], defaults={'path': ''})
|
||||
@web.route('/repository/<path:path>', methods=['GET'])
|
||||
@web.route('/organization/<path:path>', methods=['GET'])
|
||||
@no_cache
|
||||
def index(path):
|
||||
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/<path:path>', methods=['GET'])
|
||||
|
@ -106,9 +107,10 @@ def new():
|
|||
return index('')
|
||||
|
||||
|
||||
@web.route('/repository/')
|
||||
@web.route('/repository/', defaults={'path': ''})
|
||||
@web.route('/repository/<path:path>', methods=['GET'])
|
||||
@no_cache
|
||||
def repository():
|
||||
def repository(path):
|
||||
return index('')
|
||||
|
||||
|
||||
|
@ -179,97 +181,6 @@ def receipt():
|
|||
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'])
|
||||
def confirm_email():
|
||||
code = request.values['code']
|
||||
|
@ -297,3 +208,134 @@ def confirm_recovery():
|
|||
return redirect(url_for('web.user'))
|
||||
else:
|
||||
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)
|
||||
|
|
|
@ -1,18 +1,26 @@
|
|||
import logging
|
||||
import stripe
|
||||
import json
|
||||
|
||||
from flask import request, make_response, Blueprint
|
||||
|
||||
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.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__)
|
||||
|
||||
webhooks = Blueprint('webhooks', __name__)
|
||||
|
||||
|
||||
@webhooks.route('/stripe', methods=['POST'])
|
||||
def stripe_webhook():
|
||||
request_data = request.get_json()
|
||||
|
@ -36,3 +44,38 @@ def stripe_webhook():
|
|||
send_invoice_email(user.email, invoice_html)
|
||||
|
||||
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)
|
||||
|
|
58
initdb.py
|
@ -9,6 +9,7 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables,
|
|||
|
||||
from data.database import *
|
||||
from data import model
|
||||
from data.model import oauth
|
||||
from app import app
|
||||
|
||||
|
||||
|
@ -182,6 +183,8 @@ def initialize_database():
|
|||
Visibility.create(name='private')
|
||||
LoginService.create(name='github')
|
||||
LoginService.create(name='quayrobot')
|
||||
|
||||
BuildTriggerService.create(name='github')
|
||||
|
||||
LogEntryKind.create(name='account_change_plan')
|
||||
LogEntryKind.create(name='account_change_cc')
|
||||
|
@ -207,6 +210,7 @@ def initialize_database():
|
|||
LogEntryKind.create(name='add_repo_webhook')
|
||||
LogEntryKind.create(name='delete_repo_webhook')
|
||||
LogEntryKind.create(name='set_repo_description')
|
||||
|
||||
LogEntryKind.create(name='build_dockerfile')
|
||||
|
||||
LogEntryKind.create(name='org_create_team')
|
||||
|
@ -220,6 +224,19 @@ def initialize_database():
|
|||
LogEntryKind.create(name='modify_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():
|
||||
logger.debug('Wiping all data from the DB.')
|
||||
|
@ -257,6 +274,9 @@ def populate_database():
|
|||
new_user_4.verified = True
|
||||
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.verified = True
|
||||
reader.save()
|
||||
|
@ -265,6 +285,8 @@ def populate_database():
|
|||
outside_org.verified = True
|
||||
outside_org.save()
|
||||
|
||||
model.create_notification('test_notification', new_user_1, metadata={'some': 'value'})
|
||||
|
||||
__generate_repository(new_user_4, 'randomrepo', 'Random repo repository.', False,
|
||||
[], (4, [], ['latest', 'prod']))
|
||||
|
||||
|
@ -308,9 +330,24 @@ def populate_database():
|
|||
False, [], (0, [], None))
|
||||
|
||||
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',
|
||||
tag, 'build-name')
|
||||
|
||||
trigger = model.create_build_trigger(building, 'github', '123authtoken',
|
||||
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.save()
|
||||
|
||||
|
@ -319,6 +356,15 @@ def populate_database():
|
|||
org.stripe_id = TEST_STRIPE_ID
|
||||
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)
|
||||
|
||||
owners = model.get_organization_team('buynlarge', 'owners')
|
||||
|
@ -423,6 +469,12 @@ def populate_database():
|
|||
timestamp=today,
|
||||
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__':
|
||||
app.config['LOGGING_CONFIG']()
|
||||
initialize_database()
|
||||
|
|
|
@ -22,4 +22,8 @@ logstash_formatter
|
|||
redis
|
||||
hiredis
|
||||
git+https://github.com/dotcloud/docker-py.git
|
||||
loremipsum
|
||||
loremipsum
|
||||
pygithub
|
||||
flask-restful
|
||||
jsonschema
|
||||
git+https://github.com/NateFerrero/oauth2lib.git
|
|
@ -1,45 +1,51 @@
|
|||
APScheduler==2.1.2
|
||||
Flask==0.10.1
|
||||
Flask-Login==0.2.9
|
||||
Flask-Login==0.2.10
|
||||
Flask-Mail==0.9.0
|
||||
Flask-Principal==0.4.0
|
||||
Flask-RESTful==0.2.12
|
||||
Jinja2==2.7.2
|
||||
MarkupSafe==0.18
|
||||
Pillow==2.3.0
|
||||
MarkupSafe==0.19
|
||||
Pillow==2.3.1
|
||||
PyGithub==1.24.1
|
||||
PyMySQL==0.6.1
|
||||
Werkzeug==0.9.4
|
||||
aniso8601==0.82
|
||||
argparse==1.2.1
|
||||
beautifulsoup4==4.3.2
|
||||
blinker==1.3
|
||||
boto==2.24.0
|
||||
boto==2.27.0
|
||||
distribute==0.6.34
|
||||
git+https://github.com/dotcloud/docker-py.git
|
||||
ecdsa==0.10
|
||||
ecdsa==0.11
|
||||
gevent==1.0
|
||||
greenlet==0.4.2
|
||||
gunicorn==18.0
|
||||
hiredis==0.1.2
|
||||
html5lib==1.0b3
|
||||
itsdangerous==0.23
|
||||
jsonschema==2.3.0
|
||||
lockfile==0.9.1
|
||||
logstash-formatter==0.5.8
|
||||
loremipsum==1.0.2
|
||||
marisa-trie==0.5.1
|
||||
mixpanel-py==3.1.1
|
||||
marisa-trie==0.6
|
||||
mixpanel-py==3.1.2
|
||||
mock==1.0.1
|
||||
paramiko==1.12.1
|
||||
peewee==2.2.0
|
||||
git+https://github.com/NateFerrero/oauth2lib.git
|
||||
paramiko==1.13.0
|
||||
peewee==2.2.2
|
||||
py-bcrypt==0.4
|
||||
pyPdf==1.13
|
||||
pycrypto==2.6.1
|
||||
python-daemon==1.6
|
||||
python-dateutil==2.2
|
||||
python-digitalocean==0.6
|
||||
python-digitalocean==0.7
|
||||
pytz==2014.2
|
||||
redis==2.9.1
|
||||
reportlab==2.7
|
||||
requests==2.2.1
|
||||
six==1.5.2
|
||||
stripe==1.12.0
|
||||
six==1.6.1
|
||||
stripe==1.12.2
|
||||
websocket-client==0.11.0
|
||||
wsgiref==0.1.2
|
||||
xhtml2pdf==0.0.5
|
||||
|
|
|
@ -15,8 +15,9 @@ var isDebug = !!options['d'];
|
|||
|
||||
var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/';
|
||||
var repo = isDebug ? 'complex' : 'r0';
|
||||
var org = isDebug ? 'buynlarge' : 'quay'
|
||||
var orgrepo = 'orgrepo'
|
||||
var org = isDebug ? 'buynlarge' : 'devtable'
|
||||
var orgrepo = isDebug ? 'buynlarge/orgrepo' : 'quay/testconnect2';
|
||||
var buildrepo = isDebug ? 'devtable/building' : 'quay/testconnect2';
|
||||
|
||||
var outputDir = "screenshots/";
|
||||
|
||||
|
@ -32,8 +33,16 @@ casper.on("page.error", function(msg, trace) {
|
|||
});
|
||||
|
||||
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', {
|
||||
'username': 'devtable',
|
||||
'username': isDebug ? 'devtable' : 'quaydemo',
|
||||
'password': isDebug ? 'password': 'C>K98%y"_=54x"<',
|
||||
}, false);
|
||||
});
|
||||
|
@ -43,6 +52,7 @@ casper.thenClick('.form-signin button[type=submit]', function() {
|
|||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.waitForSelector('.fa-lock');
|
||||
this.log('Generating user home screenshot.');
|
||||
});
|
||||
|
||||
|
@ -150,12 +160,25 @@ casper.then(function() {
|
|||
this.log('Generating oganization repository admin screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
|
||||
this.waitForText('outsideorg')
|
||||
casper.thenOpen(rootUrl + 'repository/' + orgrepo + '/admin', function() {
|
||||
this.waitForText('Robot Account')
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
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();
|
||||
|
|
|
@ -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 {
|
||||
margin-top: 10px;
|
||||
margin: 20px;
|
||||
padding: 20px;
|
||||
background: #F7F6F6;
|
||||
|
@ -282,21 +349,6 @@ i.toggle-icon:hover {
|
|||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#copyClipboard {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#copyClipboard.zeroclipboard-is-hover {
|
||||
background: #428bca;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#clipboardCopied.hovering {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
@ -506,7 +558,22 @@ i.toggle-icon:hover {
|
|||
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;
|
||||
}
|
||||
|
||||
|
@ -775,11 +842,20 @@ i.toggle-icon:hover {
|
|||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.new-repo .section-title {
|
||||
float: right;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.new-repo .repo-option {
|
||||
margin: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.new-repo .repo-option label {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.new-repo .repo-option i {
|
||||
font-size: 18px;
|
||||
padding-left: 10px;
|
||||
|
@ -1596,10 +1672,17 @@ p.editable:hover i {
|
|||
}
|
||||
|
||||
.repo .empty-description {
|
||||
max-width: 600px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.repo .empty-description pre:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.repo .empty-description .panel-default {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.repo dl.dl-horizontal dt {
|
||||
width: 80px;
|
||||
padding-right: 10px;
|
||||
|
@ -1710,7 +1793,38 @@ p.editable:hover i {
|
|||
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;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
|
@ -1721,7 +1835,7 @@ p.editable:hover i {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#clipboardCopied.animated {
|
||||
#clipboardCopied.animated, .clipboard-copied-message {
|
||||
-webkit-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;
|
||||
|
@ -2037,6 +2151,13 @@ p.editable:hover i {
|
|||
left: 4px;
|
||||
}
|
||||
|
||||
.repo-admin .right-controls {
|
||||
text-align: right;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.repo-admin .right-info {
|
||||
font-size: 11px;
|
||||
margin-top: 10px;
|
||||
|
@ -2205,16 +2326,16 @@ p.editable:hover i {
|
|||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.delete-ui {
|
||||
.delete-ui-element {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.delete-ui i {
|
||||
.delete-ui-element i {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.delete-ui .delete-ui-button {
|
||||
.delete-ui-element .delete-ui-button {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
color: white;
|
||||
|
@ -2230,15 +2351,15 @@ p.editable:hover i {
|
|||
transition: width 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.delete-ui .delete-ui-button button {
|
||||
.delete-ui-element .delete-ui-button button {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.delete-ui:focus i {
|
||||
.delete-ui-element:focus i {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.delete-ui:focus .delete-ui-button {
|
||||
.delete-ui-element:focus .delete-ui-button {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
|
@ -2812,7 +2933,7 @@ p.editable:hover i {
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.create-org .step-container .description {
|
||||
.form-group .description {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
color: #888;
|
||||
|
@ -2820,7 +2941,7 @@ p.editable:hover i {
|
|||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.create-org .form-group input {
|
||||
.form-group.nested input {
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
@ -2927,9 +3048,10 @@ p.editable:hover i {
|
|||
.tt-suggestion {
|
||||
display: block;
|
||||
padding: 3px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-suggestion.tt-is-under-cursor {
|
||||
.tt-suggestion.tt-cursor {
|
||||
color: #fff;
|
||||
background-color: #0081c2;
|
||||
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)
|
||||
}
|
||||
|
||||
.tt-suggestion.tt-is-under-cursor a {
|
||||
.tt-suggestion.tt-cursor a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tt-empty {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tt-suggestion p {
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -3380,4 +3509,216 @@ pre.command:before {
|
|||
|
||||
.label.MAINTAINER {
|
||||
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;
|
||||
}
|
12
static/directives/application-info.html
Normal 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>
|
24
static/directives/application-manager.html
Normal 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>
|
15
static/directives/application-reference-dialog.html
Normal 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;">
|
||||
×
|
||||
</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>
|
4
static/directives/application-reference.html
Normal 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>
|
14
static/directives/copy-box.html
Normal 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>
|
4
static/directives/delete-ui.html
Normal 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>
|
1
static/directives/dropdown-select-icon.html
Normal file
|
@ -0,0 +1 @@
|
|||
<ng-transclude>
|
1
static/directives/dropdown-select-menu.html
Normal file
|
@ -0,0 +1 @@
|
|||
<ul class="dropdown-menu" ng-transclude></ul>
|
13
static/directives/dropdown-select.html
Normal 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>
|
|
@ -1,10 +1,7 @@
|
|||
<!-- Quay -->
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex1-collapse">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="fa-bar"></span>
|
||||
<span class="fa-bar"></span>
|
||||
<span class="fa-bar"></span>
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex1-collapse" style="padding: 0px; padding-left: 4px; padding-right: 4px;">
|
||||
<span style="font-size: 24px">≡</span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/" target="{{ appLinkTarget() }}">
|
||||
<img src="/static/img/quay-logo.png">
|
||||
|
@ -39,11 +36,14 @@
|
|||
<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" />
|
||||
{{ user.username }}
|
||||
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan"
|
||||
bs-tooltip="(user.askForPassword ? 'A password is needed for this account<br>' : '') + (overPlan ? 'You are using more private repositories than your plan allows' : '')"
|
||||
<span class="badge user-notification notification-animated"
|
||||
ng-show="notificationService.notifications.length"
|
||||
ng-class="notificationService.notificationClasses"
|
||||
bs-tooltip=""
|
||||
title="User Notifications"
|
||||
data-placement="left"
|
||||
data-container="body">
|
||||
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
||||
{{ notificationService.notifications.length }}
|
||||
</span>
|
||||
<b class="caret"></b>
|
||||
</a>
|
||||
|
@ -51,8 +51,16 @@
|
|||
<li>
|
||||
<a href="/user/" target="{{ appLinkTarget() }}">
|
||||
Account Settings
|
||||
<span class="badge user-notification" ng-show="user.askForPassword || overPlan">
|
||||
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
||||
</a>
|
||||
</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>
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
<span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span>
|
||||
<span id="logs-range" class="mini">
|
||||
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>
|
||||
<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 class="right">
|
||||
|
@ -42,7 +42,7 @@
|
|||
<thead>
|
||||
<th>Description</th>
|
||||
<th style="min-width: 226px">Date/Time</th>
|
||||
<th>User/Token</th>
|
||||
<th>User/Token/App</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
@ -53,14 +53,24 @@
|
|||
</td>
|
||||
<td>{{ log.datetime }}</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>
|
||||
<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>
|
||||
<span>{{ log.metadata.token }}</span>
|
||||
</span>
|
||||
<span ng-show="!log.performer && !log.metadata.token">
|
||||
<span ng-if="!log.performer && !log.metadata.token">
|
||||
(anonymous)
|
||||
</span>
|
||||
</td>
|
||||
|
|
15
static/directives/notification-bar.html
Normal 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()">×</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>
|
11
static/directives/notification-view.html
Normal 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>
|
|
@ -1,5 +1,6 @@
|
|||
<button class="btn btn-success" data-trigger="click" bs-popover="'static/directives/popup-input-dialog.html'"
|
||||
data-placement="bottom" ng-click="popupShown()">
|
||||
<button class="btn btn-success" data-trigger="click"
|
||||
data-content-template="static/directives/popup-input-dialog.html"
|
||||
data-placement="bottom" ng-click="popupShown()" bs-popover>
|
||||
<span ng-transclude></span>
|
||||
</button>
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<form name="popupinput" ng-submit="inputSubmit(); hide()" novalidate>
|
||||
<input id="input-box" type="text form-control" placeholder="{{ placeholder }}" ng-blur="hide()"
|
||||
<form name="popupinput" ng-submit="inputSubmit(); $hide()" novalidate>
|
||||
<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>
|
||||
</form>
|
||||
|
|
|
@ -48,10 +48,7 @@
|
|||
<span class="role-group" current-role="prototype.role" role-changed="setRole(role, prototype)" roles="roles"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<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>
|
||||
<span class="delete-ui" delete-title="'Delete Permission'" perform-delete="deletePrototype(prototype)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<div class="resource-view-element">
|
||||
<div class="resource-spinner" ng-class="resource.loading ? 'visible' : ''">
|
||||
<div class="small-spinner"></div>
|
||||
</div>
|
||||
<div class="quay-spinner" ng-show="resource.loading"></div>
|
||||
<div class="resource-error" ng-show="!resource.loading && resource.hasError">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
|
|
@ -24,10 +24,7 @@
|
|||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<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>
|
||||
<span class="delete-ui" delete-title="'Delete Robot Account'" perform-delete="deleteRobot(robotInfo)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
ng-class="getImageListingClasses(image)">
|
||||
<span class="image-listing-circle"></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) }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
23
static/directives/trigger-description.html
Normal 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>
|
61
static/directives/trigger-setup-github.html
Normal 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>
|
|
@ -3,7 +3,7 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<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
|
||||
</a>
|
||||
</h4>
|
||||
|
|
35
static/img/500/background.svg
Normal 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
|
@ -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
|
@ -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 |
BIN
static/img/build-history.png
Normal file
After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 81 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 183 KiB |
1212
static/js/app.js
|
@ -638,10 +638,8 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
|
|||
$rootScope.description = jQuery(getFirstTextLine(repo.description)).text() ||
|
||||
'Visualization of images and tags for ' + kind + ' Docker repository: ' + qualifiedRepoName;
|
||||
|
||||
// If the repository is marked as building, start monitoring it for changes.
|
||||
if (repo.is_building) {
|
||||
startBuildInfoTimer(repo);
|
||||
}
|
||||
// Load the builds for this repository. If none are active it will cancel the poll.
|
||||
startBuildInfoTimer(repo);
|
||||
|
||||
$('#copyClipboard').clipboardCopy();
|
||||
});
|
||||
|
@ -672,15 +670,19 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
|
|||
};
|
||||
|
||||
ApiService.getRepoBuilds(null, params, true).then(function(resp) {
|
||||
// Build a filtered list of the builds that are currently running.
|
||||
var runningBuilds = [];
|
||||
for (var i = 0; i < resp.builds.length; ++i) {
|
||||
var build = resp.builds[i];
|
||||
if (build.status != 'complete') {
|
||||
if (build['phase'] != 'complete' && build['phase'] != 'error') {
|
||||
runningBuilds.push(build);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.buildsInfo = runningBuilds;
|
||||
var existingBuilds = $scope.runningBuilds || [];
|
||||
$scope.runningBuilds = runningBuilds;
|
||||
$scope.buildHistory = resp.builds;
|
||||
|
||||
if (!runningBuilds.length) {
|
||||
// Cancel the build timer.
|
||||
cancelBuildInfoTimer();
|
||||
|
@ -688,8 +690,10 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
|
|||
// Mark the repo as no longer building.
|
||||
$scope.repo.is_building = false;
|
||||
|
||||
// Reload the repo information.
|
||||
loadViewInfo();
|
||||
// Reload the repo information if all of the builds recently finished.
|
||||
if (existingBuilds.length > 0) {
|
||||
loadViewInfo();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -798,9 +802,23 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
|
|||
// itself (should) be the Dockerfile.
|
||||
if (zipFiles && Object.keys(zipFiles).length) {
|
||||
// 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) {
|
||||
$scope.dockerFileContents = dockerfile.asText();
|
||||
$scope.dockerFilePath = dockerfilePath;
|
||||
}
|
||||
|
||||
// Build the zip file tree.
|
||||
|
@ -815,21 +833,17 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
|
|||
});
|
||||
} else {
|
||||
$scope.dockerFileContents = response;
|
||||
$scope.dockerFilePath = 'Dockerfile';
|
||||
}
|
||||
|
||||
$scope.loaded = true;
|
||||
};
|
||||
|
||||
var downloadBuildPack = function() {
|
||||
var downloadBuildPack = function(url) {
|
||||
$scope.downloadProgress = 0;
|
||||
$scope.downloading = true;
|
||||
|
||||
ApiService.getRepoBuildArchiveUrl(null, params).then(function(resp) {
|
||||
startDownload(resp['url']);
|
||||
}, function() {
|
||||
$scope.downloading = false;
|
||||
$scope.downloadError = true;
|
||||
});
|
||||
startDownload(url);
|
||||
};
|
||||
|
||||
var startDownload = function(url) {
|
||||
|
@ -880,7 +894,7 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
|
|||
'name': name
|
||||
};
|
||||
|
||||
downloadBuildPack();
|
||||
downloadBuildPack(resp['archive_url']);
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
|
@ -888,7 +902,8 @@ function BuildPackageCtrl($scope, Restangular, ApiService, $routeParams, $rootSc
|
|||
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 name = $routeParams.name;
|
||||
var pollTimerHandle = null;
|
||||
|
@ -904,7 +919,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
}
|
||||
});
|
||||
|
||||
$scope.builds = [];
|
||||
$scope.builds = null;
|
||||
$scope.polling = false;
|
||||
|
||||
$scope.buildDialogShowCounter = 0;
|
||||
|
@ -914,12 +929,16 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
};
|
||||
|
||||
$scope.handleBuildStarted = function(newBuild) {
|
||||
$scope.builds.push(newBuild);
|
||||
$scope.builds.unshift(newBuild);
|
||||
$scope.setCurrentBuild(newBuild['id'], true);
|
||||
};
|
||||
|
||||
$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) {
|
||||
|
@ -929,8 +948,14 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
$scope.restartBuild = function(build) {
|
||||
$('#confirmRestartBuildModal').modal('hide');
|
||||
|
||||
var subdirectory = '';
|
||||
if (build['job_config']) {
|
||||
subdirectory = build['job_config']['build_subdir'] || '';
|
||||
}
|
||||
|
||||
var data = {
|
||||
'file_id': build['resource_key']
|
||||
'file_id': build['resource_key'],
|
||||
'subdirectory': subdirectory
|
||||
};
|
||||
|
||||
var params = {
|
||||
|
@ -938,26 +963,18 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
};
|
||||
|
||||
ApiService.requestRepoBuild(data, params).then(function(newBuild) {
|
||||
$scope.builds.push(newBuild);
|
||||
$scope.builds.unshift(newBuild);
|
||||
$scope.setCurrentBuild(newBuild['id'], true);
|
||||
});
|
||||
};
|
||||
|
||||
$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) {
|
||||
if (!$scope.builds) { return; }
|
||||
|
||||
// Find the build.
|
||||
for (var i = 0; i < $scope.builds.length; ++i) {
|
||||
if ($scope.builds[i].id == buildId) {
|
||||
|
@ -1042,17 +1059,13 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
var entry = logs[i];
|
||||
var type = entry['type'] || 'entry';
|
||||
if (type == 'command' || type == 'phase' || type == 'error') {
|
||||
entry['_logs'] = [];
|
||||
entry['logs'] = AngularViewArray.create();
|
||||
entry['index'] = startIndex + i;
|
||||
|
||||
$scope.logEntries.push(entry);
|
||||
$scope.currentParentEntry = entry;
|
||||
$scope.currentParentEntry = entry;
|
||||
} else if ($scope.currentParentEntry) {
|
||||
if ($scope.currentParentEntry['logs']) {
|
||||
$scope.currentParentEntry['logs'].push(entry);
|
||||
} else {
|
||||
$scope.currentParentEntry['_logs'].push(entry);
|
||||
}
|
||||
$scope.currentParentEntry['logs'].push(entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1120,7 +1133,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
if ($location.search().current) {
|
||||
$scope.setCurrentBuild($location.search().current, false);
|
||||
} 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();
|
||||
}
|
||||
|
||||
function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope) {
|
||||
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope, $location) {
|
||||
var namespace = $routeParams.namespace;
|
||||
var name = $routeParams.name;
|
||||
|
||||
|
@ -1138,6 +1151,33 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
|
|||
|
||||
$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 '[](' + linkUrl + ')';
|
||||
|
||||
case 'asciidoc':
|
||||
return 'image:' + imageUrl + '["Docker Repository on Quay.io", link="' + linkUrl + '"]';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.buildEntityForPermission = function(name, permission, kind) {
|
||||
var key = name + ':' + kind;
|
||||
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));
|
||||
permissionPost.customPOST(permission).then(function(result) {
|
||||
permissionPost.customPUT(permission).then(function(result) {
|
||||
$scope.permissions[kind][entityName] = result;
|
||||
}, function(result) {
|
||||
$('#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 params = {
|
||||
'repository': namespace + '/' + name
|
||||
|
@ -1421,7 +1585,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
|
|||
}
|
||||
|
||||
UserService.updateUserIn($scope, function(user) {
|
||||
$scope.askForPassword = user.askForPassword;
|
||||
$scope.cuser = jQuery.extend({}, user);
|
||||
|
||||
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.githubRedirectUri = KeyService.githubRedirectUri;
|
||||
$scope.githubClientId = KeyService.githubClientId;
|
||||
$scope.authorizedApps = null;
|
||||
|
||||
$('.form-change').popover();
|
||||
|
||||
$scope.logsShown = 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() {
|
||||
if (!$scope.hasPaidBusinessPlan) { return; }
|
||||
$scope.logsShown++;
|
||||
|
@ -1518,7 +1711,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
|
|||
$scope.sentEmail = $scope.cuser.email;
|
||||
|
||||
// Reset the form.
|
||||
$scope.cuser.repeatEmail = '';
|
||||
delete $scope.cuser['repeatEmail'];
|
||||
|
||||
$scope.changeEmailForm.$setPristine();
|
||||
}, function(result) {
|
||||
$scope.updatingUser = false;
|
||||
|
@ -1540,8 +1734,9 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
|
|||
$scope.changePasswordSuccess = true;
|
||||
|
||||
// Reset the form
|
||||
$scope.cuser.password = '';
|
||||
$scope.cuser.repeatPassword = '';
|
||||
delete $scope.cuser['password']
|
||||
delete $scope.cuser['repeatPassword']
|
||||
|
||||
$scope.changePasswordForm.$setPristine();
|
||||
|
||||
// Reload the user.
|
||||
|
@ -1614,6 +1809,16 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
|
|||
}, 10);
|
||||
};
|
||||
|
||||
var fetchRepository = function() {
|
||||
var params = {
|
||||
'repository': namespace + '/' + name
|
||||
};
|
||||
|
||||
ApiService.getRepoAsResource(params).get(function(repo) {
|
||||
$scope.repo = repo;
|
||||
});
|
||||
};
|
||||
|
||||
var fetchImage = function() {
|
||||
var params = {
|
||||
'repository': namespace + '/' + name,
|
||||
|
@ -1621,10 +1826,13 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
|
|||
};
|
||||
|
||||
$scope.image = ApiService.getImageAsResource(params).get(function(image) {
|
||||
$scope.repo = {
|
||||
'name': name,
|
||||
'namespace': namespace
|
||||
};
|
||||
if (!$scope.repo) {
|
||||
$scope.repo = {
|
||||
'name': name,
|
||||
'namespace': namespace,
|
||||
'is_public': true
|
||||
};
|
||||
}
|
||||
|
||||
$rootScope.title = 'View Image - ' + image.id;
|
||||
$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.
|
||||
fetchImage();
|
||||
}
|
||||
|
@ -1673,13 +1884,16 @@ function V1Ctrl($scope, $location, UserService) {
|
|||
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);
|
||||
|
||||
$scope.githubRedirectUri = KeyService.githubRedirectUri;
|
||||
$scope.githubClientId = KeyService.githubClientId;
|
||||
|
||||
$scope.repo = {
|
||||
'is_public': 1,
|
||||
'description': '',
|
||||
'initialize': false
|
||||
'initialize': ''
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
$scope.checkingPlan = true;
|
||||
$scope.planRequired = null;
|
||||
$scope.isUserNamespace = isUserNamespace;
|
||||
|
||||
if (isUserNamespace) {
|
||||
// Load the user's subscription information in case they want to create a private
|
||||
// repository.
|
||||
ApiService.getUserPrivateCount().then(function(resp) {
|
||||
if (resp.privateCount + 1 > resp.reposAllowed) {
|
||||
PlanService.getMinimumPlan(resp.privateCount + 1, false, function(minimum) {
|
||||
$scope.planRequired = minimum;
|
||||
});
|
||||
}
|
||||
// Determine whether private repositories are allowed for the namespace.
|
||||
checkPrivateAllowed();
|
||||
|
||||
$scope.checkingPlan = false;
|
||||
}, function() {
|
||||
$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';
|
||||
}
|
||||
// Default to private repos for organizations.
|
||||
$scope.repo.is_public = isUserNamespace ? '1' : '0';
|
||||
});
|
||||
|
||||
$scope.changeNamespace = function(namespace) {
|
||||
|
@ -1771,12 +1962,20 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
|
|||
$scope.creating = false;
|
||||
$scope.created = created;
|
||||
|
||||
// Repository created. Start the upload process if applicable.
|
||||
if ($scope.repo.initialize) {
|
||||
// Start the upload process if applicable.
|
||||
if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') {
|
||||
$scope.createdForBuild = created;
|
||||
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.
|
||||
$location.path('/repository/' + created.namespace + '/' + created.name);
|
||||
}, 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) {
|
||||
|
@ -1810,16 +2037,7 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
|
|||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||
$scope.subscribedPlan = subscribedPlan;
|
||||
$scope.planRequired = null;
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
checkPrivateAllowed();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -1933,12 +2151,17 @@ function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, U
|
|||
$scope.invoiceLoading = true;
|
||||
$scope.logsShown = 0;
|
||||
$scope.invoicesShown = 0;
|
||||
$scope.applicationsShown = 0;
|
||||
$scope.changingOrganization = false;
|
||||
|
||||
$scope.loadLogs = function() {
|
||||
$scope.logsShown++;
|
||||
};
|
||||
|
||||
$scope.loadApplications = function() {
|
||||
$scope.applicationsShown++;
|
||||
};
|
||||
|
||||
$scope.loadInvoices = function() {
|
||||
$scope.invoicesShown++;
|
||||
};
|
||||
|
@ -2223,4 +2446,132 @@ function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangul
|
|||
// Load the org info and the member info.
|
||||
loadOrganization();
|
||||
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();
|
||||
}
|
|
@ -872,7 +872,8 @@ function FileTreeBase() {
|
|||
* Calculates the dimensions of the tree.
|
||||
*/
|
||||
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 ch = (this.getNodesHeight() * barHeight) + 40;
|
||||
|
||||
|
@ -1470,7 +1471,7 @@ function LogUsageChart(titleMap) {
|
|||
* Builds the D3-representation of the data.
|
||||
*/
|
||||
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
|
||||
// entry per {kind, day} pair.
|
||||
|
|
8
static/lib/angular-motion.min.css
vendored
Normal file
3543
static/lib/angular-strap.js
vendored
Normal file
12
static/lib/angular-strap.min.js
vendored
9
static/lib/angular-strap.tpl.min.js
vendored
Normal file
1
static/lib/bootstrap-additions.min.css
vendored
Normal file
7
static/lib/typeahead.bundle.min.js
vendored
Normal file
7
static/lib/typeahead.min.js
vendored
|
@ -37,8 +37,9 @@
|
|||
<div class="tab-content">
|
||||
<!-- Dockerfile view -->
|
||||
<div class="tab-pane active" id="dockerfile">
|
||||
<div class="dockerfile-view" contents="dockerFileContents"></div>
|
||||
<span ng-show="!dockerFileContents">No Dockerfile found in the build pack</span>
|
||||
<div class="dockerfile-path" ng-if="dockerFileContents">{{ dockerFilePath }}</div>
|
||||
<div class="dockerfile-view" contents="dockerFileContents" ng-if="dockerFileContents"></div>
|
||||
<span ng-if="!dockerFileContents">No Dockerfile found in the build pack</span>
|
||||
</div>
|
||||
|
||||
<!-- File tree -->
|
||||
|
|
|
@ -17,20 +17,7 @@
|
|||
<dl class="dl-normal">
|
||||
<dt>Full Image ID</dt>
|
||||
<dd>
|
||||
<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>
|
||||
<div class="copy-box" value="image.value.id"></div>
|
||||
</dd>
|
||||
<dt>Created</dt>
|
||||
<dd am-time-ago="parseDate(image.value.created)"></dd>
|
||||
|
|