Merge remote-tracking branch 'origin/master' into tagyourit
Conflicts: endpoints/api.py static/js/app.js static/partials/view-repo.html test/data/test.db test/specs.py test/test_api_usage.py
This commit is contained in:
commit
302bfb27ae
123 changed files with 16314 additions and 3789 deletions
2309
endpoints/api.py
2309
endpoints/api.py
File diff suppressed because it is too large
Load diff
281
endpoints/api/__init__.py
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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)
|
||||
|
|
Reference in a new issue