+
+
From d080ca2cc6cb482b5e20aab4fb02e8f3a10272f3 Mon Sep 17 00:00:00 2001
From: Sam Chow
Date: Mon, 14 May 2018 15:45:26 -0400
Subject: [PATCH 05/14] Create webpack config for config app
further improve developer morale
get initial angular loading
Add remote css to config index
Starts work to port endpoints into config app
Add the api blueprint
---
config_app/Procfile | 2 +-
config_app/app.py | 3 +-
config_app/application.py | 10 +-
config_app/config_endpoints/api/__init__.py | 151 ++
config_app/config_endpoints/common.py | 363 +++-
config_app/config_endpoints/setup_web.py | 43 +-
config_app/js/components/file-upload-box.js | 172 ++
config_app/js/config-app.module.ts | 45 +
.../config-bool-field.html | 8 +
.../config-certificates-field.html | 76 +
.../config-contact-field.html | 46 +
.../config-contacts-field.html | 4 +
.../config-file-field.html | 13 +
.../config-list-field.html | 17 +
.../config-map-field.html | 20 +
.../config-numeric-field.html | 6 +
.../config-parsed-field.html | 1 +
.../config-service-key-field.html | 29 +
.../config-setup-tool.html | 1656 ++++++++++++++++
.../config-string-field.html | 10 +
.../config-string-list-field.html | 6 +
.../config-variable-field.html | 10 +
.../core-config-setup/config-setup-tool.html | 1657 +++++++++++++++++
.../js/core-config-setup/core-config-setup.js | 1454 +++++++++++++++
config_app/js/main.ts | 36 +
config_app/js/services/api-service.js | 332 ++++
config_app/js/services/container-service.js | 45 +
config_app/js/services/cookie-service.js | 23 +
config_app/js/services/features-config.js | 91 +
config_app/js/services/user-service.js | 217 +++
config_app/js/services/util-service.js | 83 +
config_app/js/setup/setup.component.js | 332 ++++
config_app/js/setup/setup.html | 311 ++++
config_app/static/css/core-ui.css | 1500 +++++++++++++++
.../static/lib/angular-file-upload.min.js | 2 +
config_app/templates/config_index.html | 12 -
config_app/templates/index.html | 51 +
config_app/util/__init__.py | 0
config_app/util/baseprovider.py | 128 ++
config_app/util/config.py | 21 +
config_app/util/fileprovider.py | 60 +
config_app/web.py | 4 +-
config_app/webpack.config.js | 60 +
endpoints/common.py | 6 +-
local-config-app.sh | 10 +-
package.json | 5 +-
static/configappjs/index.js | 5 -
web.py | 8 -
webpack.config.js | 5 +-
49 files changed, 8996 insertions(+), 153 deletions(-)
create mode 100644 config_app/config_endpoints/api/__init__.py
create mode 100644 config_app/js/components/file-upload-box.js
create mode 100644 config_app/js/config-app.module.ts
create mode 100644 config_app/js/config-field-templates/config-bool-field.html
create mode 100644 config_app/js/config-field-templates/config-certificates-field.html
create mode 100644 config_app/js/config-field-templates/config-contact-field.html
create mode 100644 config_app/js/config-field-templates/config-contacts-field.html
create mode 100644 config_app/js/config-field-templates/config-file-field.html
create mode 100644 config_app/js/config-field-templates/config-list-field.html
create mode 100644 config_app/js/config-field-templates/config-map-field.html
create mode 100644 config_app/js/config-field-templates/config-numeric-field.html
create mode 100644 config_app/js/config-field-templates/config-parsed-field.html
create mode 100644 config_app/js/config-field-templates/config-service-key-field.html
create mode 100644 config_app/js/config-field-templates/config-setup-tool.html
create mode 100644 config_app/js/config-field-templates/config-string-field.html
create mode 100644 config_app/js/config-field-templates/config-string-list-field.html
create mode 100644 config_app/js/config-field-templates/config-variable-field.html
create mode 100644 config_app/js/core-config-setup/config-setup-tool.html
create mode 100644 config_app/js/core-config-setup/core-config-setup.js
create mode 100644 config_app/js/main.ts
create mode 100644 config_app/js/services/api-service.js
create mode 100644 config_app/js/services/container-service.js
create mode 100644 config_app/js/services/cookie-service.js
create mode 100644 config_app/js/services/features-config.js
create mode 100644 config_app/js/services/user-service.js
create mode 100644 config_app/js/services/util-service.js
create mode 100644 config_app/js/setup/setup.component.js
create mode 100644 config_app/js/setup/setup.html
create mode 100644 config_app/static/css/core-ui.css
create mode 100644 config_app/static/lib/angular-file-upload.min.js
delete mode 100644 config_app/templates/config_index.html
create mode 100644 config_app/templates/index.html
create mode 100644 config_app/util/__init__.py
create mode 100644 config_app/util/baseprovider.py
create mode 100644 config_app/util/config.py
create mode 100644 config_app/util/fileprovider.py
create mode 100644 config_app/webpack.config.js
delete mode 100644 static/configappjs/index.js
diff --git a/config_app/Procfile b/config_app/Procfile
index 242c204d4..0ea2ba9c6 100644
--- a/config_app/Procfile
+++ b/config_app/Procfile
@@ -1,3 +1,3 @@
app: PYTHONPATH="../" gunicorn -c conf/gunicorn_local.py application:application
-# webpack: npm run watch
+# webpack: npm run watch-config-app
diff --git a/config_app/app.py b/config_app/app.py
index ba09f5713..811f51457 100644
--- a/config_app/app.py
+++ b/config_app/app.py
@@ -1,3 +1,4 @@
-from flask import Flask, request, Request
+from flask import Flask
app = Flask(__name__)
+
diff --git a/config_app/application.py b/config_app/application.py
index a4d231092..9f4249e00 100644
--- a/config_app/application.py
+++ b/config_app/application.py
@@ -1,15 +1,9 @@
-# import os
-# import logging
-# import logging.config
-
-# from util.log import logfile_path
from app import app as application
-
# Bind all of the blueprints
import web
if __name__ == '__main__':
- logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False)
- application.run(port=5000, debug=True, threaded=True, host='0.0.0.0')
+ logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False)
+ application.run(port=5000, debug=True, threaded=True, host='0.0.0.0')
diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py
new file mode 100644
index 000000000..67580475a
--- /dev/null
+++ b/config_app/config_endpoints/api/__init__.py
@@ -0,0 +1,151 @@
+import logging
+
+from config_app import app
+from config_app.util.config import config_provider
+
+from flask import Blueprint, request, session
+from flask_restful import Resource, abort, Api, reqparse
+from flask_restful.utils.cors import crossdomain
+
+from functools import partial, wraps
+
+
+logger = logging.getLogger(__name__)
+api_bp = Blueprint('api', __name__)
+
+CROSS_DOMAIN_HEADERS = ['Authorization', 'Content-Type', 'X-Requested-With']
+
+
+class ApiExceptionHandlingApi(Api):
+ @crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS)
+ def handle_error(self, error):
+ print('HANDLING ERROR IN API')
+ return super(ApiExceptionHandlingApi, self).handle_error(error)
+
+
+api = ApiExceptionHandlingApi()
+
+
+class HelloWorld(Resource):
+ def get(self):
+ print("hit the dummy endpoint")
+ return {'hello': 'world'}
+
+
+api.add_resource(HelloWorld, '/')
+
+
+
+def verify_not_prod(func):
+ @add_method_metadata('enterprise_only', True)
+ def wrapped(*args, **kwargs):
+ # Verify that we are not running on a production (i.e. hosted) stack. If so, we fail.
+ # This should never happen (because of the feature-flag on SUPER_USERS), but we want to be
+ # absolutely sure.
+ # if app.config['SERVER_HOSTNAME'].find('quay.io') >= 0:
+ # TODO(config_port) fixme
+ if False:
+ logger.error('!!! Super user method called IN PRODUCTION !!!')
+ raise StandardError()
+
+ return func(*args, **kwargs)
+
+ return wrapped
+
+
+def resource(*urls, **kwargs):
+ def wrapper(api_resource):
+ if not api_resource:
+ return None
+
+ api_resource.registered = True
+ api.add_resource(api_resource, *urls, **kwargs)
+ return api_resource
+
+ return wrapper
+
+
+class ApiResource(Resource):
+ registered = False
+ method_decorators = []
+
+ def options(self):
+ return None, 200
+
+
+def add_method_metadata(name, value):
+ def modifier(func):
+ if func is None:
+ return None
+
+ 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 func is None:
+ return None
+
+ if '__api_metadata' in dir(func):
+ return func.__api_metadata.get(name, None)
+ return None
+
+
+def no_cache(f):
+ @wraps(f)
+ def add_no_cache(*args, **kwargs):
+ response = f(*args, **kwargs)
+ if response is not None:
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
+ return response
+ return add_no_cache
+
+
+nickname = partial(add_method_metadata, 'nickname')
+
+api.init_app(api_bp)
+# api.decorators = [csrf_protect(),
+# crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS),
+# process_oauth, time_decorator(api_bp.name, metric_queue),
+# require_xhr_from_browser]
+
+
+
+
+@resource('/v1/superuser/config')
+class SuperUserConfig(ApiResource):
+ """ Resource for fetching and updating the current configuration, if any. """
+ schemas = {
+ 'UpdateConfig': {
+ 'type': 'object',
+ 'description': 'Updates the YAML config file',
+ 'required': [
+ 'config',
+ 'hostname'
+ ],
+ 'properties': {
+ 'config': {
+ 'type': 'object'
+ },
+ 'hostname': {
+ 'type': 'string'
+ },
+ 'password': {
+ 'type': 'string'
+ },
+ },
+ },
+ }
+
+ @verify_not_prod
+ @nickname('scGetConfig')
+ def get(self):
+ """ Returns the currently defined configuration, if any. """
+ config_object = config_provider.get_config()
+ return {
+ 'config': config_object
+ }
diff --git a/config_app/config_endpoints/common.py b/config_app/config_endpoints/common.py
index 1bcdfb03d..1378f0209 100644
--- a/config_app/config_endpoints/common.py
+++ b/config_app/config_endpoints/common.py
@@ -1,78 +1,305 @@
-from flask import make_response, render_template, request, session
+import logging
+import os
+import re
+import sys
+from collections import OrderedDict
+from cachetools import lru_cache
-def render_page_template(name, route_data=None, **kwargs):
+from flask import make_response, render_template
+from flask_restful import reqparse
+
+from config_app.config_endpoints.api import method_metadata
+from config_app.app import app
+
+
+def truthy_bool(param):
+ return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'}
+
+
+DEFAULT_JS_BUNDLE_NAME = 'configapp'
+PARAM_REGEX = re.compile(r'<([^:>]+:)*([\w]+)>')
+logger = logging.getLogger(__name__)
+TYPE_CONVERTER = {
+ truthy_bool: 'boolean',
+ str: 'string',
+ basestring: 'string',
+ reqparse.text_type: 'string',
+ int: 'integer',
+}
+
+
+def _list_files(path, extension, contains=""):
+ """ Returns a list of all the files with the given extension found under the given path. """
+
+ def matches(f):
+ return os.path.splitext(f)[1] == '.' + extension and contains in os.path.splitext(f)[0]
+
+ def join_path(dp, f):
+ # Remove the static/ prefix. It is added in the template.
+ return os.path.join(dp, f)[len('static/'):]
+
+ filepath = os.path.join('static/', path)
+ return [join_path(dp, f) for dp, _, files in os.walk(filepath) for f in files if matches(f)]
+
+
+def render_page_template(name, route_data=None, js_bundle_name=DEFAULT_JS_BUNDLE_NAME, **kwargs):
""" Renders the page template with the given name as the response and returns its contents. """
- # main_scripts = _list_files('build', 'js', js_bundle_name)
- #
- # use_cdn = app.config.get('USE_CDN', True)
- # if request.args.get('use_cdn') is not None:
- # use_cdn = request.args.get('use_cdn') == 'true'
- #
- # external_styles = get_external_css(local=not use_cdn)
- # external_scripts = get_external_javascript(local=not use_cdn)
- #
- # # Add Stripe checkout if billing is enabled.
- # if features.BILLING:
- # external_scripts.append('//checkout.stripe.com/checkout.js')
- #
- # def get_external_login_config():
- # login_config = []
- # for login_service in oauth_login.services:
- # login_config.append({
- # 'id': login_service.service_id(),
- # 'title': login_service.service_name(),
- # 'config': login_service.get_public_config(),
- # 'icon': login_service.get_icon(),
- # })
- #
- # return login_config
- #
- # def get_oauth_config():
- # oauth_config = {}
- # for oauth_app in oauth_apps:
- # oauth_config[oauth_app.key_name] = oauth_app.get_public_config()
- #
- # return oauth_config
- #
- # contact_href = None
- # if len(app.config.get('CONTACT_INFO', [])) == 1:
- # contact_href = app.config['CONTACT_INFO'][0]
- #
- # version_number = ''
- # if not features.BILLING:
- # version_number = 'Quay %s' % __version__
- #
- # scopes_set = {scope.scope: scope._asdict() for scope in scopes.app_scopes(app.config).values()}
+ main_scripts = _list_files('build', 'js', js_bundle_name)
contents = render_template(name,
route_data=route_data,
- # external_styles=external_styles,
- # external_scripts=external_scripts,
- # main_scripts=main_scripts,
- # feature_set=features.get_features(),
- # config_set=frontend_visible_config(app.config),
- # oauth_set=get_oauth_config(),
- # external_login_set=get_external_login_config(),
- # scope_set=scopes_set,
- # vuln_priority_set=PRIORITY_LEVELS,
- # enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''),
- # mixpanel_key=app.config.get('MIXPANEL_KEY', ''),
- # munchkin_key=app.config.get('MARKETO_MUNCHKIN_ID', ''),
- # recaptcha_key=app.config.get('RECAPTCHA_SITE_KEY', ''),
- # google_tagmanager_key=app.config.get('GOOGLE_TAGMANAGER_KEY', ''),
- # google_anaytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''),
- # sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''),
- # is_debug=str(app.config.get('DEBUGGING', False)).lower(),
- # show_chat=features.SUPPORT_CHAT,
- # aci_conversion=features.ACI_CONVERSION,
- # has_billing=features.BILLING,
- # contact_href=contact_href,
- # hostname=app.config['SERVER_HOSTNAME'],
- # preferred_scheme=app.config['PREFERRED_URL_SCHEME'],
- # version_number=version_number,
- # current_year=datetime.datetime.now().year,
+ main_scripts=main_scripts,
**kwargs)
resp = make_response(contents)
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp
+
+
+def fully_qualified_name(method_view_class):
+ return '%s.%s' % (method_view_class.__module__, method_view_class.__name__)
+
+
+# @lru_cache(maxsize=1)
+def generate_route_data():
+ include_internal = True
+ compact = True
+
+ def swagger_parameter(name, description, kind='path', param_type='string', required=True,
+ enum=None, schema=None):
+ # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#parameterObject
+ parameter_info = {
+ 'name': name,
+ 'in': kind,
+ 'required': required
+ }
+
+ if schema:
+ parameter_info['schema'] = {
+ '$ref': '#/definitions/%s' % schema
+ }
+ else:
+ parameter_info['type'] = param_type
+
+ if enum is not None and len(list(enum)) > 0:
+ parameter_info['enum'] = list(enum)
+
+ return parameter_info
+
+ paths = {}
+ models = {}
+ tags = []
+ tags_added = set()
+ operation_ids = set()
+
+ print('APP URL MAp:')
+ print(app.url_map)
+ for rule in app.url_map.iter_rules():
+ endpoint_method = app.view_functions[rule.endpoint]
+
+ # Verify that we have a view class for this API method.
+ if not 'view_class' in dir(endpoint_method):
+ continue
+
+ view_class = endpoint_method.view_class
+
+ # Hide the class if it is internal.
+ internal = method_metadata(view_class, 'internal')
+ if not include_internal and internal:
+ continue
+
+ # Build the tag.
+ parts = fully_qualified_name(view_class).split('.')
+ tag_name = parts[-2]
+ if not tag_name in tags_added:
+ tags_added.add(tag_name)
+ tags.append({
+ 'name': tag_name,
+ 'description': (sys.modules[view_class.__module__].__doc__ or '').strip()
+ })
+
+ # Build the Swagger data for the path.
+ swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule)
+ full_name = fully_qualified_name(view_class)
+ path_swagger = {
+ 'x-name': full_name,
+ 'x-path': swagger_path,
+ 'x-tag': tag_name
+ }
+
+ related_user_res = method_metadata(view_class, 'related_user_resource')
+ if related_user_res is not None:
+ path_swagger['x-user-related'] = fully_qualified_name(related_user_res)
+
+ paths[swagger_path] = path_swagger
+
+ # Add any global path parameters.
+ param_data_map = view_class.__api_path_params if '__api_path_params' in dir(view_class) else {}
+ if param_data_map:
+ path_parameters_swagger = []
+ for path_parameter in param_data_map:
+ description = param_data_map[path_parameter].get('description')
+ path_parameters_swagger.append(swagger_parameter(path_parameter, description))
+
+ path_swagger['parameters'] = path_parameters_swagger
+
+ # Add the individual HTTP operations.
+ method_names = list(rule.methods.difference(['HEAD', 'OPTIONS']))
+ for method_name in method_names:
+ # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#operation-object
+ method = getattr(view_class, method_name.lower(), None)
+ if method is None:
+ logger.debug('Unable to find method for %s in class %s', method_name, view_class)
+ continue
+
+ operationId = method_metadata(method, 'nickname')
+ operation_swagger = {
+ 'operationId': operationId,
+ 'parameters': [],
+ }
+
+ if operationId is None:
+ continue
+
+ if operationId in operation_ids:
+ raise Exception('Duplicate operation Id: %s' % operationId)
+
+ operation_ids.add(operationId)
+
+ # Mark the method as internal.
+ internal = method_metadata(method, 'internal')
+ if internal is not None:
+ operation_swagger['x-internal'] = True
+
+ if include_internal:
+ requires_fresh_login = method_metadata(method, 'requires_fresh_login')
+ if requires_fresh_login is not None:
+ operation_swagger['x-requires-fresh-login'] = True
+
+ # Add the path parameters.
+ if rule.arguments:
+ for path_parameter in rule.arguments:
+ description = param_data_map.get(path_parameter, {}).get('description')
+ operation_swagger['parameters'].append(swagger_parameter(path_parameter, description))
+
+ # Add the query parameters.
+ if '__api_query_params' in dir(method):
+ for query_parameter_info in method.__api_query_params:
+ name = query_parameter_info['name']
+ description = query_parameter_info['help']
+ param_type = TYPE_CONVERTER[query_parameter_info['type']]
+ required = query_parameter_info['required']
+
+ operation_swagger['parameters'].append(
+ swagger_parameter(name, description, kind='query',
+ param_type=param_type,
+ required=required,
+ enum=query_parameter_info['choices']))
+
+ # Add the OAuth security block.
+ # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#securityRequirementObject
+ scope = method_metadata(method, 'oauth2_scope')
+ if scope and not compact:
+ operation_swagger['security'] = [{'oauth2_implicit': [scope.scope]}]
+
+ # Add the responses block.
+ # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#responsesObject
+ response_schema_name = method_metadata(method, 'response_schema')
+ if not compact:
+ if response_schema_name:
+ models[response_schema_name] = view_class.schemas[response_schema_name]
+
+ models['ApiError'] = {
+ 'type': 'object',
+ 'properties': {
+ 'status': {
+ 'type': 'integer',
+ 'description': 'Status code of the response.'
+ },
+ 'type': {
+ 'type': 'string',
+ 'description': 'Reference to the type of the error.'
+ },
+ 'detail': {
+ 'type': 'string',
+ 'description': 'Details about the specific instance of the error.'
+ },
+ 'title': {
+ 'type': 'string',
+ 'description': 'Unique error code to identify the type of error.'
+ },
+ 'error_message': {
+ 'type': 'string',
+ 'description': 'Deprecated; alias for detail'
+ },
+ 'error_type': {
+ 'type': 'string',
+ 'description': 'Deprecated; alias for detail'
+ }
+ },
+ 'required': [
+ 'status',
+ 'type',
+ 'title',
+ ]
+ }
+
+ responses = {
+ '400': {
+ 'description': 'Bad Request',
+ },
+
+ '401': {
+ 'description': 'Session required',
+ },
+
+ '403': {
+ 'description': 'Unauthorized access',
+ },
+
+ '404': {
+ 'description': 'Not found',
+ },
+ }
+
+ for _, body in responses.items():
+ body['schema'] = {'$ref': '#/definitions/ApiError'}
+
+ if method_name == 'DELETE':
+ responses['204'] = {
+ 'description': 'Deleted'
+ }
+ elif method_name == 'POST':
+ responses['201'] = {
+ 'description': 'Successful creation'
+ }
+ else:
+ responses['200'] = {
+ 'description': 'Successful invocation'
+ }
+
+ if response_schema_name:
+ responses['200']['schema'] = {
+ '$ref': '#/definitions/%s' % response_schema_name
+ }
+
+ operation_swagger['responses'] = responses
+
+ # Add the request block.
+ request_schema_name = method_metadata(method, 'request_schema')
+ if request_schema_name and not compact:
+ models[request_schema_name] = view_class.schemas[request_schema_name]
+
+ operation_swagger['parameters'].append(
+ swagger_parameter('body', 'Request body contents.', kind='body',
+ schema=request_schema_name))
+
+ # Add the operation to the parent path.
+ if not internal or (internal and include_internal):
+ path_swagger[method_name.lower()] = operation_swagger
+
+ tags.sort(key=lambda t: t['name'])
+ paths = OrderedDict(sorted(paths.items(), key=lambda p: p[1]['x-tag']))
+
+ if compact:
+ return {'paths': paths}
diff --git a/config_app/config_endpoints/setup_web.py b/config_app/config_endpoints/setup_web.py
index eb07ae01d..90693d037 100644
--- a/config_app/config_endpoints/setup_web.py
+++ b/config_app/config_endpoints/setup_web.py
@@ -1,47 +1,18 @@
-import os
-import json
-import logging
-
-from datetime import timedelta, datetime
-
-from cachetools import lru_cache
-# from flask import (abort, redirect, request, url_for, make_response, Response, render_template,
-# Blueprint, jsonify, send_file, session)
from flask import Blueprint
-# from flask_login import current_user
-
-
-from app import (app)
-# from endpoints.api.discovery import swagger_route_data
from common import render_page_template
+from config_app.config_endpoints.common import generate_route_data
from util.cache import no_cache
-
-
-# @lru_cache(maxsize=1)
-# def _get_route_data():
-# return swagger_route_data(include_internal=True, compact=True)
-
-
-def render_page_template_with_routedata(name, *args, **kwargs):
- return render_page_template(name, *args, **kwargs)
-
-# Capture the unverified SSL errors.
-logger = logging.getLogger(__name__)
-logging.captureWarnings(True)
-
setup_web = Blueprint('setup_web', __name__, template_folder='templates')
-# STATUS_TAGS = app.config['STATUS_TAGS']
+
+def render_page_template_with_routedata(name, *args, **kwargs):
+ return render_page_template(name, generate_route_data(), *args, **kwargs)
+
@setup_web.route('/', methods=['GET'], defaults={'path': ''})
@no_cache
def index(path, **kwargs):
- return render_page_template_with_routedata('config_index.html', js_bundle_name='configapp', **kwargs)
+ return render_page_template_with_routedata('index.html', js_bundle_name='configapp', **kwargs)
+
-@setup_web.errorhandler(404)
-@setup_web.route('/404', methods=['GET'])
-def not_found_error_display(e = None):
- resp = index('', error_code=404, error_info=dict(reason='notfound'))
- resp.status_code = 404
- return resp
diff --git a/config_app/js/components/file-upload-box.js b/config_app/js/components/file-upload-box.js
new file mode 100644
index 000000000..7005c21ed
--- /dev/null
+++ b/config_app/js/components/file-upload-box.js
@@ -0,0 +1,172 @@
+/**
+ * An element which adds a stylize box for uploading a file.
+ */
+angular.module('quay-config').directive('fileUploadBox', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: '/static/directives/file-upload-box.html',
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'selectMessage': '@selectMessage',
+
+ 'filesSelected': '&filesSelected',
+ 'filesCleared': '&filesCleared',
+ 'filesValidated': '&filesValidated',
+
+ 'extensions': '= $scope.selectedFiles.length) {
+ callback(true, fileIds);
+ return;
+ }
+
+ // For the current file, retrieve a file-drop URL from the API for the file.
+ var currentFile = $scope.selectedFiles[currentIndex];
+ var mimeType = currentFile.type || 'application/octet-stream';
+ var data = {
+ 'mimeType': mimeType
+ };
+
+ $scope.currentlyUploadingFile = currentFile;
+ $scope.uploadProgress = 0;
+
+ ApiService.getFiledropUrl(data).then(function(resp) {
+ // Perform the upload.
+ conductUpload(currentFile, resp.url, resp.file_id, mimeType, progressCb, doneCb);
+ }, function() {
+ callback(false, 'Could not retrieve upload URL');
+ });
+ };
+
+ // Start the uploading.
+ $scope.state = 'uploading';
+ performFileUpload();
+ };
+
+ $scope.handleFilesChanged = function(files) {
+ if ($scope.state == 'uploading') { return; }
+
+ $scope.message = null;
+ $scope.selectedFiles = files;
+
+ if (files.length == 0) {
+ $scope.state = 'clear';
+ $scope.filesCleared();
+ } else {
+ for (var i = 0; i < files.length; ++i) {
+ if (files[i].size > MAX_FILE_SIZE) {
+ $scope.state = 'error';
+ $scope.message = 'File ' + files[i].name + ' is larger than the maximum file ' +
+ 'size of ' + MAX_FILE_SIZE_MB + ' MB';
+ return;
+ }
+ }
+
+ $scope.state = 'checking';
+ $scope.filesSelected({
+ 'files': files,
+ 'callback': function(status, message) {
+ $scope.state = status ? 'okay' : 'error';
+ $scope.message = message;
+
+ if (status) {
+ $scope.filesValidated({
+ 'files': files,
+ 'uploadFiles': uploadFiles
+ });
+ }
+ }
+ });
+ }
+ };
+
+ $scope.getAccepts = function(extensions) {
+ if (!extensions || !extensions.length) {
+ return '*';
+ }
+
+ return extensions.join(',');
+ };
+
+ $scope.$watch('reset', function(reset) {
+ if (reset) {
+ $scope.state = 'clear';
+ $element.find('#file-drop-' + $scope.boxId).parent().trigger('reset');
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+});
\ No newline at end of file
diff --git a/config_app/js/config-app.module.ts b/config_app/js/config-app.module.ts
new file mode 100644
index 000000000..59fe1bf20
--- /dev/null
+++ b/config_app/js/config-app.module.ts
@@ -0,0 +1,45 @@
+import { NgModule } from 'ng-metadata/core';
+import * as restangular from 'restangular';
+
+const quayDependencies: string[] = [
+ 'restangular',
+ 'ngCookies',
+ 'angularFileUpload',
+ 'ngSanitize'
+];
+
+@NgModule(({
+ imports: quayDependencies,
+ declarations: [],
+ providers: [
+ provideConfig,
+ ]
+}))
+class DependencyConfig{}
+
+
+provideConfig.$inject = [
+ '$provide',
+ '$injector',
+ '$compileProvider',
+ 'RestangularProvider',
+];
+
+function provideConfig($provide: ng.auto.IProvideService,
+ $injector: ng.auto.IInjectorService,
+ $compileProvider: ng.ICompileProvider,
+ RestangularProvider: any): void {
+
+ // Configure the API provider.
+ RestangularProvider.setBaseUrl('/api/v1/');
+
+ console.log('i');
+}
+
+
+@NgModule({
+ imports: [ DependencyConfig ],
+ declarations: [],
+ providers: []
+})
+export class ConfigAppModule {}
diff --git a/config_app/js/config-field-templates/config-bool-field.html b/config_app/js/config-field-templates/config-bool-field.html
new file mode 100644
index 000000000..190698290
--- /dev/null
+++ b/config_app/js/config-field-templates/config-bool-field.html
@@ -0,0 +1,8 @@
+
+
+
diff --git a/config_app/js/config-field-templates/config-certificates-field.html b/config_app/js/config-field-templates/config-certificates-field.html
new file mode 100644
index 000000000..f20e4c459
--- /dev/null
+++ b/config_app/js/config-field-templates/config-certificates-field.html
@@ -0,0 +1,76 @@
+
+
+
+
+ extra_ca_certs is a single file and cannot be processed by this tool. If a valid and appended list of certificates, they will be installed on container startup.
+
+
+
+
+
This section lists any custom or self-signed SSL certificates that are installed in the container on startup after being read from the extra_ca_certs directory in the configuration volume.
+
+
+ Custom certificates are typically used in place of publicly signed certificates for corporate-internal services.
+
+
Please make sure that all custom names used for downstream services (such as Clair) are listed in the certificates below.
+
+ Uploading, validating and updating certificate(s)
+
+
+
No custom certificates found.
+
+
+
+
\ No newline at end of file
diff --git a/config_app/js/config-field-templates/config-contact-field.html b/config_app/js/config-field-templates/config-contact-field.html
new file mode 100644
index 000000000..58cdea0c4
--- /dev/null
+++ b/config_app/js/config-field-templates/config-contact-field.html
@@ -0,0 +1,46 @@
+
diff --git a/config_app/js/config-field-templates/config-numeric-field.html b/config_app/js/config-field-templates/config-numeric-field.html
new file mode 100644
index 000000000..8c25a2fea
--- /dev/null
+++ b/config_app/js/config-field-templates/config-numeric-field.html
@@ -0,0 +1,6 @@
+
+
+
diff --git a/config_app/js/config-field-templates/config-parsed-field.html b/config_app/js/config-field-templates/config-parsed-field.html
new file mode 100644
index 000000000..766b0a8a2
--- /dev/null
+++ b/config_app/js/config-field-templates/config-parsed-field.html
@@ -0,0 +1 @@
+
diff --git a/config_app/js/config-field-templates/config-service-key-field.html b/config_app/js/config-field-templates/config-service-key-field.html
new file mode 100644
index 000000000..52b7c1187
--- /dev/null
+++ b/config_app/js/config-field-templates/config-service-key-field.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ Could not load service keys
+
+
+
+
+
+
+ Valid key for service {{ serviceName }} exists
+
+
+ No valid key found for service {{ serviceName }}
+ Create Key
+
+
+
+
+
+
+
+
diff --git a/config_app/js/config-field-templates/config-setup-tool.html b/config_app/js/config-field-templates/config-setup-tool.html
new file mode 100644
index 000000000..629e3b45f
--- /dev/null
+++ b/config_app/js/config-field-templates/config-setup-tool.html
@@ -0,0 +1,1656 @@
+
+
+
+
diff --git a/config_app/js/config-field-templates/config-string-field.html b/config_app/js/config-field-templates/config-string-field.html
new file mode 100644
index 000000000..703891f89
--- /dev/null
+++ b/config_app/js/config-field-templates/config-string-field.html
@@ -0,0 +1,10 @@
+
+
+
diff --git a/config_app/js/config-field-templates/config-string-list-field.html b/config_app/js/config-field-templates/config-string-list-field.html
new file mode 100644
index 000000000..de29dfb91
--- /dev/null
+++ b/config_app/js/config-field-templates/config-string-list-field.html
@@ -0,0 +1,6 @@
+
+
+
diff --git a/config_app/js/config-field-templates/config-variable-field.html b/config_app/js/config-field-templates/config-variable-field.html
new file mode 100644
index 000000000..9236469cd
--- /dev/null
+++ b/config_app/js/config-field-templates/config-variable-field.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/config_app/js/core-config-setup/config-setup-tool.html b/config_app/js/core-config-setup/config-setup-tool.html
new file mode 100644
index 000000000..ec3faa1c7
--- /dev/null
+++ b/config_app/js/core-config-setup/config-setup-tool.html
@@ -0,0 +1,1657 @@
+
+
+
+
+
diff --git a/config_app/js/core-config-setup/core-config-setup.js b/config_app/js/core-config-setup/core-config-setup.js
new file mode 100644
index 000000000..d069ab186
--- /dev/null
+++ b/config_app/js/core-config-setup/core-config-setup.js
@@ -0,0 +1,1454 @@
+import * as URI from 'urijs';
+import * as angular from 'angular';
+const templateUrl = require('./config-setup-tool.html');
+const urlParsedField = require('../config-field-templates/config-parsed-field.html');
+const urlVarField = require('../config-field-templates/config-variable-field.html');
+const urlListField = require('../config-field-templates/config-list-field.html');
+const urlFileField = require('../config-field-templates/config-file-field.html');
+const urlBoolField = require('../config-field-templates/config-bool-field.html');
+const urlNumericField = require('../config-field-templates/config-numeric-field.html');
+const urlContactsField = require('../config-field-templates/config-contacts-field.html');
+const urlMapField = require('../config-field-templates/config-map-field.html');
+const urlServiceKeyField = require('../config-field-templates/config-service-key-field.html');
+const urlStringField = require('../config-field-templates/config-string-field.html');
+
+const urlStringListField = require('../config-field-templates/config-string-list-field.html');
+const urlCertField = require('../config-field-templates/config-certificates-field.html');
+
+
+angular.module("quay-config")
+ .directive('configSetupTool', () => {
+ var directiveDefinitionObject = {
+ priority: 1,
+ templateUrl,
+ replace: true,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'isActive': '=isActive',
+ 'configurationSaved': '&configurationSaved'
+ },
+ controller: function($rootScope, $scope, $element, $timeout, ApiService) {
+ console.log('in the controller of the configSetupTool')
+
+ var authPassword = null;
+
+ $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$';
+ $scope.GITHOST_REGEX = '^https?://([a-zA-Z0-9]+\.?\/?)+$';
+
+ $scope.SERVICES = [
+ {'id': 'redis', 'title': 'Redis'},
+
+ {'id': 'registry-storage', 'title': 'Registry Storage'},
+
+ {'id': 'time-machine', 'title': 'Time Machine'},
+
+ {'id': 'access', 'title': 'Access Settings'},
+
+ {'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) {
+ return config.PREFERRED_URL_SCHEME == 'https';
+ }},
+
+ {'id': 'ldap', 'title': 'LDAP Authentication', 'condition': function(config) {
+ return config.AUTHENTICATION_TYPE == 'LDAP';
+ }, 'password': true},
+
+ {'id': 'jwt', 'title': 'JWT Authentication', 'condition': function(config) {
+ return config.AUTHENTICATION_TYPE == 'JWT';
+ }, 'password': true},
+
+ {'id': 'keystone', 'title': 'Keystone Authentication', 'condition': function(config) {
+ return config.AUTHENTICATION_TYPE == 'Keystone';
+ }, 'password': true},
+
+ {'id': 'apptoken-auth', 'title': 'App Token Authentication', 'condition': function(config) {
+ return config.AUTHENTICATION_TYPE == 'AppToken';
+ }},
+
+ {'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) {
+ return config.FEATURE_ACI_CONVERSION;
+ }},
+
+ {'id': 'mail', 'title': 'E-mail Support', 'condition': function(config) {
+ return config.FEATURE_MAILING;
+ }},
+
+ {'id': 'github-login', 'title': 'Github (Enterprise) Authentication', 'condition': function(config) {
+ return config.FEATURE_GITHUB_LOGIN;
+ }},
+
+ {'id': 'google-login', 'title': 'Google Authentication', 'condition': function(config) {
+ return config.FEATURE_GOOGLE_LOGIN;
+ }},
+
+ {'id': 'github-trigger', 'title': 'GitHub (Enterprise) Build Triggers', 'condition': function(config) {
+ return config.FEATURE_GITHUB_BUILD;
+ }},
+
+ {'id': 'bitbucket-trigger', 'title': 'BitBucket Build Triggers', 'condition': function(config) {
+ return config.FEATURE_BITBUCKET_BUILD;
+ }},
+
+ {'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) {
+ return config.FEATURE_GITLAB_BUILD;
+ }},
+
+ {'id': 'security-scanner', 'title': 'Quay Security Scanner', 'condition': function(config) {
+ return config.FEATURE_SECURITY_SCANNER;
+ }},
+
+ {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) {
+ return config.FEATURE_BITTORRENT;
+ }},
+
+ {'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) {
+ return $scope.getOIDCProviders(config).length > 0;
+ }},
+
+ {'id': 'actionlogarchiving', 'title': 'Action Log Rotation', 'condition': function(config) {
+ return config.FEATURE_ACTION_LOG_ROTATION;
+ }},
+ ];
+
+ $scope.STORAGE_CONFIG_FIELDS = {
+ 'LocalStorage': [
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/some/directory', 'kind': 'text'}
+ ],
+
+ 'S3Storage': [
+ {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'},
+ {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true},
+ {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true},
+ {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true},
+ {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}
+ ],
+
+ 'AzureStorage': [
+ {'name': 'azure_container', 'title': 'Azure Storage Container', 'placeholder': 'container', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/container', 'kind': 'text'},
+ {'name': 'azure_account_name', 'title': 'Azure Account Name', 'placeholder': 'accountnamehere', 'kind': 'text'},
+ {'name': 'azure_account_key', 'title': 'Azure Account Key', 'placeholder': 'accountkeyhere', 'kind': 'text', 'optional': true},
+ {'name': 'sas_token', 'title': 'Azure SAS Token', 'placeholder': 'sastokenhere', 'kind': 'text', 'optional': true},
+ ],
+
+ 'GoogleCloudStorage': [
+ {'name': 'access_key', 'title': 'Cloud Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'},
+ {'name': 'secret_key', 'title': 'Cloud Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
+ {'name': 'bucket_name', 'title': 'GCS Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}
+ ],
+
+ 'RadosGWStorage': [
+ {'name': 'hostname', 'title': 'Rados Server Hostname', 'placeholder': 'my.rados.hostname', 'kind': 'text'},
+ {'name': 'port', 'title': 'Custom Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true},
+ {'name': 'is_secure', 'title': 'Is Secure', 'placeholder': 'Require SSL', 'kind': 'bool'},
+ {'name': 'access_key', 'title': 'Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text', 'help_url': 'http://ceph.com/docs/master/radosgw/admin/'},
+ {'name': 'secret_key', 'title': 'Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'},
+ {'name': 'bucket_name', 'title': 'Bucket Name', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}
+ ],
+
+ 'SwiftStorage': [
+ {'name': 'auth_version', 'title': 'Swift Auth Version', 'kind': 'option', 'values': [1, 2, 3]},
+ {'name': 'auth_url', 'title': 'Swift Auth URL', 'placeholder': 'http://swiftdomain/auth/v1.0', 'kind': 'text'},
+ {'name': 'swift_container', 'title': 'Swift Container Name', 'placeholder': 'mycontainer', 'kind': 'text',
+ 'help_text': 'The swift container for all objects. Must already exist inside Swift.'},
+
+ {'name': 'storage_path', 'title': 'Storage Path', 'placeholder': '/path/inside/container', 'kind': 'text'},
+
+ {'name': 'swift_user', 'title': 'Username', 'placeholder': 'accesskeyhere', 'kind': 'text',
+ 'help_text': 'Note: For Swift V1, this is "username:password" (-U on the CLI).'},
+ {'name': 'swift_password', 'title': 'Key/Password', 'placeholder': 'secretkeyhere', 'kind': 'text',
+ 'help_text': 'Note: For Swift V1, this is the API token (-K on the CLI).'},
+
+ {'name': 'ca_cert_path', 'title': 'CA Cert Filename', 'placeholder': 'conf/stack/swift.cert', 'kind': 'text', 'optional': true},
+
+ {'name': 'temp_url_key', 'title': 'Temp URL Key (optional)', 'placholder': 'key-here', 'kind': 'text', 'optional': true,
+ 'help_url': 'https://coreos.com/products/enterprise-registry/docs/latest/swift-temp-url.html',
+ 'help_text': 'If enabled, will allow for faster pulls directly from Swift.'},
+
+ {'name': 'os_options', 'title': 'OS Options', 'kind': 'map',
+ 'keys': ['tenant_id', 'auth_token', 'service_type', 'endpoint_type', 'tenant_name', 'object_storage_url', 'region_name',
+ 'project_id', 'project_name', 'project_domain_name', 'user_domain_name', 'user_domain_id']}
+ ],
+
+ 'CloudFrontedS3Storage': [
+ {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'},
+ {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'},
+ {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true},
+ {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true},
+ {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true},
+ {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true},
+
+ {'name': 'cloudfront_distribution_domain', 'title': 'CloudFront Distribution Domain Name', 'placeholder': 'somesubdomain.cloudfront.net', 'pattern': '^([0-9a-zA-Z]+\\.)+[0-9a-zA-Z]+$', 'kind': 'text'},
+ {'name': 'cloudfront_key_id', 'title': 'CloudFront Key ID', 'placeholder': 'APKATHISISAKEYID', 'kind': 'text'},
+ {'name': 'cloudfront_privatekey_filename', 'title': 'CloudFront Private Key', 'filesuffix': 'cloudfront-signing-key.pem', 'kind': 'file'},
+ ],
+ };
+
+ $scope.enableFeature = function(config, feature) {
+ config[feature] = true;
+ };
+
+ $scope.validateHostname = function(hostname) {
+ if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) {
+ return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.'
+ }
+
+ return null;
+ };
+
+ $scope.config = null;
+ $scope.mapped = {
+ '$hasChanges': false
+ };
+
+ $scope.hasfile = {};
+ $scope.validating = null;
+ $scope.savingConfiguration = false;
+
+ $scope.removeOIDCProvider = function(provider) {
+ delete $scope.config[provider];
+ };
+
+ $scope.addOIDCProvider = () => {
+ bootbox.prompt('Enter an ID for the OIDC provider', function(result) {
+ if (!result) {
+ return;
+ }
+
+ result = result.toUpperCase();
+
+ if (!result.match(/^[A-Z0-9]+$/)) {
+ bootbox.alert('Invalid ID for OIDC provider: must be alphanumeric');
+ return;
+ }
+
+ if (result == 'GITHUB' || result == 'GOOGLE') {
+ bootbox.alert('Invalid ID for OIDC provider: cannot be a reserved name');
+ return;
+ }
+
+ var key = result + '_LOGIN_CONFIG';
+ if ($scope.config[key]) {
+ bootbox.alert('Invalid ID for OIDC provider: already exists');
+ return;
+ }
+
+ $scope.config[key] = {};
+ });
+ };
+
+ $scope.getOIDCProviderId = function(key) {
+ var index = key.indexOf('_LOGIN_CONFIG');
+ if (index <= 0) {
+ return null;
+ }
+
+ return key.substr(0, index).toLowerCase();
+ };
+
+ $scope.getOIDCProviders = function(config) {
+ var keys = Object.keys(config || {});
+ return keys.filter(function(key) {
+ if (key == 'GITHUB_LOGIN_CONFIG' || key == 'GOOGLE_LOGIN_CONFIG') {
+ // Has custom UI and config.
+ return false;
+ }
+
+ return !!$scope.getOIDCProviderId(key);
+ });
+ };
+
+ $scope.getServices = function(config) {
+ var services = [];
+ if (!config) { return services; }
+
+ for (var i = 0; i < $scope.SERVICES.length; ++i) {
+ var service = $scope.SERVICES[i];
+ if (!service.condition || service.condition(config)) {
+ services.push({
+ 'service': service,
+ 'status': 'validating'
+ });
+ }
+ }
+
+ return services;
+ };
+
+ $scope.validationStatus = function(serviceInfos) {
+ if (!serviceInfos) { return 'validating'; }
+
+ var hasError = false;
+ for (var i = 0; i < serviceInfos.length; ++i) {
+ if (serviceInfos[i].status == 'validating') {
+ return 'validating';
+ }
+ if (serviceInfos[i].status == 'error') {
+ hasError = true;
+ }
+ }
+
+ return hasError ? 'failed' : 'success';
+ };
+
+ $scope.cancelValidation = function() {
+ $('#validateAndSaveModal').modal('hide');
+ $scope.validating = null;
+ $scope.savingConfiguration = false;
+ };
+
+ $scope.validateService = function(serviceInfo, opt_password) {
+ var params = {
+ 'service': serviceInfo.service.id
+ };
+
+ var data = {
+ 'config': $scope.config,
+ 'password': opt_password || ''
+ };
+
+ var errorDisplay = ApiService.errorDisplay(
+ 'Could not validate configuration. Please report this error.',
+ function() {
+ authPassword = null;
+ });
+
+ ApiService.scValidateConfig(data, params).then(function(resp) {
+ serviceInfo.status = resp.status ? 'success' : 'error';
+ serviceInfo.errorMessage = $.trim(resp.reason || '');
+
+ if (!resp.status) {
+ authPassword = null;
+ }
+
+ }, errorDisplay);
+ };
+
+ $scope.checkValidateAndSave = function() {
+ if ($scope.configform.$valid) {
+ saveStorageConfig();
+ $scope.validateAndSave();
+ return;
+ }
+
+ var query = $element.find("input.ng-invalid:first");
+
+ if (query && query.length) {
+ query[0].scrollIntoView();
+ query.focus();
+ }
+ };
+
+ $scope.validateAndSave = function() {
+ $scope.validating = $scope.getServices($scope.config);
+
+ var requirePassword = false;
+ for (var i = 0; i < $scope.validating.length; ++i) {
+ var serviceInfo = $scope.validating[i];
+ if (serviceInfo.service.password) {
+ requirePassword = true;
+ break;
+ }
+ }
+
+ if (!requirePassword) {
+ $scope.performValidateAndSave();
+ return;
+ }
+
+ var box = bootbox.dialog({
+ "message": 'Please enter your superuser password to validate your auth configuration:' +
+ '',
+ "title": 'Enter Password',
+ "buttons": {
+ "success": {
+ "label": "Validate Config",
+ "className": "btn-success btn-continue",
+ "callback": function() {
+ $scope.performValidateAndSave($('#validatePassword').val());
+ }
+ },
+ "close": {
+ "label": "Cancel",
+ "className": "btn-default",
+ "callback": function() {
+ }
+ }
+ }
+ });
+
+ box.bind('shown.bs.modal', function(){
+ box.find("input").focus();
+ box.find("form").submit(function() {
+ if (!$('#validatePassword').val()) { return; }
+ box.modal('hide');
+ });
+ });
+ };
+
+ $scope.performValidateAndSave = function(opt_password) {
+ $scope.savingConfiguration = false;
+ $scope.validating = $scope.getServices($scope.config);
+
+ authPassword = opt_password;
+
+ $('#validateAndSaveModal').modal({
+ keyboard: false,
+ backdrop: 'static'
+ });
+
+ for (var i = 0; i < $scope.validating.length; ++i) {
+ var serviceInfo = $scope.validating[i];
+ $scope.validateService(serviceInfo, opt_password);
+ }
+ };
+
+ $scope.saveConfiguration = function() {
+ $scope.savingConfiguration = true;
+
+ // Make sure to note that fully verified setup is completed. We use this as a signal
+ // in the setup tool.
+ $scope.config['SETUP_COMPLETE'] = true;
+
+ var data = {
+ 'config': $scope.config,
+ 'hostname': window.location.host,
+ 'password': authPassword || ''
+ };
+
+ var errorDisplay = ApiService.errorDisplay(
+ 'Could not save configuration. Please report this error.',
+ function() {
+ authPassword = null;
+ });
+
+ ApiService.scUpdateConfig(data).then(function(resp) {
+ authPassword = null;
+
+ $scope.savingConfiguration = false;
+ $scope.mapped.$hasChanges = false;
+
+ $('#validateAndSaveModal').modal('hide');
+
+ $scope.configurationSaved({'config': $scope.config});
+ }, errorDisplay);
+ };
+
+ // Convert storage config to an array
+ var initializeStorageConfig = function($scope) {
+ var config = $scope.config.DISTRIBUTED_STORAGE_CONFIG || {};
+ var defaultLocations = $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS || [];
+ var preference = $scope.config.DISTRIBUTED_STORAGE_PREFERENCE || [];
+
+ $scope.serverStorageConfig = angular.copy(config);
+ $scope.storageConfig = [];
+
+ Object.keys(config).forEach(function(location) {
+ $scope.storageConfig.push({
+ location: location,
+ defaultLocation: defaultLocations.indexOf(location) >= 0,
+ data: angular.copy(config[location]),
+ error: {},
+ });
+ });
+
+ if (!$scope.storageConfig.length) {
+ $scope.addStorageConfig('default');
+ return;
+ }
+
+ // match DISTRIBUTED_STORAGE_PREFERENCE order first, remaining are
+ // ordered by unicode point value
+ $scope.storageConfig.sort(function(a, b) {
+ var indexA = preference.indexOf(a.location);
+ var indexB = preference.indexOf(b.location);
+
+ if (indexA > -1 && indexB > -1) return indexA < indexB ? -1 : 1;
+ if (indexA > -1) return -1;
+ if (indexB > -1) return 1;
+
+ return a.location < b.location ? -1 : 1;
+ });
+ };
+
+ $scope.allowChangeLocationStorageConfig = function(location) {
+ if (!$scope.serverStorageConfig[location]) { return true };
+
+ // allow user to change location ID if another exists with the same ID
+ return $scope.storageConfig.filter(function(sc) {
+ return sc.location === location;
+ }).length >= 2;
+ };
+
+ $scope.allowRemoveStorageConfig = function(location) {
+ return $scope.storageConfig.length > 1 && $scope.allowChangeLocationStorageConfig(location);
+ };
+
+ $scope.canAddStorageConfig = function() {
+ return $scope.config &&
+ $scope.config.FEATURE_STORAGE_REPLICATION &&
+ $scope.storageConfig &&
+ (!$scope.storageConfig.length || $scope.storageConfig.length < 10);
+ };
+
+ $scope.addStorageConfig = function(location) {
+ var storageType = 'LocalStorage';
+
+ // Use last storage type by default
+ if ($scope.storageConfig.length) {
+ storageType = $scope.storageConfig[$scope.storageConfig.length-1].data[0];
+ }
+
+ $scope.storageConfig.push({
+ location: location || '',
+ defaultLocation: false,
+ data: [storageType, {}],
+ error: {},
+ });
+ };
+
+ $scope.removeStorageConfig = function(sc) {
+ $scope.storageConfig.splice($scope.storageConfig.indexOf(sc), 1);
+ };
+
+ var saveStorageConfig = function() {
+ var config = {};
+ var defaultLocations = [];
+ var preference = [];
+
+ $scope.storageConfig.forEach(function(sc) {
+ config[sc.location] = sc.data;
+ if (sc.defaultLocation) defaultLocations.push(sc.location);
+ preference.push(sc.location);
+ });
+
+ $scope.config.DISTRIBUTED_STORAGE_CONFIG = config;
+ $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = defaultLocations;
+ $scope.config.DISTRIBUTED_STORAGE_PREFERENCE = preference;
+ };
+
+ var gitlabSelector = function(key) {
+ return function(value) {
+ if (!value || !$scope.config) { return; }
+
+ if (!$scope.config[key]) {
+ $scope.config[key] = {};
+ }
+
+ if (value == 'enterprise') {
+ if ($scope.config[key]['GITLAB_ENDPOINT'] == 'https://gitlab.com/') {
+ $scope.config[key]['GITLAB_ENDPOINT'] = '';
+ }
+ } else if (value == 'hosted') {
+ $scope.config[key]['GITLAB_ENDPOINT'] = 'https://gitlab.com/';
+ }
+ };
+ };
+
+ var githubSelector = function(key) {
+ return function(value) {
+ if (!value || !$scope.config) { return; }
+
+ if (!$scope.config[key]) {
+ $scope.config[key] = {};
+ }
+
+ if (value == 'enterprise') {
+ if ($scope.config[key]['GITHUB_ENDPOINT'] == 'https://github.com/') {
+ $scope.config[key]['GITHUB_ENDPOINT'] = '';
+ }
+ delete $scope.config[key]['API_ENDPOINT'];
+ } else if (value == 'hosted') {
+ $scope.config[key]['GITHUB_ENDPOINT'] = 'https://github.com/';
+ $scope.config[key]['API_ENDPOINT'] = 'https://api.github.com/';
+ }
+ };
+ };
+
+ var getKey = function(config, path) {
+ if (!config) {
+ return null;
+ }
+
+ var parts = path.split('.');
+ var current = config;
+ for (var i = 0; i < parts.length; ++i) {
+ var part = parts[i];
+ if (!current[part]) { return null; }
+ current = current[part];
+ }
+ return current;
+ };
+
+ var initializeMappedLogic = function(config) {
+ var gle = getKey(config, 'GITHUB_LOGIN_CONFIG.GITHUB_ENDPOINT');
+ var gte = getKey(config, 'GITHUB_TRIGGER_CONFIG.GITHUB_ENDPOINT');
+
+ $scope.mapped['GITHUB_LOGIN_KIND'] = gle == 'https://github.com/' ? 'hosted' : 'enterprise';
+ $scope.mapped['GITHUB_TRIGGER_KIND'] = gte == 'https://github.com/' ? 'hosted' : 'enterprise';
+
+ var glabe = getKey(config, 'GITLAB_TRIGGER_KIND.GITHUB_ENDPOINT');
+ $scope.mapped['GITLAB_TRIGGER_KIND'] = glabe == 'https://gitlab.com/' ? 'hosted' : 'enterprise';
+
+ $scope.mapped['redis'] = {};
+ $scope.mapped['redis']['host'] = getKey(config, 'BUILDLOGS_REDIS.host') || getKey(config, 'USER_EVENTS_REDIS.host');
+ $scope.mapped['redis']['port'] = getKey(config, 'BUILDLOGS_REDIS.port') || getKey(config, 'USER_EVENTS_REDIS.port');
+ $scope.mapped['redis']['password'] = getKey(config, 'BUILDLOGS_REDIS.password') || getKey(config, 'USER_EVENTS_REDIS.password');
+
+ $scope.mapped['TLS_SETTING'] = 'none';
+ if (config['PREFERRED_URL_SCHEME'] == 'https') {
+ if (config['EXTERNAL_TLS_TERMINATION'] === true) {
+ $scope.mapped['TLS_SETTING'] = 'external-tls';
+ } else {
+ $scope.mapped['TLS_SETTING'] = 'internal-tls';
+ }
+ }
+ };
+
+ var tlsSetter = function(value) {
+ if (value == null || !$scope.config) { return; }
+
+ switch (value) {
+ case 'none':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'http';
+ delete $scope.config['EXTERNAL_TLS_TERMINATION'];
+ return;
+
+ case 'external-tls':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'https';
+ $scope.config['EXTERNAL_TLS_TERMINATION'] = true;
+ return;
+
+ case 'internal-tls':
+ $scope.config['PREFERRED_URL_SCHEME'] = 'https';
+ delete $scope.config['EXTERNAL_TLS_TERMINATION'];
+ return;
+ }
+ };
+
+ var redisSetter = function(keyname) {
+ return function(value) {
+ if (value == null || !$scope.config) { return; }
+
+ if (!$scope.config['BUILDLOGS_REDIS']) {
+ $scope.config['BUILDLOGS_REDIS'] = {};
+ }
+
+ if (!$scope.config['USER_EVENTS_REDIS']) {
+ $scope.config['USER_EVENTS_REDIS'] = {};
+ }
+
+ if (!value) {
+ delete $scope.config['BUILDLOGS_REDIS'][keyname];
+ delete $scope.config['USER_EVENTS_REDIS'][keyname];
+ return;
+ }
+
+ $scope.config['BUILDLOGS_REDIS'][keyname] = value;
+ $scope.config['USER_EVENTS_REDIS'][keyname] = value;
+ };
+ };
+
+ // Add mapped logic.
+ $scope.$watch('mapped.GITHUB_LOGIN_KIND', githubSelector('GITHUB_LOGIN_CONFIG'));
+ $scope.$watch('mapped.GITHUB_TRIGGER_KIND', githubSelector('GITHUB_TRIGGER_CONFIG'));
+ $scope.$watch('mapped.GITLAB_TRIGGER_KIND', gitlabSelector('GITLAB_TRIGGER_KIND'));
+ $scope.$watch('mapped.TLS_SETTING', tlsSetter);
+
+ $scope.$watch('mapped.redis.host', redisSetter('host'));
+ $scope.$watch('mapped.redis.port', redisSetter('port'));
+ $scope.$watch('mapped.redis.password', redisSetter('password'));
+
+ // Remove extra extra fields (which are not allowed) from storage config.
+ var updateFields = function(sc) {
+ var type = sc.data[0];
+ var configObject = sc.data[1];
+ var allowedFields = $scope.STORAGE_CONFIG_FIELDS[type];
+
+ // Remove any fields not allowed.
+ for (var fieldName in configObject) {
+ if (!configObject.hasOwnProperty(fieldName)) {
+ continue;
+ }
+
+ var isValidField = $.grep(allowedFields, function(field) {
+ return field.name == fieldName;
+ }).length > 0;
+
+ if (!isValidField) {
+ delete configObject[fieldName];
+ }
+ }
+
+ // Set any missing boolean fields to false.
+ for (var i = 0; i < allowedFields.length; ++i) {
+ if (allowedFields[i].kind == 'bool') {
+ configObject[allowedFields[i].name] = configObject[allowedFields[i].name] || false;
+ }
+ }
+ };
+
+ // Validate and update storage config on update.
+ var refreshStorageConfig = function() {
+ if (!$scope.config || !$scope.storageConfig) return;
+
+ var locationCounts = {};
+ var errors = [];
+ var valid = true;
+
+ $scope.storageConfig.forEach(function(sc) {
+ // remove extra fields from storage config
+ updateFields(sc);
+
+ if (!locationCounts[sc.location]) locationCounts[sc.location] = 0;
+ locationCounts[sc.location]++;
+ });
+
+ // validate storage config
+ $scope.storageConfig.forEach(function(sc) {
+ var error = {};
+
+ if ($scope.config.FEATURE_STORAGE_REPLICATION && sc.data[0] === 'LocalStorage') {
+ error.engine = 'Replication to a locally mounted directory is unsupported as it is only accessible on a single machine.';
+ valid = false;
+ }
+
+ if (locationCounts[sc.location] > 1) {
+ error.location = 'Location ID must be unique.';
+ valid = false;
+ }
+
+ errors.push(error);
+ });
+
+ $scope.storageConfigError = errors;
+ $scope.configform.$setValidity('storageConfig', valid);
+ };
+
+ $scope.$watch('config.INTERNAL_OIDC_SERVICE_ID', function(service_id) {
+ if (service_id) {
+ $scope.config['FEATURE_DIRECT_LOGIN'] = false;
+ }
+ });
+
+ $scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() {
+ refreshStorageConfig();
+ });
+
+ $scope.$watch('storageConfig', function() {
+ refreshStorageConfig();
+ }, true);
+
+ $scope.$watch('config', function(value) {
+ $scope.mapped['$hasChanges'] = true;
+ }, true);
+
+ $scope.$watch('isActive', function(value) {
+ if (!value) { return; }
+
+ ApiService.scGetConfig().then(function(resp) {
+ $scope.config = resp['config'] || {};
+ initializeMappedLogic($scope.config);
+ initializeStorageConfig($scope);
+ $scope.mapped['$hasChanges'] = false;
+ }, ApiService.errorDisplay('Could not load config'));
+ });
+ }
+ };
+
+ return directiveDefinitionObject;
+ })
+
+ .directive('configParsedField', function ($timeout) {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlParsedField,
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'parser': '&parser',
+ 'serializer': '&serializer'
+ },
+ controller: function($scope, $element, $transclude) {
+ $scope.childScope = null;
+
+ $transclude(function(clone, scope) {
+ $scope.childScope = scope;
+ $scope.childScope['fields'] = {};
+ $element.append(clone);
+ });
+
+ $scope.childScope.$watch('fields', function(value) {
+ // Note: We need the timeout here because Angular starts the digest of the
+ // parent scope AFTER the child scope, which means it can end up one action
+ // behind. The timeout ensures that the parent scope will be fully digest-ed
+ // and then we update the binding. Yes, this is a hack :-/.
+ $timeout(function() {
+ $scope.binding = $scope.serializer({'fields': value});
+ });
+ }, true);
+
+ $scope.$watch('binding', function(value) {
+ var parsed = $scope.parser({'value': value});
+ for (var key in parsed) {
+ if (parsed.hasOwnProperty(key)) {
+ $scope.childScope['fields'][key] = parsed[key];
+ }
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configVariableField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlVarField,
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding'
+ },
+ controller: function($scope, $element) {
+ $scope.sections = {};
+ $scope.currentSection = null;
+
+ $scope.setSection = function(section) {
+ $scope.binding = section.value;
+ };
+
+ this.addSection = function(section, element) {
+ $scope.sections[section.value] = {
+ 'title': section.valueTitle,
+ 'value': section.value,
+ 'element': element
+ };
+
+ element.hide();
+
+ if (!$scope.binding) {
+ $scope.binding = section.value;
+ }
+ };
+
+ $scope.$watch('binding', function(binding) {
+ if (!binding) { return; }
+
+ if ($scope.currentSection) {
+ $scope.currentSection.element.hide();
+ }
+
+ if ($scope.sections[binding]) {
+ $scope.sections[binding].element.show();
+ $scope.currentSection = $scope.sections[binding];
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('variableSection', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlVarField,
+ priority: 1,
+ require: '^configVariableField',
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'value': '@value',
+ 'valueTitle': '@valueTitle'
+ },
+ controller: function($scope, $element) {
+ var parentCtrl = $element.parent().controller('configVariableField');
+ parentCtrl.addSection($scope, $element);
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configListField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlListField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'placeholder': '@placeholder',
+ 'defaultValue': '@defaultValue',
+ 'itemTitle': '@itemTitle',
+ 'itemPattern': '@itemPattern'
+ },
+ controller: function($scope, $element) {
+ $scope.removeItem = function(item) {
+ var index = $scope.binding.indexOf(item);
+ if (index >= 0) {
+ $scope.binding.splice(index, 1);
+ }
+ };
+
+ $scope.addItem = function() {
+ if (!$scope.newItemName) {
+ return;
+ }
+
+ if (!$scope.binding) {
+ $scope.binding = [];
+ }
+
+ if ($scope.binding.indexOf($scope.newItemName) >= 0) {
+ return;
+ }
+
+ $scope.binding.push($scope.newItemName);
+ $scope.newItemName = null;
+ };
+
+ $scope.patternMap = {};
+
+ $scope.getRegexp = function(pattern) {
+ if (!pattern) {
+ pattern = '.*';
+ }
+
+ if ($scope.patternMap[pattern]) {
+ return $scope.patternMap[pattern];
+ }
+
+ return $scope.patternMap[pattern] = new RegExp(pattern);
+ };
+
+ $scope.$watch('binding', function(binding) {
+ if (!binding && $scope.defaultValue) {
+ $scope.binding = eval($scope.defaultValue);
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configFileField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlFileField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'filename': '@filename',
+ 'skipCheckFile': '@skipCheckFile',
+ 'hasFile': '=hasFile',
+ 'binding': '=?binding'
+ },
+ controller: function($scope, $element, Restangular, $upload) {
+ $scope.hasFile = false;
+
+ var setHasFile = function(hasFile) {
+ $scope.hasFile = hasFile;
+ $scope.binding = hasFile ? $scope.filename : null;
+ };
+
+ $scope.onFileSelect = function(files) {
+ if (files.length < 1) {
+ setHasFile(false);
+ return;
+ }
+
+ $scope.uploadProgress = 0;
+ $scope.upload = $upload.upload({
+ url: '/api/v1/superuser/config/file/' + $scope.filename,
+ method: 'POST',
+ data: {'_csrf_token': window.__token},
+ file: files[0],
+ }).progress(function(evt) {
+ $scope.uploadProgress = parseInt(100.0 * evt.loaded / evt.total);
+ if ($scope.uploadProgress == 100) {
+ $scope.uploadProgress = null;
+ setHasFile(true);
+ }
+ }).success(function(data, status, headers, config) {
+ $scope.uploadProgress = null;
+ setHasFile(true);
+ });
+ };
+
+ var loadStatus = function(filename) {
+ Restangular.one('superuser/config/file/' + filename).get().then(function(resp) {
+ setHasFile(false);
+ });
+ };
+
+ if ($scope.filename && $scope.skipCheckFile != "true") {
+ loadStatus($scope.filename);
+ }
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configBoolField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlBoolField,
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding'
+ },
+ controller: function($scope, $element) {
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configNumericField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlNumericField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'placeholder': '@placeholder',
+ 'defaultValue': '@defaultValue',
+ },
+ controller: function($scope, $element) {
+ $scope.bindinginternal = 0;
+
+ $scope.$watch('binding', function(binding) {
+ if ($scope.binding == 0 && $scope.defaultValue) {
+ $scope.binding = $scope.defaultValue * 1;
+ }
+
+ $scope.bindinginternal = $scope.binding;
+ });
+
+ $scope.$watch('bindinginternal', function(binding) {
+ var newValue = $scope.bindinginternal * 1;
+ if (isNaN(newValue)) {
+ newValue = 0;
+ }
+ $scope.binding = newValue;
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configContactsField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlContactsField,
+ priority: 1,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding'
+ },
+ controller: function($scope, $element) {
+ var padItems = function(items) {
+ // Remove the last item if both it and the second to last items are empty.
+ if (items.length > 1 && !items[items.length - 2].value && !items[items.length - 1].value) {
+ items.splice(items.length - 1, 1);
+ return;
+ }
+
+ // If the last item is non-empty, add a new item.
+ if (items.length == 0 || items[items.length - 1].value) {
+ items.push({'value': ''});
+ return;
+ }
+ };
+
+ $scope.itemHash = null;
+ $scope.$watch('items', function(items) {
+ if (!items) { return; }
+ padItems(items);
+
+ var itemHash = '';
+ var binding = [];
+ for (var i = 0; i < items.length; ++i) {
+ var item = items[i];
+ if (item.value && (URI(item.value).host() || URI(item.value).path())) {
+ binding.push(item.value);
+ itemHash += item.value;
+ }
+ }
+
+ $scope.itemHash = itemHash;
+ $scope.binding = binding;
+ }, true);
+
+ $scope.$watch('binding', function(binding) {
+ var current = binding || [];
+ var items = [];
+ var itemHash = '';
+ for (var i = 0; i < current.length; ++i) {
+ items.push({'value': current[i]})
+ itemHash += current[i];
+ }
+
+ if ($scope.itemHash != itemHash) {
+ $scope.items = items;
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configContactField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlContactsField,
+ priority: 1,
+ replace: false,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding'
+ },
+ controller: function($scope, $element) {
+ $scope.kind = null;
+ $scope.value = null;
+
+ var updateBinding = function() {
+ if ($scope.value == null) { return; }
+ var value = $scope.value || '';
+
+ switch ($scope.kind) {
+ case 'mailto':
+ $scope.binding = 'mailto:' + value;
+ return;
+
+ case 'tel':
+ $scope.binding = 'tel:' + value;
+ return;
+
+ case 'irc':
+ $scope.binding = 'irc://' + value;
+ return;
+
+ default:
+ $scope.binding = value;
+ return;
+ }
+ };
+
+ $scope.$watch('kind', updateBinding);
+ $scope.$watch('value', updateBinding);
+
+ $scope.$watch('binding', function(value) {
+ if (!value) {
+ $scope.kind = null;
+ $scope.value = null;
+ return;
+ }
+
+ var uri = URI(value);
+ $scope.kind = uri.scheme();
+
+ switch ($scope.kind) {
+ case 'mailto':
+ case 'tel':
+ $scope.value = uri.path();
+ break;
+
+ case 'irc':
+ $scope.value = value.substr('irc://'.length);
+ break;
+
+ default:
+ $scope.kind = 'http';
+ $scope.value = value;
+ break;
+ }
+ });
+
+ $scope.getPlaceholder = function(kind) {
+ switch (kind) {
+ case 'mailto':
+ return 'some@example.com';
+
+ case 'tel':
+ return '555-555-5555';
+
+ case 'irc':
+ return 'myserver:port/somechannel';
+
+ default:
+ return 'http://some/url';
+ }
+ };
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configMapField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlMapField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'keys': '=keys'
+ },
+ controller: function($scope, $element) {
+ $scope.newKey = null;
+ $scope.newValue = null;
+
+ $scope.hasValues = function(binding) {
+ return binding && Object.keys(binding).length;
+ };
+
+ $scope.removeKey = function(key) {
+ delete $scope.binding[key];
+ };
+
+ $scope.addEntry = function() {
+ if (!$scope.newKey || !$scope.newValue) { return; }
+
+ $scope.binding = $scope.binding || {};
+ $scope.binding[$scope.newKey] = $scope.newValue;
+ $scope.newKey = null;
+ $scope.newValue = null;
+ }
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configServiceKeyField', function (ApiService) {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlServiceKeyField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'serviceName': '@serviceName',
+ },
+ controller: function($scope, $element) {
+ $scope.foundKeys = [];
+ $scope.loading = false;
+ $scope.loadError = false;
+ $scope.hasValidKey = false;
+ $scope.hasValidKeyStr = null;
+
+ $scope.updateKeys = function() {
+ $scope.foundKeys = [];
+ $scope.loading = true;
+
+ ApiService.listServiceKeys().then(function(resp) {
+ $scope.loading = false;
+ $scope.loadError = false;
+
+ resp['keys'].forEach(function(key) {
+ if (key['service'] == $scope.serviceName) {
+ $scope.foundKeys.push(key);
+ }
+ });
+
+ $scope.hasValidKey = checkValidKey($scope.foundKeys);
+ $scope.hasValidKeyStr = $scope.hasValidKey ? 'true' : '';
+ }, function() {
+ $scope.loading = false;
+ $scope.loadError = true;
+ });
+ };
+
+ // Perform initial loading of the keys.
+ $scope.updateKeys();
+
+ $scope.isKeyExpired = function(key) {
+ if (key.expiration_date != null) {
+ var expiration_date = moment(key.expiration_date);
+ return moment().isAfter(expiration_date);
+ }
+ return false;
+ };
+
+ $scope.showRequestServiceKey = function() {
+ $scope.requestKeyInfo = {
+ 'service': $scope.serviceName
+ };
+ };
+
+ $scope.handleKeyCreated = function() {
+ $scope.updateKeys();
+ };
+
+ var checkValidKey = function(keys) {
+ for (var i = 0; i < keys.length; ++i) {
+ var key = keys[i];
+ if (!key.approval) {
+ continue;
+ }
+
+ if ($scope.isKeyExpired(key)) {
+ continue;
+ }
+
+ return true;
+ }
+
+ return false;
+ };
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configStringField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlStringField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'placeholder': '@placeholder',
+ 'pattern': '@pattern',
+ 'defaultValue': '@defaultValue',
+ 'validator': '&validator',
+ 'isOptional': '=isOptional'
+ },
+ controller: function($scope, $element) {
+ var firstSet = true;
+
+ $scope.patternMap = {};
+
+ $scope.getRegexp = function(pattern) {
+ if (!pattern) {
+ pattern = '.*';
+ }
+
+ if ($scope.patternMap[pattern]) {
+ return $scope.patternMap[pattern];
+ }
+
+ return $scope.patternMap[pattern] = new RegExp(pattern);
+ };
+
+ $scope.$watch('binding', function(binding) {
+ if (firstSet && !binding && $scope.defaultValue) {
+ $scope.binding = $scope.defaultValue;
+ firstSet = false;
+ }
+
+ $scope.errorMessage = $scope.validator({'value': binding || ''});
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configStringListField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlStringListField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ 'binding': '=binding',
+ 'itemTitle': '@itemTitle',
+ 'itemDelimiter': '@itemDelimiter',
+ 'placeholder': '@placeholder',
+ 'isOptional': '=isOptional'
+ },
+ controller: function($scope, $element) {
+ $scope.$watch('internalBinding', function(value) {
+ if (value) {
+ $scope.binding = value.split($scope.itemDelimiter);
+ }
+ });
+
+ $scope.$watch('binding', function(value) {
+ if (value) {
+ $scope.internalBinding = value.join($scope.itemDelimiter);
+ }
+ });
+ }
+ };
+ return directiveDefinitionObject;
+ })
+
+ .directive('configCertificatesField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: urlCertField,
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ },
+ controller: function($scope, $element, $upload, ApiService, UserService) {
+ $scope.resetUpload = 0;
+ $scope.certsUploading = false;
+
+ var loadCertificates = function() {
+ $scope.certificatesResource = ApiService.getCustomCertificatesAsResource().get(function(resp) {
+ $scope.certInfo = resp;
+ $scope.certsUploading = false;
+ });
+ };
+
+ UserService.updateUserIn($scope, function(user) {
+ if (!user.anonymous) {
+ loadCertificates();
+ }
+ });
+
+ $scope.handleCertsSelected = function(files, callback) {
+ $scope.certsUploading = true;
+ $upload.upload({
+ url: '/api/v1/superuser/customcerts/' + files[0].name,
+ method: 'POST',
+ data: {'_csrf_token': window.__token},
+ file: files[0]
+ }).success(function() {
+ callback(true);
+ $scope.resetUpload++;
+ loadCertificates();
+ }).error(function(r) {
+ bootbox.alert('Could not upload certificate')
+ callback(false);
+ $scope.resetUpload++;
+ loadCertificates();
+ });
+ };
+
+ $scope.deleteCert = function(path) {
+ var errorDisplay = ApiService.errorDisplay('Could not delete certificate');
+ var params = {
+ 'certpath': path
+ };
+
+ ApiService.deleteCustomCertificate(null, params).then(loadCertificates, errorDisplay);
+ };
+ }
+ };
+ return directiveDefinitionObject;
+ });
diff --git a/config_app/js/main.ts b/config_app/js/main.ts
new file mode 100644
index 000000000..9ca931a8d
--- /dev/null
+++ b/config_app/js/main.ts
@@ -0,0 +1,36 @@
+// imports shims, etc
+import 'core-js';
+
+import '../static/css/core-ui.css';
+
+import * as angular from 'angular';
+import { ConfigAppModule } from './config-app.module';
+import { bundle } from 'ng-metadata/core';
+
+// load all app dependencies
+require('../static/lib/angular-file-upload.min.js');
+require('../../static/js/tar');
+
+const ng1QuayModule: string = bundle(ConfigAppModule, []).name;
+angular.module('quay-config', [ng1QuayModule])
+ .run(() => {
+ console.log(' init run was called')
+ });
+
+console.log('Hello world! I\'m the config app');
+
+declare var require: any;
+function requireAll(r) {
+ r.keys().forEach(r);
+}
+
+// load all services
+// require('./services/api-service');
+requireAll(require.context('./services', true, /\.js$/));
+
+
+// load all the components after services
+requireAll(require.context('./setup', true, /\.js$/));
+requireAll(require.context('./core-config-setup', true, /\.js$/));
+
+
diff --git a/config_app/js/services/api-service.js b/config_app/js/services/api-service.js
new file mode 100644
index 000000000..eaa16b746
--- /dev/null
+++ b/config_app/js/services/api-service.js
@@ -0,0 +1,332 @@
+/**
+ * Service which exposes the server-defined API as a nice set of helper methods and automatic
+ * callbacks. Any method defined on the server is exposed here as an equivalent method. Also
+ * defines some helper functions for working with API responses.
+ */
+// console.log(angular.module('quay-config').requires);
+angular.module('quay-config').factory('ApiService', ['Restangular', '$q', 'UtilService', function(Restangular, $q, UtilService) {
+ var apiService = {};
+
+ // if (!window.__endpoints) {
+ // return apiService;
+ // }
+
+ var getResource = function(getMethod, operation, opt_parameters, opt_background) {
+ var resource = {};
+ resource.withOptions = function(options) {
+ this.options = options;
+ return this;
+ };
+
+ resource.get = function(processor, opt_errorHandler) {
+ var options = this.options;
+ var result = {
+ 'loading': true,
+ 'value': null,
+ 'hasError': false
+ };
+
+ getMethod(options, opt_parameters, opt_background, true).then(function(resp) {
+ result.value = processor(resp);
+ result.loading = false;
+ }, function(resp) {
+ result.hasError = true;
+ result.loading = false;
+ if (opt_errorHandler) {
+ opt_errorHandler(resp);
+ }
+ });
+
+ return result;
+ };
+
+ return resource;
+ };
+
+ var buildUrl = function(path, parameters) {
+ // We already have /api/v1/ on the URLs, so remove them from the paths.
+ path = path.substr('/api/v1/'.length, path.length);
+
+ // Build the path, adjusted with the inline parameters.
+ var used = {};
+ var url = '';
+ for (var i = 0; i < path.length; ++i) {
+ var c = path[i];
+ if (c == '{') {
+ var end = path.indexOf('}', i);
+ var varName = path.substr(i + 1, end - i - 1);
+
+ if (!parameters[varName]) {
+ throw new Error('Missing parameter: ' + varName);
+ }
+
+ used[varName] = true;
+ url += parameters[varName];
+ i = end;
+ continue;
+ }
+
+ url += c;
+ }
+
+ // Append any query parameters.
+ var isFirst = true;
+ for (var paramName in parameters) {
+ if (!parameters.hasOwnProperty(paramName)) { continue; }
+ if (used[paramName]) { continue; }
+
+ var value = parameters[paramName];
+ if (value) {
+ url += isFirst ? '?' : '&';
+ url += paramName + '=' + encodeURIComponent(value)
+ isFirst = false;
+ }
+ }
+
+ return url;
+ };
+
+ var getGenericOperationName = function(userOperationName) {
+ return userOperationName.replace('User', '');
+ };
+
+ var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) {
+ if (userRelatedResource) {
+ if (userRelatedResource[method.toLowerCase()]) {
+ return userRelatedResource[method.toLowerCase()]['operationId'];
+ }
+ }
+
+ throw new Error('Could not find user operation matching org operation: ' + orgOperationName);
+ };
+
+ var freshLoginInProgress = [];
+ var reject = function(msg) {
+ for (var i = 0; i < freshLoginInProgress.length; ++i) {
+ freshLoginInProgress[i].deferred.reject({'data': {'message': msg}});
+ }
+ freshLoginInProgress = [];
+ };
+
+ var retry = function() {
+ for (var i = 0; i < freshLoginInProgress.length; ++i) {
+ freshLoginInProgress[i].retry();
+ }
+ freshLoginInProgress = [];
+ };
+
+ var freshLoginFailCheck = function(opName, opArgs) {
+ return function(resp) {
+ var deferred = $q.defer();
+
+ // If the error is a fresh login required, show the dialog.
+ // TODO: remove error_type (old style error)
+ var fresh_login_required = resp.data['title'] == 'fresh_login_required' || resp.data['error_type'] == 'fresh_login_required';
+ if (resp.status == 401 && fresh_login_required) {
+ var retryOperation = function() {
+ apiService[opName].apply(apiService, opArgs).then(function(resp) {
+ deferred.resolve(resp);
+ }, function(resp) {
+ deferred.reject(resp);
+ });
+ };
+
+ var verifyNow = function() {
+ if (!$('#freshPassword').val()) {
+ return;
+ }
+
+ var info = {
+ 'password': $('#freshPassword').val()
+ };
+
+ $('#freshPassword').val('');
+
+ // Conduct the sign in of the user.
+ apiService.verifyUser(info).then(function() {
+ // On success, retry the operations. if it succeeds, then resolve the
+ // deferred promise with the result. Otherwise, reject the same.
+ retry();
+ }, function(resp) {
+ // Reject with the sign in error.
+ reject('Invalid verification credentials');
+ });
+ };
+
+ // Add the retry call to the in progress list. If there is more than a single
+ // in progress call, we skip showing the dialog (since it has already been
+ // shown).
+ freshLoginInProgress.push({
+ 'deferred': deferred,
+ 'retry': retryOperation
+ })
+
+ if (freshLoginInProgress.length > 1) {
+ return deferred.promise;
+ }
+
+ var box = bootbox.dialog({
+ "message": 'It has been more than a few minutes since you last logged in, ' +
+ 'so please verify your password to perform this sensitive operation:' +
+ '',
+ "title": 'Please Verify',
+ "buttons": {
+ "verify": {
+ "label": "Verify",
+ "className": "btn-success btn-continue",
+ "callback": verifyNow
+ },
+ "close": {
+ "label": "Cancel",
+ "className": "btn-default",
+ "callback": function() {
+ reject('Verification canceled')
+ }
+ }
+ }
+ });
+
+ box.bind('shown.bs.modal', function(){
+ box.find("input").focus();
+ box.find("form").submit(function() {
+ if (!$('#freshPassword').val()) { return; }
+
+ box.modal('hide');
+ verifyNow();
+ });
+ });
+
+ // Return a new promise. We'll accept or reject it based on the result
+ // of the login.
+ return deferred.promise;
+ }
+
+ // Otherwise, we just 'raise' the error via the reject method on the promise.
+ return $q.reject(resp);
+ };
+ };
+
+ var buildMethodsForOperation = function(operation, method, path, resourceMap) {
+ var operationName = operation['operationId'];
+ var urlPath = path['x-path'];
+
+ // Add the operation itself.
+ apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forceget) {
+ var one = Restangular.one(buildUrl(urlPath, opt_parameters));
+ if (opt_background) {
+ one.withHttpConfig({
+ 'ignoreLoadingBar': true
+ });
+ }
+
+ var opObj = one[opt_forceget ? 'get' : 'custom' + method.toUpperCase()](opt_options);
+
+ // If the operation requires_fresh_login, then add a specialized error handler that
+ // will defer the operation's result if sudo is requested.
+ if (operation['x-requires-fresh-login']) {
+ opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
+ }
+ return opObj;
+ };
+
+ // If the method for the operation is a GET, add an operationAsResource method.
+ if (method == 'get') {
+ apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) {
+ var getMethod = apiService[operationName];
+ return getResource(getMethod, operation, opt_parameters, opt_background);
+ };
+ }
+
+ // If the operation has a user-related operation, then make a generic operation for this operation
+ // that can call both the user and the organization versions of the operation, depending on the
+ // parameters given.
+ if (path['x-user-related']) {
+ var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[path['x-user-related']]);
+ var genericOperationName = getGenericOperationName(userOperationName);
+ apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) {
+ if (orgname) {
+ if (orgname.name) {
+ orgname = orgname.name;
+ }
+
+ var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background);
+ return apiService[operationName](opt_options, params);
+ } else {
+ return apiService[userOperationName](opt_options, opt_parameters, opt_background);
+ }
+ };
+ }
+ };
+
+
+ var allowedMethods = ['get', 'post', 'put', 'delete'];
+ var resourceMap = {};
+ var forEachOperation = function(callback) {
+ for (var path in window.__endpoints) {
+ if (!window.__endpoints.hasOwnProperty(path)) {
+ continue;
+ }
+
+ for (var method in window.__endpoints[path]) {
+ if (!window.__endpoints[path].hasOwnProperty(method)) {
+ continue;
+ }
+
+ if (allowedMethods.indexOf(method.toLowerCase()) < 0) { continue; }
+ callback(window.__endpoints[path][method], method, window.__endpoints[path]);
+ }
+ }
+ };
+
+ // Build the map of resource names to their objects.
+ forEachOperation(function(operation, method, path) {
+ resourceMap[path['x-name']] = path;
+ });
+
+ // Construct the methods for each API endpoint.
+ forEachOperation(function(operation, method, path) {
+ buildMethodsForOperation(operation, method, path, resourceMap);
+ });
+
+ apiService.getErrorMessage = function(resp, defaultMessage) {
+ var message = defaultMessage;
+ if (resp && resp['data']) {
+ //TODO: remove error_message and error_description (old style error)
+ message = resp['data']['detail'] || resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
+ }
+
+ return message;
+ };
+
+ apiService.errorDisplay = function(defaultMessage, opt_handler) {
+ return function(resp) {
+ var message = apiService.getErrorMessage(resp, defaultMessage);
+ if (opt_handler) {
+ var handlerMessage = opt_handler(resp);
+ if (handlerMessage) {
+ message = handlerMessage;
+ }
+ }
+
+ message = UtilService.stringToHTML(message);
+ bootbox.dialog({
+ "message": message,
+ "title": defaultMessage || 'Request Failure',
+ "buttons": {
+ "close": {
+ "label": "Close",
+ "className": "btn-primary"
+ }
+ }
+ });
+ };
+ };
+
+ // todo: remove hacks
+ apiService.scGetConfig = () => new Promise(() => { hello: true });
+ apiService.scRegistryStatus = () => new Promise(() => { hello: true });
+
+ return apiService;
+}]);
diff --git a/config_app/js/services/container-service.js b/config_app/js/services/container-service.js
new file mode 100644
index 000000000..c20cfc162
--- /dev/null
+++ b/config_app/js/services/container-service.js
@@ -0,0 +1,45 @@
+/**
+ * Helper service for working with the registry's container. Only works in enterprise.
+ */
+angular.module('quay-config')
+ .factory('ContainerService', ['ApiService', '$timeout', 'Restangular',
+ function(ApiService, $timeout, Restangular) {
+ var containerService = {};
+ containerService.restartContainer = function(callback) {
+ ApiService.scShutdownContainer(null, null).then(function(resp) {
+ $timeout(callback, 2000);
+ }, ApiService.errorDisplay('Cannot restart container. Please report this to support.'))
+ };
+
+ containerService.scheduleStatusCheck = function(callback, opt_config) {
+ $timeout(function() {
+ containerService.checkStatus(callback, opt_config);
+ }, 2000);
+ };
+
+ containerService.checkStatus = function(callback, opt_config) {
+ var errorHandler = function(resp) {
+ if (resp.status == 404 || resp.status == 502 || resp.status == -1) {
+ // Container has not yet come back up, so we schedule another check.
+ containerService.scheduleStatusCheck(callback, opt_config);
+ return;
+ }
+
+ return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp);
+ };
+
+ // If config is specified, override the API base URL from this point onward.
+ // TODO(jschorr): Find a better way than this. This is safe, since this will only be called
+ // for a restart, but it is still ugly.
+ if (opt_config && opt_config['SERVER_HOSTNAME']) {
+ var scheme = opt_config['PREFERRED_URL_SCHEME'] || 'http';
+ var baseUrl = scheme + '://' + opt_config['SERVER_HOSTNAME'] + '/api/v1/';
+ Restangular.setBaseUrl(baseUrl);
+ }
+
+ ApiService.scRegistryStatus(null, null, /* background */true)
+ .then(callback, errorHandler);
+ };
+
+ return containerService;
+ }]);
diff --git a/config_app/js/services/cookie-service.js b/config_app/js/services/cookie-service.js
new file mode 100644
index 000000000..af904124a
--- /dev/null
+++ b/config_app/js/services/cookie-service.js
@@ -0,0 +1,23 @@
+/**
+ * Helper service for working with cookies.
+ */
+angular.module('quay-config').factory('CookieService', ['$cookies', function($cookies) {
+ var cookieService = {};
+ cookieService.putPermanent = function(name, value) {
+ document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/";
+ };
+
+ cookieService.putSession = function(name, value) {
+ $cookies.put(name, value);
+ };
+
+ cookieService.clear = function(name) {
+ $cookies.remove(name);
+ };
+
+ cookieService.get = function(name) {
+ return $cookies.get(name);
+ };
+
+ return cookieService;
+}]);
diff --git a/config_app/js/services/features-config.js b/config_app/js/services/features-config.js
new file mode 100644
index 000000000..e655f32bf
--- /dev/null
+++ b/config_app/js/services/features-config.js
@@ -0,0 +1,91 @@
+/**
+ * Feature flags.
+ */
+angular.module('quay-config').factory('Features', [function() {
+ if (!window.__features) {
+ return {};
+ }
+
+ var features = window.__features;
+ features.getFeature = function(name, opt_defaultValue) {
+ var value = features[name];
+ if (value == null) {
+ return opt_defaultValue;
+ }
+ return value;
+ };
+
+ features.hasFeature = function(name) {
+ return !!features.getFeature(name);
+ };
+
+ features.matchesFeatures = function(list) {
+ for (var i = 0; i < list.length; ++i) {
+ var value = features.getFeature(list[i]);
+ if (!value) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ return features;
+}]);
+
+/**
+ * Application configuration.
+ */
+angular.module('quay-config').factory('Config', ['Features', function(Features) {
+ if (!window.__config) {
+ return {};
+ }
+
+ var config = window.__config;
+ config.getDomain = function() {
+ return config['SERVER_HOSTNAME'];
+ };
+
+ config.getHost = function(opt_auth) {
+ var auth = opt_auth;
+ if (auth) {
+ auth = auth + '@';
+ }
+
+ return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME'];
+ };
+
+ config.getHttp = function() {
+ return config['PREFERRED_URL_SCHEME'];
+ };
+
+ config.getUrl = function(opt_path) {
+ var path = opt_path || '';
+ return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path;
+ };
+
+ config.getValue = function(name, opt_defaultValue) {
+ var value = config[name];
+ if (value == null) {
+ return opt_defaultValue;
+ }
+ return value;
+ };
+
+ config.getEnterpriseLogo = function(opt_defaultValue) {
+ if (!config.ENTERPRISE_LOGO_URL) {
+ if (opt_defaultValue) {
+ return opt_defaultValue;
+ }
+
+ if (Features.BILLING) {
+ return '/static/img/quay-horizontal-color.svg';
+ } else {
+ return '/static/img/QuayEnterprise_horizontal_color.svg';
+ }
+ }
+
+ return config.ENTERPRISE_LOGO_URL;
+ };
+
+ return config;
+}]);
\ No newline at end of file
diff --git a/config_app/js/services/user-service.js b/config_app/js/services/user-service.js
new file mode 100644
index 000000000..918ad9adb
--- /dev/null
+++ b/config_app/js/services/user-service.js
@@ -0,0 +1,217 @@
+import * as Raven from 'raven-js';
+
+
+/**
+ * Service which monitors the current user session and provides methods for returning information
+ * about the user.
+ */
+angular.module('quay-config')
+ .factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location', '$timeout',
+
+function(ApiService, CookieService, $rootScope, Config, $location, $timeout) {
+ var userResponse = {
+ verified: false,
+ anonymous: true,
+ username: null,
+ email: null,
+ organizations: [],
+ logins: [],
+ beforeload: true
+ };
+
+ var userService = {};
+ var _EXTERNAL_SERVICES = ['ldap', 'jwtauthn', 'keystone', 'dex'];
+
+ userService.hasEverLoggedIn = function() {
+ return CookieService.get('quay.loggedin') == 'true';
+ };
+
+ userService.updateUserIn = function(scope, opt_callback) {
+ scope.$watch(function () { return userService.currentUser(); }, function (currentUser) {
+ if (currentUser) {
+ $timeout(function(){
+ scope.user = currentUser;
+ if (opt_callback) {
+ opt_callback(currentUser);
+ }
+ }, 0, false);
+ };
+ }, true);
+ };
+
+ userService.load = function(opt_callback) {
+ var handleUserResponse = function(loadedUser) {
+ userResponse = loadedUser;
+
+ if (!userResponse.anonymous) {
+ if (Config.MIXPANEL_KEY) {
+ try {
+ mixpanel.identify(userResponse.username);
+ mixpanel.people.set({
+ '$email': userResponse.email,
+ '$username': userResponse.username,
+ 'verified': userResponse.verified
+ });
+ mixpanel.people.set_once({
+ '$created': new Date()
+ })
+ } catch (e) {
+ window.console.log(e);
+ }
+ }
+
+ if (Config.MARKETO_MUNCHKIN_ID && userResponse['marketo_user_hash']) {
+ var associateLeadBody = {'Email': userResponse.email};
+ if (window.Munchkin !== undefined) {
+ try {
+ Munchkin.munchkinFunction(
+ 'associateLead',
+ associateLeadBody,
+ userResponse['marketo_user_hash']
+ );
+ } catch (e) {
+ }
+ } else {
+ window.__quay_munchkin_queue.push([
+ 'associateLead',
+ associateLeadBody,
+ userResponse['marketo_user_hash']
+ ]);
+ }
+ }
+
+ if (window.Raven !== undefined) {
+ try {
+ Raven.setUser({
+ email: userResponse.email,
+ id: userResponse.username
+ });
+ } catch (e) {
+ window.console.log(e);
+ }
+ }
+
+ CookieService.putPermanent('quay.loggedin', 'true');
+ } else {
+ if (window.Raven !== undefined) {
+ Raven.setUser();
+ }
+ }
+
+ // If the loaded user has a prompt, redirect them to the update page.
+ if (loadedUser.prompts && loadedUser.prompts.length) {
+ $location.path('/updateuser');
+ return;
+ }
+
+ if (opt_callback) {
+ opt_callback(loadedUser);
+ }
+ };
+
+ ApiService.getLoggedInUser().then(function(loadedUser) {
+ handleUserResponse(loadedUser);
+ }, function() {
+ handleUserResponse({'anonymous': true});
+ });
+ };
+
+ userService.isOrganization = function(name) {
+ return !!userService.getOrganization(name);
+ };
+
+ userService.getOrganization = function(name) {
+ if (!userResponse || !userResponse.organizations) { return null; }
+ for (var i = 0; i < userResponse.organizations.length; ++i) {
+ var org = userResponse.organizations[i];
+ if (org.name == name) {
+ return org;
+ }
+ }
+
+ return null;
+ };
+
+ userService.isNamespaceAdmin = function(namespace) {
+ if (namespace == userResponse.username) {
+ return true;
+ }
+
+ var org = userService.getOrganization(namespace);
+ if (!org) {
+ return false;
+ }
+
+ return org.is_org_admin;
+ };
+
+ userService.isKnownNamespace = function(namespace) {
+ if (namespace == userResponse.username) {
+ return true;
+ }
+
+ var org = userService.getOrganization(namespace);
+ return !!org;
+ };
+
+ userService.getNamespace = function(namespace) {
+ var org = userService.getOrganization(namespace);
+ if (org) {
+ return org;
+ }
+
+ if (namespace == userResponse.username) {
+ return userResponse;
+ }
+
+ return null;
+ };
+
+ userService.getCLIUsername = function() {
+ if (!userResponse) {
+ return null;
+ }
+
+ var externalUsername = null;
+ userResponse.logins.forEach(function(login) {
+ if (_EXTERNAL_SERVICES.indexOf(login.service) >= 0) {
+ externalUsername = login.service_identifier;
+ }
+ });
+
+ return externalUsername || userResponse.username;
+ };
+
+ userService.deleteNamespace = function(info, callback) {
+ var namespace = info.user ? info.user.username : info.organization.name;
+ if (!namespace) {
+ return;
+ }
+
+ var errorDisplay = ApiService.errorDisplay('Could not delete namespace', callback);
+ var cb = function(resp) {
+ userService.load(function(currentUser) {
+ callback(true);
+ $location.path('/');
+ });
+ }
+
+ if (info.user) {
+ ApiService.deleteCurrentUser().then(cb, errorDisplay)
+ } else {
+ var delParams = {
+ 'orgname': info.organization.name
+ };
+ ApiService.deleteAdminedOrganization(null, delParams).then(cb, errorDisplay);
+ }
+ };
+
+ userService.currentUser = function() {
+ return userResponse;
+ };
+
+ // Update the user in the root scope.
+ userService.updateUserIn($rootScope);
+
+ return userService;
+}]);
diff --git a/config_app/js/services/util-service.js b/config_app/js/services/util-service.js
new file mode 100644
index 000000000..34f0a4191
--- /dev/null
+++ b/config_app/js/services/util-service.js
@@ -0,0 +1,83 @@
+/**
+ * Service which exposes various utility methods.
+ */
+angular.module('quay-config').factory('UtilService', ['$sanitize',
+ function($sanitize) {
+ var utilService = {};
+
+ var adBlockEnabled = null;
+
+ utilService.isAdBlockEnabled = function(callback) {
+ if (adBlockEnabled !== null) {
+ callback(adBlockEnabled);
+ return;
+ }
+
+ if(typeof blockAdBlock === 'undefined') {
+ callback(true);
+ return;
+ }
+
+ var bab = new BlockAdBlock({
+ checkOnLoad: false,
+ resetOnEnd: true
+ });
+
+ bab.onDetected(function() { adBlockEnabled = true; callback(true); });
+ bab.onNotDetected(function() { adBlockEnabled = false; callback(false); });
+ bab.check();
+ };
+
+ utilService.isEmailAddress = function(val) {
+ var emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
+ return emailRegex.test(val);
+ };
+
+ utilService.escapeHtmlString = function(text) {
+ var textStr = (text || '').toString();
+ var adjusted = textStr.replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+ return adjusted;
+ };
+
+ utilService.stringToHTML = function(text) {
+ text = utilService.escapeHtmlString(text);
+ text = text.replace(/\n/g, ' ');
+ return text;
+ };
+
+ utilService.getRestUrl = function(args) {
+ var url = '';
+ for (var i = 0; i < arguments.length; ++i) {
+ if (i > 0) {
+ url += '/';
+ }
+ url += encodeURI(arguments[i])
+ }
+ return url;
+ };
+
+ utilService.textToSafeHtml = function(text) {
+ return $sanitize(utilService.escapeHtmlString(text));
+ };
+
+ return utilService;
+ }])
+ .factory('CoreDialog', [() => {
+ var service = {};
+ service['fatal'] = function(title, message) {
+ bootbox.dialog({
+ "title": title,
+ "message": "
" + message,
+ "buttons": {},
+ "className": "co-dialog fatal-error",
+ "closeButton": false
+ });
+ };
+
+ return service;
+ }]);
diff --git a/config_app/js/setup/setup.component.js b/config_app/js/setup/setup.component.js
new file mode 100644
index 000000000..3e828214c
--- /dev/null
+++ b/config_app/js/setup/setup.component.js
@@ -0,0 +1,332 @@
+import * as URI from 'urijs';
+const templateUrl = require('./setup.html');
+
+(function() {
+ /**
+ * The Setup page provides a nice GUI walkthrough experience for setting up Quay Enterprise.
+ */
+
+ angular.module('quay-config').directive('setup', () => {
+ const directiveDefinitionObject = {
+ priority: 1,
+ templateUrl,
+ replace: true,
+ transclude: true,
+ restrict: 'C',
+ scope: {
+ 'isActive': '=isActive',
+ 'configurationSaved': '&configurationSaved'
+ },
+ controller: SetupCtrl,
+ };
+
+ return directiveDefinitionObject;
+ })
+
+ function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) {
+ // if (!Features.SUPER_USERS) {
+ // return;
+ // }
+
+ $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9_\.\-]+(:[0-9]+)?$';
+
+ $scope.validateHostname = function(hostname) {
+ if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) {
+ return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.'
+ }
+
+ return null;
+ };
+
+ // Note: The values of the enumeration are important for isStepFamily. For example,
+ // *all* states under the "configuring db" family must start with "config-db".
+ $scope.States = {
+ // Loading the state of the product.
+ 'LOADING': 'loading',
+
+ // The configuration directory is missing.
+ 'MISSING_CONFIG_DIR': 'missing-config-dir',
+
+ // The config.yaml exists but it is invalid.
+ 'INVALID_CONFIG': 'config-invalid',
+
+ // DB is being configured.
+ 'CONFIG_DB': 'config-db',
+
+ // DB information is being validated.
+ 'VALIDATING_DB': 'config-db-validating',
+
+ // DB information is being saved to the config.
+ 'SAVING_DB': 'config-db-saving',
+
+ // A validation error occurred with the database.
+ 'DB_ERROR': 'config-db-error',
+
+ // Database is being setup.
+ 'DB_SETUP': 'setup-db',
+
+ // Database setup has succeeded.
+ 'DB_SETUP_SUCCESS': 'setup-db-success',
+
+ // An error occurred when setting up the database.
+ 'DB_SETUP_ERROR': 'setup-db-error',
+
+ // The container is being restarted for the database changes.
+ 'DB_RESTARTING': 'setup-db-restarting',
+
+ // A superuser is being configured.
+ 'CREATE_SUPERUSER': 'create-superuser',
+
+ // The superuser is being created.
+ 'CREATING_SUPERUSER': 'create-superuser-creating',
+
+ // An error occurred when setting up the superuser.
+ 'SUPERUSER_ERROR': 'create-superuser-error',
+
+ // The superuser was created successfully.
+ 'SUPERUSER_CREATED': 'create-superuser-created',
+
+ // General configuration is being setup.
+ 'CONFIG': 'config',
+
+ // The configuration is fully valid.
+ 'VALID_CONFIG': 'valid-config',
+
+ // The container is being restarted for the configuration changes.
+ 'CONFIG_RESTARTING': 'config-restarting',
+
+ // The product is ready for use.
+ 'READY': 'ready'
+ }
+
+ $scope.csrf_token = window.__token;
+ $scope.currentStep = $scope.States.LOADING;
+ $scope.errors = {};
+ $scope.stepProgress = [];
+ $scope.hasSSL = false;
+ $scope.hostname = null;
+ $scope.currentConfig = null;
+
+ $scope.currentState = {
+ 'hasDatabaseSSLCert': false
+ };
+
+ $scope.$watch('currentStep', function(currentStep) {
+ $scope.stepProgress = $scope.getProgress(currentStep);
+
+ switch (currentStep) {
+ case $scope.States.CONFIG:
+ $('#setupModal').modal('hide');
+ break;
+
+ case $scope.States.MISSING_CONFIG_DIR:
+ $scope.showMissingConfigDialog();
+ break;
+
+ case $scope.States.INVALID_CONFIG:
+ $scope.showInvalidConfigDialog();
+ break;
+
+ case $scope.States.DB_SETUP:
+ $scope.performDatabaseSetup();
+ // Fall-through.
+
+ case $scope.States.CREATE_SUPERUSER:
+ case $scope.States.DB_RESTARTING:
+ case $scope.States.CONFIG_DB:
+ case $scope.States.VALID_CONFIG:
+ case $scope.States.READY:
+ $('#setupModal').modal({
+ keyboard: false,
+ backdrop: 'static'
+ });
+ break;
+ }
+ });
+
+ $scope.restartContainer = function(state) {
+ $scope.currentStep = state;
+ ContainerService.restartContainer(function() {
+ $scope.checkStatus()
+ });
+ };
+
+ $scope.showSuperuserPanel = function() {
+ $('#setupModal').modal('hide');
+ var prefix = $scope.hasSSL ? 'https' : 'http';
+ var hostname = $scope.hostname;
+ if (!hostname) {
+ hostname = document.location.hostname;
+ if (document.location.port) {
+ hostname = hostname + ':' + document.location.port;
+ }
+ }
+
+ window.location = prefix + '://' + hostname + '/superuser';
+ };
+
+ $scope.configurationSaved = function(config) {
+ $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https';
+ $scope.hostname = config['SERVER_HOSTNAME'];
+ $scope.currentConfig = config;
+
+ $scope.currentStep = $scope.States.VALID_CONFIG;
+ };
+
+ $scope.getProgress = function(step) {
+ var isStep = $scope.isStep;
+ var isStepFamily = $scope.isStepFamily;
+ var States = $scope.States;
+
+ return [
+ isStepFamily(step, States.CONFIG_DB),
+ isStepFamily(step, States.DB_SETUP),
+ isStep(step, States.DB_RESTARTING),
+ isStepFamily(step, States.CREATE_SUPERUSER),
+ isStep(step, States.CONFIG),
+ isStep(step, States.VALID_CONFIG),
+ isStep(step, States.CONFIG_RESTARTING),
+ isStep(step, States.READY)
+ ];
+ };
+
+ $scope.isStepFamily = function(step, family) {
+ if (!step) { return false; }
+ return step.indexOf(family) == 0;
+ };
+
+ $scope.isStep = function(step) {
+ for (var i = 1; i < arguments.length; ++i) {
+ if (arguments[i] == step) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ $scope.beginSetup = function() {
+ $scope.currentStep = $scope.States.CONFIG_DB;
+ };
+
+ $scope.showInvalidConfigDialog = function() {
+ var message = "The config.yaml file found in conf/stack could not be parsed."
+ var title = "Invalid configuration file";
+ CoreDialog.fatal(title, message);
+ };
+
+
+ $scope.showMissingConfigDialog = function() {
+ var message = "A volume should be mounted into the container at /conf/stack: " +
+ "
docker run -v /path/to/config:/conf/stack
" +
+ " Once fixed, restart the container. For more information, " +
+ "" +
+ "Read the Setup Guide"
+
+ var title = "Missing configuration volume";
+ CoreDialog.fatal(title, message);
+ };
+
+ $scope.parseDbUri = function(value) {
+ if (!value) { return null; }
+
+ // Format: mysql+pymysql://:@/
+ var uri = URI(value);
+ return {
+ 'kind': uri.protocol(),
+ 'username': uri.username(),
+ 'password': uri.password(),
+ 'server': uri.host(),
+ 'database': uri.path() ? uri.path().substr(1) : ''
+ };
+ };
+
+ $scope.serializeDbUri = function(fields) {
+ if (!fields['server']) { return ''; }
+ if (!fields['database']) { return ''; }
+
+ var uri = URI();
+ try {
+ uri = uri && uri.host(fields['server']);
+ uri = uri && uri.protocol(fields['kind']);
+ uri = uri && uri.username(fields['username']);
+ uri = uri && uri.password(fields['password']);
+ uri = uri && uri.path('/' + (fields['database'] || ''));
+ uri = uri && uri.toString();
+ } catch (ex) {
+ return '';
+ }
+
+ return uri;
+ };
+
+ $scope.createSuperUser = function() {
+ $scope.currentStep = $scope.States.CREATING_SUPERUSER;
+ ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) {
+ UserService.load();
+ $scope.checkStatus();
+ }, function(resp) {
+ $scope.currentStep = $scope.States.SUPERUSER_ERROR;
+ $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser');
+ });
+ };
+
+ $scope.performDatabaseSetup = function() {
+ $scope.currentStep = $scope.States.DB_SETUP;
+ ApiService.scSetupDatabase(null, null).then(function(resp) {
+ if (resp['error']) {
+ $scope.currentStep = $scope.States.DB_SETUP_ERROR;
+ $scope.errors.DatabaseSetupError = resp['error'];
+ } else {
+ $scope.currentStep = $scope.States.DB_SETUP_SUCCESS;
+ }
+ }, ApiService.errorDisplay('Could not setup database. Please report this to support.'))
+ };
+
+ $scope.validateDatabase = function() {
+ $scope.currentStep = $scope.States.VALIDATING_DB;
+ $scope.databaseInvalid = null;
+
+ var data = {
+ 'config': {
+ 'DB_URI': $scope.databaseUri
+ },
+ 'hostname': window.location.host
+ };
+
+ if ($scope.currentState.hasDatabaseSSLCert) {
+ data['config']['DB_CONNECTION_ARGS'] = {
+ 'ssl': {
+ 'ca': 'conf/stack/database.pem'
+ }
+ };
+ }
+
+ var params = {
+ 'service': 'database'
+ };
+
+ ApiService.scValidateConfig(data, params).then(function(resp) {
+ var status = resp.status;
+
+ if (status) {
+ $scope.currentStep = $scope.States.SAVING_DB;
+ ApiService.scUpdateConfig(data, null).then(function(resp) {
+ $scope.checkStatus();
+ }, ApiService.errorDisplay('Cannot update config. Please report this to support'));
+ } else {
+ $scope.currentStep = $scope.States.DB_ERROR;
+ $scope.errors.DatabaseValidationError = resp.reason;
+ }
+ }, ApiService.errorDisplay('Cannot validate database. Please report this to support'));
+ };
+
+ $scope.checkStatus = function() {
+ ContainerService.checkStatus(function(resp) {
+ $scope.currentStep = resp['status'];
+ }, $scope.currentConfig);
+ };
+
+ // Load the initial status.
+ $scope.checkStatus();
+ };
+})();
diff --git a/config_app/js/setup/setup.html b/config_app/js/setup/setup.html
new file mode 100644
index 000000000..b40e1d78d
--- /dev/null
+++ b/config_app/js/setup/setup.html
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+ Quay Enterprise Setup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Almost done!
+
Configure your Redis database and other settings below
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Setup
+
+
+
+
+
+
+
+
+ is currently being restarted
+
+ This can take several minutes. If the container does not restart on its own,
+ please re-execute the docker run command.
+
+
+
+
+
Installation and setup of is complete
+ You can now invite users to join, create organizations and start pushing and pulling
+ repositories.
+
+
+ Note: SSL is enabled. Please make sure to visit with
+ an https prefix
+
+
+
+
+
+
All configuration has been validated and saved
+ The container must be restarted to apply the configuration changes.
+
+
+
+
+
The database has been setup and is ready
+ The container must be restarted to apply the configuration changes.
+
+
+
+
+
+
+ is currently setting up its database
+ schema
+
+ This can take several minutes.
+
+
+
+
+
+ Please enter the connection details for your empty database. The schema will be created in the following step.
+
+
+
+
+
+
Database Type:
+
+
+
+
+
+
Database Server:
+
+ >
+
+ The server (and optionally, custom port) where the database lives
+
+
+
+
+
Username:
+
+
+
This user must have full access to the database
+
+
+
+
Password:
+
+
+
+
+
+
Database Name:
+
+
+
+
+
+
SSL Certificate:
+
+
+
Optional SSL certicate (in PEM format) to use to connect to the database