From cc9bedbeb91eff638f1c5b21e35a0de859309404 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Mon, 13 Aug 2018 11:40:50 -0400 Subject: [PATCH 1/3] refactor approval service key to not need approver --- auth/test/test_registry_jwt.py | 2 +- config_app/config_endpoints/api/superuser.py | 46 +++++- .../api/superuser_models_pre_oci.py | 16 ++ .../kube-deploy-modal.component.ts | 1 + .../request-service-key-dialog.html | 137 ++++++++++++++++++ .../request-service-key-dialog.js | 124 ++++++++++++++++ data/model/service_keys.py | 2 +- endpoints/api/superuser_models_pre_oci.py | 2 +- initdb.py | 5 +- test/test_endpoints.py | 6 +- util/generatepresharedkey.py | 3 +- 11 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 config_app/js/components/request-service-key-dialog/request-service-key-dialog.html create mode 100644 config_app/js/components/request-service-key-dialog/request-service-key-dialog.js diff --git a/auth/test/test_registry_jwt.py b/auth/test/test_registry_jwt.py index 78ac029bc..944c12dc5 100644 --- a/auth/test/test_registry_jwt.py +++ b/auth/test/test_registry_jwt.py @@ -155,7 +155,7 @@ def test_mixing_keys_e2e(initialized_db): # Approve the key and try again. admin_user = model.user.get_user('devtable') - model.service_keys.approve_service_key(key.kid, admin_user, ServiceKeyApprovalType.SUPERUSER) + model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER, approver=admin_user) valid_token = _token(token_data, key_id='newkey', private_key=private_key) diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index d35ccca55..be20d484b 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -3,15 +3,17 @@ import pathvalidate import os import subprocess -from flask import request, jsonify +from flask import request, jsonify, make_response +from data.database import ServiceKeyApprovalType +from data.model import ServiceKeyDoesNotExist from util.config.validator import EXTRA_CA_DIRECTORY from config_app.config_endpoints.exception import InvalidRequest from config_app.config_endpoints.api import resource, ApiResource, nickname from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model from config_app.config_util.ssl import load_certificate, CertInvalidException -from config_app.c_app import config_provider, INIT_SCRIPTS_LOCATION +from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION logger = logging.getLogger(__name__) @@ -146,4 +148,44 @@ class SuperUserServiceKeyManagement(ApiResource): 'keys': [key.to_dict() for key in keys], }) +@resource('/v1/superuser/approvedkeys/') +class SuperUserServiceKeyApproval(ApiResource): + """ Resource for approving service keys. """ + schemas = { + 'ApproveServiceKey': { + 'id': 'ApproveServiceKey', + 'type': 'object', + 'description': 'Information for approving service keys', + 'properties': { + 'notes': { + 'type': 'string', + 'description': 'Optional approval notes', + }, + }, + }, + } + + @nickname('approveServiceKey') + @validate_json_request('ApproveServiceKey') + def post(self, kid): + notes = request.get_json().get('notes', '') + approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool + try: + key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) + + # Log the approval of the service key. + key_log_metadata = { + 'kid': kid, + 'service': key.service, + 'name': key.name, + 'expiration_date': key.expiration_date, + } + + log_action('service_key_approve', None, key_log_metadata) + except ServiceKeyDoesNotExist: + raise NotFound() + except ServiceKeyAlreadyApproved: + pass + + return make_response('', 201) diff --git a/config_app/config_endpoints/api/superuser_models_pre_oci.py b/config_app/config_endpoints/api/superuser_models_pre_oci.py index 3002d5686..22cdbf821 100644 --- a/config_app/config_endpoints/api/superuser_models_pre_oci.py +++ b/config_app/config_endpoints/api/superuser_models_pre_oci.py @@ -17,6 +17,13 @@ def _create_key(key): return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, key.rotation_duration, approval) +class ServiceKeyDoesNotExist(Exception): + pass + + +class ServiceKeyAlreadyApproved(Exception): + pass + class PreOCIModel(SuperuserDataInterface): """ @@ -27,5 +34,14 @@ class PreOCIModel(SuperuserDataInterface): keys = model.service_keys.list_all_keys() return [_create_key(key) for key in keys] + def approve_service_key(self, kid, approval_type, notes=''): + try: + key = model.service_keys.approve_service_key(kid, approval_type, notes=notes) + return _create_key(key) + except model.ServiceKeyDoesNotExist: + raise ServiceKeyDoesNotExist + except model.ServiceKeyAlreadyApproved: + raise ServiceKeyAlreadyApproved + pre_oci_model = PreOCIModel() diff --git a/config_app/js/components/kube-deploy-modal/kube-deploy-modal.component.ts b/config_app/js/components/kube-deploy-modal/kube-deploy-modal.component.ts index d55e87d03..47eec013b 100644 --- a/config_app/js/components/kube-deploy-modal/kube-deploy-modal.component.ts +++ b/config_app/js/components/kube-deploy-modal/kube-deploy-modal.component.ts @@ -46,6 +46,7 @@ export class KubeDeployModalComponent { this.errorMessage = `Could cycle the deployments with the new configuration. Error: ${err.toString()}`; }) }).catch(err => { + console.log(err) this.state = 'error'; this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`; }) diff --git a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.html b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.html new file mode 100644 index 000000000..27ba1ba13 --- /dev/null +++ b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.html @@ -0,0 +1,137 @@ +
+ + +
+ \ No newline at end of file diff --git a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js new file mode 100644 index 000000000..bd3351567 --- /dev/null +++ b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js @@ -0,0 +1,124 @@ +/** + * An element which displays a dialog for requesting or creating a service key. + */ +angular.module('quay').directive('requestServiceKeyDialog', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/request-service-key-dialog.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'requestKeyInfo': '=requestKeyInfo', + 'keyCreated': '&keyCreated' + }, + controller: function($scope, $element, $timeout, ApiService) { + var handleNewKey = function(key) { + var data = { + 'notes': 'Approved during setup of service ' + key.service + }; + + var params = { + 'kid': key.kid + }; + + ApiService.approveServiceKey(data, params).then(function(resp) { + $scope.keyCreated({'key': key}); + $scope.step = 2; + }, ApiService.errorDisplay('Could not approve service key')); + }; + + var checkKeys = function() { + var isShown = ($element.find('.modal').data('bs.modal') || {}).isShown; + if (!isShown) { + return; + } + + // TODO: filter by service. + ApiService.listServiceKeys().then(function(resp) { + var keys = resp['keys']; + for (var i = 0; i < keys.length; ++i) { + var key = keys[i]; + if (key.service == $scope.requestKeyInfo.service && !key.approval && key.rotation_duration) { + handleNewKey(key); + return; + } + } + + $timeout(checkKeys, 1000); + }, ApiService.errorDisplay('Could not list service keys')); + }; + + $scope.show = function() { + $scope.working = false; + $scope.step = 0; + $scope.requestKind = null; + $scope.preshared = { + 'name': $scope.requestKeyInfo.service + ' Service Key', + 'notes': 'Created during setup for service `' + $scope.requestKeyInfo.service + '`' + }; + + $element.find('.modal').modal({}); + }; + + $scope.hide = function() { + $scope.loading = false; + $element.find('.modal').modal('hide'); + }; + + $scope.showGenerate = function() { + $scope.step = 1; + }; + + $scope.startApproval = function() { + $scope.step = 1; + checkKeys(); + }; + + $scope.isDownloadSupported = function() { + var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent); + if (isSafari) { + // Doesn't work properly in Safari, sadly. + return false; + } + + try { return !!new Blob(); } catch(e) {} + return false; + }; + + $scope.downloadPrivateKey = function(key) { + var blob = new Blob([key.private_key]); + FileSaver.saveAs(blob, key.service + '.pem'); + }; + + $scope.createPresharedKey = function() { + $scope.working = true; + + var data = { + 'name': $scope.preshared.name, + 'service': $scope.requestKeyInfo.service, + 'expiration': $scope.preshared.expiration || null, + 'notes': $scope.preshared.notes + }; + + ApiService.createServiceKey(data).then(function(resp) { + $scope.working = false; + $scope.step = 2; + $scope.createdKey = resp; + $scope.keyCreated({'key': resp}); + }, ApiService.errorDisplay('Could not create service key')); + }; + + $scope.updateNotes = function(content) { + $scope.preshared.notes = content; + }; + + $scope.$watch('requestKeyInfo', function(info) { + if (info && info.service) { + $scope.show(); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/data/model/service_keys.py b/data/model/service_keys.py index faeb68269..eb460299b 100644 --- a/data/model/service_keys.py +++ b/data/model/service_keys.py @@ -145,7 +145,7 @@ def set_key_expiration(kid, expiration_date): service_key.save() -def approve_service_key(kid, approver, approval_type, notes=''): +def approve_service_key(kid, approval_type, approver=None, notes=''): try: with db_transaction(): key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get() diff --git a/endpoints/api/superuser_models_pre_oci.py b/endpoints/api/superuser_models_pre_oci.py index daa97f83f..27a62b0c4 100644 --- a/endpoints/api/superuser_models_pre_oci.py +++ b/endpoints/api/superuser_models_pre_oci.py @@ -118,7 +118,7 @@ class PreOCIModel(SuperuserDataInterface): def approve_service_key(self, kid, approver, approval_type, notes=''): try: - key = model.service_keys.approve_service_key(kid, approver, approval_type, notes=notes) + key = model.service_keys.approve_service_key(kid, approval_type, approver=approver, notes=notes) return _create_key(key) except model.ServiceKeyDoesNotExist: raise ServiceKeyDoesNotExist diff --git a/initdb.py b/initdb.py index 047ae1356..988b52cc6 100644 --- a/initdb.py +++ b/initdb.py @@ -161,8 +161,7 @@ def __generate_service_key(kid, name, user, timestamp, approval_type, expiration rotation_duration=rotation_duration) if approval_type is not None: - model.service_keys.approve_service_key(key.kid, user, approval_type, - notes='The **test** approval') + model.service_keys.approve_service_key(key.kid, approval_type, notes='The **test** approval') key_metadata = { 'kid': kid, @@ -820,7 +819,7 @@ def populate_database(minimal=False, with_storage=False): key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay', _TEST_JWK, {}, None) - model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER, + model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER, notes='Test service key for local/test registry testing') # Add an app specific token. diff --git a/test/test_endpoints.py b/test/test_endpoints.py index 8eb02734e..9140e3e5f 100644 --- a/test/test_endpoints.py +++ b/test/test_endpoints.py @@ -559,7 +559,7 @@ class KeyServerTestCase(EndpointTestCase): }, data=jwk, expected_code=403) # Approve the key. - model.service_keys.approve_service_key('kid420', 1, ServiceKeyApprovalType.SUPERUSER) + model.service_keys.approve_service_key('kid420', ServiceKeyApprovalType.SUPERUSER, approver=1) # Rotate that new key with assert_action_logged('service_key_rotate'): @@ -598,7 +598,7 @@ class KeyServerTestCase(EndpointTestCase): def test_attempt_delete_service_key_with_expired_key(self): # Generate two keys, approving the first. private_key, _ = model.service_keys.generate_service_key('sample_service', None, kid='first') - model.service_keys.approve_service_key('first', 1, ServiceKeyApprovalType.SUPERUSER) + model.service_keys.approve_service_key('first', ServiceKeyApprovalType.SUPERUSER, approver=1) model.service_keys.generate_service_key('sample_service', None, kid='second') # Mint a JWT with our test payload @@ -661,7 +661,7 @@ class KeyServerTestCase(EndpointTestCase): expected_code=403, service='sample_service', kid='kid321') # Approve the second key. - model.service_keys.approve_service_key('kid123', 1, ServiceKeyApprovalType.SUPERUSER) + model.service_keys.approve_service_key('kid123', ServiceKeyApprovalType.SUPERUSER, approver=1) # Using the credentials of our approved key, delete our unapproved key with assert_action_logged('service_key_delete'): diff --git a/util/generatepresharedkey.py b/util/generatepresharedkey.py index 99b5eff22..27e17128a 100644 --- a/util/generatepresharedkey.py +++ b/util/generatepresharedkey.py @@ -18,8 +18,7 @@ def generate_key(service, name, expiration_date=None, notes=None): metadata=metadata, name=name) # Auto-approve the service key. - model.service_keys.approve_service_key(key.kid, None, ServiceKeyApprovalType.AUTOMATIC, - notes=notes or '') + model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.AUTOMATIC, notes=notes or '') # Log the creation and auto-approval of the service key. key_log_metadata = { From 0bc22d810acdc2f0f4c60f8a4b156eae9ac5a420 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Mon, 13 Aug 2018 13:58:59 -0400 Subject: [PATCH 2/3] Add components for generating sec keys --- config_app/config_endpoints/api/__init__.py | 9 + config_app/config_endpoints/api/superuser.py | 9 +- .../datetime-picker/datetime-picker.html | 3 + .../datetime-picker/datetime-picker.js | 60 ++++++ .../highlighted-languages.constant.json | 8 + .../markdown/markdown-editor.component.css | 24 +++ .../markdown/markdown-editor.component.html | 43 ++++ .../markdown-editor.component.spec.ts | 188 ++++++++++++++++++ .../markdown/markdown-editor.component.ts | 147 ++++++++++++++ .../markdown/markdown-input.component.css | 14 ++ .../markdown/markdown-input.component.html | 29 +++ .../markdown/markdown-input.component.spec.ts | 34 ++++ .../markdown/markdown-input.component.ts | 34 ++++ .../markdown/markdown-toolbar.component.css | 8 + .../markdown/markdown-toolbar.component.html | 61 ++++++ .../markdown-toolbar.component.spec.ts | 11 + .../markdown/markdown-toolbar.component.ts | 17 ++ .../markdown/markdown-view.component.css | 12 ++ .../markdown/markdown-view.component.html | 2 + .../markdown/markdown-view.component.spec.ts | 79 ++++++++ .../markdown/markdown-view.component.ts | 48 +++++ .../js/components/markdown/markdown.module.ts | 97 +++++++++ .../request-service-key-dialog.js | 5 +- config_app/js/config-app.module.ts | 18 +- config_app/templates/index.html | 3 + 25 files changed, 955 insertions(+), 8 deletions(-) create mode 100644 config_app/js/components/datetime-picker/datetime-picker.html create mode 100644 config_app/js/components/datetime-picker/datetime-picker.js create mode 100644 config_app/js/components/markdown/highlighted-languages.constant.json create mode 100644 config_app/js/components/markdown/markdown-editor.component.css create mode 100644 config_app/js/components/markdown/markdown-editor.component.html create mode 100644 config_app/js/components/markdown/markdown-editor.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-editor.component.ts create mode 100644 config_app/js/components/markdown/markdown-input.component.css create mode 100644 config_app/js/components/markdown/markdown-input.component.html create mode 100644 config_app/js/components/markdown/markdown-input.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-input.component.ts create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.css create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.html create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-toolbar.component.ts create mode 100644 config_app/js/components/markdown/markdown-view.component.css create mode 100644 config_app/js/components/markdown/markdown-view.component.html create mode 100644 config_app/js/components/markdown/markdown-view.component.spec.ts create mode 100644 config_app/js/components/markdown/markdown-view.component.ts create mode 100644 config_app/js/components/markdown/markdown.module.ts diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py index 118b68802..25389a1c0 100644 --- a/config_app/config_endpoints/api/__init__.py +++ b/config_app/config_endpoints/api/__init__.py @@ -3,6 +3,7 @@ import logging from flask import Blueprint, request, abort from flask_restful import Resource, Api from flask_restful.utils.cors import crossdomain +from data import model from email.utils import formatdate from calendar import timegm from functools import partial, wraps @@ -29,6 +30,14 @@ api = ApiExceptionHandlingApi() api.init_app(api_bp) +def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None): + if not metadata: + metadata = {} + + if repo: + repo_name = repo.name + + model.log.log_action(kind, user_or_orgname, repo_name, user_or_orgname, request.remote_addr, metadata) def format_date(date): """ Output an RFC822 date format. """ diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index be20d484b..fd82ec6ee 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -5,12 +5,13 @@ import subprocess from flask import request, jsonify, make_response +from endpoints.exception import NotFound from data.database import ServiceKeyApprovalType from data.model import ServiceKeyDoesNotExist from util.config.validator import EXTRA_CA_DIRECTORY from config_app.config_endpoints.exception import InvalidRequest -from config_app.config_endpoints.api import resource, ApiResource, nickname +from config_app.config_endpoints.api import resource, ApiResource, nickname, log_action, validate_json_request from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model from config_app.config_util.ssl import load_certificate, CertInvalidException from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION @@ -170,7 +171,6 @@ class SuperUserServiceKeyApproval(ApiResource): @validate_json_request('ApproveServiceKey') def post(self, kid): notes = request.get_json().get('notes', '') - approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool try: key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) @@ -182,7 +182,10 @@ class SuperUserServiceKeyApproval(ApiResource): 'expiration_date': key.expiration_date, } - log_action('service_key_approve', None, key_log_metadata) + # Note: this may not actually be the current person modifying the config, but if they're in the config tool, + # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine + super_user = app.config.get('SUPER_USERS', [None])[0] + log_action('service_key_approve', super_user, key_log_metadata) except ServiceKeyDoesNotExist: raise NotFound() except ServiceKeyAlreadyApproved: diff --git a/config_app/js/components/datetime-picker/datetime-picker.html b/config_app/js/components/datetime-picker/datetime-picker.html new file mode 100644 index 000000000..653c3869a --- /dev/null +++ b/config_app/js/components/datetime-picker/datetime-picker.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/config_app/js/components/datetime-picker/datetime-picker.js b/config_app/js/components/datetime-picker/datetime-picker.js new file mode 100644 index 000000000..cb1463e57 --- /dev/null +++ b/config_app/js/components/datetime-picker/datetime-picker.js @@ -0,0 +1,60 @@ +const templateUrl = require('./datetime-picker.html'); +/** + * An element which displays a datetime picker. + */ +angular.module('quay-config').directive('datetimePicker', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'datetime': '=datetime', + }, + controller: function($scope, $element) { + var datetimeSet = false; + + $(function() { + $element.find('input').datetimepicker({ + 'format': 'LLL', + 'sideBySide': true, + 'showClear': true, + 'minDate': new Date(), + 'debug': false + }); + + $element.find('input').on("dp.change", function (e) { + $scope.$apply(function() { + $scope.datetime = e.date ? e.date.unix() : null; + }); + }); + }); + + $scope.$watch('selected_datetime', function(value) { + if (!datetimeSet) { return; } + + if (!value) { + if ($scope.datetime) { + $scope.datetime = null; + } + return; + } + + $scope.datetime = (new Date(value)).getTime()/1000; + }); + + $scope.$watch('datetime', function(value) { + if (!value) { + $scope.selected_datetime = null; + datetimeSet = true; + return; + } + + $scope.selected_datetime = moment.unix(value).format('LLL'); + datetimeSet = true; + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/config_app/js/components/markdown/highlighted-languages.constant.json b/config_app/js/components/markdown/highlighted-languages.constant.json new file mode 100644 index 000000000..56ee3c2cf --- /dev/null +++ b/config_app/js/components/markdown/highlighted-languages.constant.json @@ -0,0 +1,8 @@ +[ + "javascript", + "python", + "bash", + "nginx", + "xml", + "shell" +] diff --git a/config_app/js/components/markdown/markdown-editor.component.css b/config_app/js/components/markdown/markdown-editor.component.css new file mode 100644 index 000000000..dcf129171 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.css @@ -0,0 +1,24 @@ +.markdown-editor-element textarea { + height: 300px; + resize: vertical; + width: 100%; +} + +.markdown-editor-actions { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + +.markdown-editor-buttons { + display: flex; + justify-content: space-between; +} + +.markdown-editor-buttons button { + margin: 0 10px; +} + +.markdown-editor-buttons button:last-child { + margin: 0; +} diff --git a/config_app/js/components/markdown/markdown-editor.component.html b/config_app/js/components/markdown/markdown-editor.component.html new file mode 100644 index 000000000..abb632938 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.html @@ -0,0 +1,43 @@ +
+ + + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
diff --git a/config_app/js/components/markdown/markdown-editor.component.spec.ts b/config_app/js/components/markdown/markdown-editor.component.spec.ts new file mode 100644 index 000000000..a1a9f32d3 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.spec.ts @@ -0,0 +1,188 @@ +import { MarkdownEditorComponent, EditMode } from './markdown-editor.component'; +import { MarkdownSymbol } from '../../../types/common.types'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownEditorComponent", () => { + var component: MarkdownEditorComponent; + var textarea: Mock; + var documentMock: Mock; + var $windowMock: Mock; + + beforeEach(() => { + textarea = new Mock(); + documentMock = new Mock(); + $windowMock = new Mock(); + const $documentMock: any = [documentMock.Object]; + component = new MarkdownEditorComponent($documentMock, $windowMock.Object, 'chrome'); + component.textarea = textarea.Object; + }); + + describe("onBeforeUnload", () => { + + it("returns false to alert user about losing current changes", () => { + component.changeEditMode("write"); + const allow: boolean = component.onBeforeUnload(); + + expect(allow).toBe(false); + }); + }); + + describe("ngOnDestroy", () => { + + it("removes 'beforeunload' event listener", () => { + $windowMock.setup(mock => mock.onbeforeunload).is(() => 1); + component.ngOnDestroy(); + + expect($windowMock.Object.onbeforeunload.call(this)).toEqual(null); + }); + }); + + describe("changeEditMode", () => { + + it("sets component's edit mode to given mode", () => { + const editMode: EditMode = "preview"; + component.changeEditMode(editMode); + + expect(component.currentEditMode).toEqual(editMode); + }); + }); + + describe("insertSymbol", () => { + var event: {symbol: MarkdownSymbol}; + var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[]; + var innerText: string; + + beforeEach(() => { + event = {symbol: 'heading1'}; + innerText = "Here is some text"; + markdownSymbols = [ + {type: 'heading1', characters: '# ', shiftBy: 2}, + {type: 'heading2', characters: '## ', shiftBy: 3}, + {type: 'heading3', characters: '### ', shiftBy: 4}, + {type: 'bold', characters: '****', shiftBy: 2}, + {type: 'italics', characters: '__', shiftBy: 1}, + {type: 'bulleted-list', characters: '- ', shiftBy: 2}, + {type: 'numbered-list', characters: '1. ', shiftBy: 3}, + {type: 'quote', characters: '> ', shiftBy: 2}, + {type: 'link', characters: '[](url)', shiftBy: 1}, + {type: 'code', characters: '``', shiftBy: 1}, + ]; + + textarea.setup(mock => mock.focus); + textarea.setup(mock => mock.substr).is((start, end) => ''); + textarea.setup(mock => mock.val).is((value?) => innerText); + textarea.setup(mock => mock.prop).is((prop) => { + switch (prop) { + case "selectionStart": + return 0; + case "selectionEnd": + return 0; + } + }); + documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false); + }); + + it("focuses on markdown textarea", () => { + component.insertSymbol(event); + + expect(textarea.Object.focus).toHaveBeenCalled(); + }); + + it("inserts correct characters for given symbol at cursor position", () => { + markdownSymbols.forEach((symbol) => { + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText'); + expect((documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false); + expect((documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters); + + (documentMock.Object.execCommand).calls.reset(); + }); + }); + + it("splices highlighted selection between inserted characters instead of deleting them", () => { + markdownSymbols.slice(0, 1).forEach((symbol) => { + textarea.setup(mock => mock.prop).is((prop) => { + switch (prop) { + case "selectionStart": + return 0; + case "selectionEnd": + return innerText.length; + } + }); + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText'); + expect((documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false); + expect((documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`); + + (documentMock.Object.execCommand).calls.reset(); + }); + }); + + it("moves cursor to correct position for given symbol", () => { + markdownSymbols.forEach((symbol) => { + event.symbol = symbol.type; + component.insertSymbol(event); + + expect((textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart'); + expect((textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy); + expect((textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd'); + expect((textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy); + + (textarea.Object.prop).calls.reset(); + }); + }); + }); + + describe("saveChanges", () => { + + beforeEach(() => { + component.content = "# Some markdown content"; + }); + + it("emits output event with changed content", (done) => { + component.save.subscribe((event: {editedContent: string}) => { + expect(event.editedContent).toEqual(component.content); + done(); + }); + + component.saveChanges(); + }); + }); + + describe("discardChanges", () => { + + it("prompts user to confirm discarding changes", () => { + const confirmSpy: Spy = $windowMock.setup(mock => mock.confirm).is((message) => false).Spy; + component.discardChanges(); + + expect(confirmSpy.calls.argsFor(0)[0]).toEqual(`Are you sure you want to discard your changes?`); + }); + + it("emits output event with no content if user confirms discarding changes", (done) => { + $windowMock.setup(mock => mock.confirm).is((message) => true); + component.discard.subscribe((event: {}) => { + expect(event).toEqual({}); + done(); + }); + + component.discardChanges(); + }); + + it("does not emit output event if user declines confirmation of discarding changes", (done) => { + $windowMock.setup(mock => mock.confirm).is((message) => false); + component.discard.subscribe((event: {}) => { + fail(`Should not emit output event`); + done(); + }); + + component.discardChanges(); + done(); + }); + }); +}); diff --git a/config_app/js/components/markdown/markdown-editor.component.ts b/config_app/js/components/markdown/markdown-editor.component.ts new file mode 100644 index 000000000..4eb161bd4 --- /dev/null +++ b/config_app/js/components/markdown/markdown-editor.component.ts @@ -0,0 +1,147 @@ +import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener, OnDestroy } from 'ng-metadata/core'; +import { MarkdownSymbol, BrowserPlatform } from './markdown.module'; +import './markdown-editor.component.css'; + + +/** + * An editing interface for Markdown content. + */ +@Component({ + selector: 'markdown-editor', + templateUrl: require('./markdown-editor.component.html'), +}) +export class MarkdownEditorComponent implements OnDestroy { + + @Input('<') public content: string; + + @Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter(); + @Output() public discard: EventEmitter = new EventEmitter(); + + // Textarea is public for testability, should not be directly accessed + @ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery; + + private editMode: EditMode = "write"; + + constructor(@Inject('$document') private $document: ng.IDocumentService, + @Inject('$window') private $window: ng.IWindowService, + @Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) { + this.$window.onbeforeunload = this.onBeforeUnload.bind(this); + } + + @HostListener('window:beforeunload', []) + public onBeforeUnload(): boolean { + return false; + } + + public ngOnDestroy(): void { + this.$window.onbeforeunload = () => null; + } + + public changeEditMode(newMode: EditMode): void { + this.editMode = newMode; + } + + public insertSymbol(event: {symbol: MarkdownSymbol}): void { + this.textarea.focus(); + + const startPos: number = this.textarea.prop('selectionStart'); + const endPos: number = this.textarea.prop('selectionEnd'); + const innerText: string = this.textarea.val().slice(startPos, endPos); + var shiftBy: number = 0; + var characters: string = ''; + + switch (event.symbol) { + case 'heading1': + characters = '# '; + shiftBy = 2; + break; + case 'heading2': + characters = '## '; + shiftBy = 3; + break; + case 'heading3': + characters = '### '; + shiftBy = 4; + break; + case 'bold': + characters = '****'; + shiftBy = 2; + break; + case 'italics': + characters = '__'; + shiftBy = 1; + break; + case 'bulleted-list': + characters = '- '; + shiftBy = 2; + break; + case 'numbered-list': + characters = '1. '; + shiftBy = 3; + break; + case 'quote': + characters = '> '; + shiftBy = 2; + break; + case 'link': + characters = '[](url)'; + shiftBy = 1; + break; + case 'code': + characters = '``'; + shiftBy = 1; + break; + } + + const cursorPos: number = startPos + shiftBy; + + if (startPos != endPos) { + this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`, + startPos, + endPos); + } + else { + this.insertText(characters, startPos, endPos); + } + + this.textarea.prop('selectionStart', cursorPos); + this.textarea.prop('selectionEnd', cursorPos); + } + + public saveChanges(): void { + this.save.emit({editedContent: this.content}); + } + + public discardChanges(): void { + if (this.$window.confirm(`Are you sure you want to discard your changes?`)) { + this.discard.emit({}); + } + } + + public get currentEditMode(): EditMode { + return this.editMode; + } + + /** + * Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support + * depending on the platform. + */ + private insertText(text: string, startPos: number, endPos: number): void { + if (this.browserPlatform === 'firefox') { + // FIXME: Ctrl-Z highlights previous text + this.textarea.val(this.textarea.val().substr(0, startPos) + + text + + this.textarea.val().substr(endPos, this.textarea.val().length)); + } + else { + // TODO: Test other platforms (IE...) + this.$document[0].execCommand('insertText', false, text); + } + } +} + + +/** + * Type representing the current editing mode. + */ +export type EditMode = "write" | "preview"; diff --git a/config_app/js/components/markdown/markdown-input.component.css b/config_app/js/components/markdown/markdown-input.component.css new file mode 100644 index 000000000..d8a8e3a78 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.css @@ -0,0 +1,14 @@ +.markdown-input-container .glyphicon-edit { + float: right; + color: #ddd; + transition: color 0.5s ease-in-out; +} + +.markdown-input-container .glyphicon-edit:hover { + cursor: pointer; + color: #444; +} + +.markdown-input-placeholder-editable:hover { + cursor: pointer; +} \ No newline at end of file diff --git a/config_app/js/components/markdown/markdown-input.component.html b/config_app/js/components/markdown/markdown-input.component.html new file mode 100644 index 000000000..e50b7f005 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.html @@ -0,0 +1,29 @@ +
+
+ +
+ +
+ + + Click to set {{ ::$ctrl.fieldTitle }} + + + + No {{ ::$ctrl.fieldTitle }} has been set + +
+ + +
+ +
+
diff --git a/config_app/js/components/markdown/markdown-input.component.spec.ts b/config_app/js/components/markdown/markdown-input.component.spec.ts new file mode 100644 index 000000000..56b3ba697 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.spec.ts @@ -0,0 +1,34 @@ +import { MarkdownInputComponent } from './markdown-input.component'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownInputComponent", () => { + var component: MarkdownInputComponent; + + beforeEach(() => { + component = new MarkdownInputComponent(); + }); + + describe("editContent", () => { + + }); + + describe("saveContent", () => { + var editedContent: string; + + it("emits output event with changed content", (done) => { + editedContent = "# Some markdown here"; + component.contentChanged.subscribe((event: {content: string}) => { + expect(event.content).toEqual(editedContent); + done(); + }); + + component.saveContent({editedContent: editedContent}); + }); + }); + + describe("discardContent", () => { + + }); +}); diff --git a/config_app/js/components/markdown/markdown-input.component.ts b/config_app/js/components/markdown/markdown-input.component.ts new file mode 100644 index 000000000..45b869ce4 --- /dev/null +++ b/config_app/js/components/markdown/markdown-input.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, Output, EventEmitter } from 'ng-metadata/core'; +import './markdown-input.component.css'; + + +/** + * Displays editable Markdown content. + */ +@Component({ + selector: 'markdown-input', + templateUrl: require('./markdown-input.component.html'), +}) +export class MarkdownInputComponent { + + @Input('<') public content: string; + @Input('<') public canWrite: boolean; + @Input('@') public fieldTitle: string; + + @Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter(); + + private isEditing: boolean = false; + + public editContent(): void { + this.isEditing = true; + } + + public saveContent(event: {editedContent: string}): void { + this.contentChanged.emit({content: event.editedContent}); + this.isEditing = false; + } + + public discardContent(event: any): void { + this.isEditing = false; + } +} diff --git a/config_app/js/components/markdown/markdown-toolbar.component.css b/config_app/js/components/markdown/markdown-toolbar.component.css new file mode 100644 index 000000000..f2521caa7 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.css @@ -0,0 +1,8 @@ +.markdown-toolbar-element .dropdown-menu li > * { + margin: 0 4px; +} + +.markdown-toolbar-element .dropdown-menu li:hover { + cursor: pointer; + background-color: #e6e6e6; +} diff --git a/config_app/js/components/markdown/markdown-toolbar.component.html b/config_app/js/components/markdown/markdown-toolbar.component.html new file mode 100644 index 000000000..6ad04dc71 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.html @@ -0,0 +1,61 @@ +
+ +
diff --git a/config_app/js/components/markdown/markdown-toolbar.component.spec.ts b/config_app/js/components/markdown/markdown-toolbar.component.spec.ts new file mode 100644 index 000000000..90d366d1f --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.spec.ts @@ -0,0 +1,11 @@ +import { MarkdownToolbarComponent } from './markdown-toolbar.component'; +import { MarkdownSymbol } from '../../../types/common.types'; + + +describe("MarkdownToolbarComponent", () => { + var component: MarkdownToolbarComponent; + + beforeEach(() => { + component = new MarkdownToolbarComponent(); + }); +}); diff --git a/config_app/js/components/markdown/markdown-toolbar.component.ts b/config_app/js/components/markdown/markdown-toolbar.component.ts new file mode 100644 index 000000000..64da08fc8 --- /dev/null +++ b/config_app/js/components/markdown/markdown-toolbar.component.ts @@ -0,0 +1,17 @@ +import { Component, Input, Output, EventEmitter } from 'ng-metadata/core'; +import { MarkdownSymbol } from './markdown.module'; +import './markdown-toolbar.component.css'; + + +/** + * Toolbar containing Markdown symbol shortcuts. + */ +@Component({ + selector: 'markdown-toolbar', + templateUrl: require('./markdown-toolbar.component.html'), +}) +export class MarkdownToolbarComponent { + + @Input('<') public allowUndo: boolean = true; + @Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter(); +} diff --git a/config_app/js/components/markdown/markdown-view.component.css b/config_app/js/components/markdown/markdown-view.component.css new file mode 100644 index 000000000..bfbc4957d --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.css @@ -0,0 +1,12 @@ +.markdown-view-content { + word-wrap: break-word; + overflow: hidden; +} + +.markdown-view-content p { + margin: 0; +} + +code * { + font-family: "Lucida Console", Monaco, monospace; +} diff --git a/config_app/js/components/markdown/markdown-view.component.html b/config_app/js/components/markdown/markdown-view.component.html new file mode 100644 index 000000000..c3c164d4a --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.html @@ -0,0 +1,2 @@ +
diff --git a/config_app/js/components/markdown/markdown-view.component.spec.ts b/config_app/js/components/markdown/markdown-view.component.spec.ts new file mode 100644 index 000000000..2f2379541 --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.spec.ts @@ -0,0 +1,79 @@ +import { MarkdownViewComponent } from './markdown-view.component'; +import { SimpleChanges } from 'ng-metadata/core'; +import { Converter, ConverterOptions } from 'showdown'; +import { Mock } from 'ts-mocks'; +import Spy = jasmine.Spy; + + +describe("MarkdownViewComponent", () => { + var component: MarkdownViewComponent; + var markdownConverterMock: Mock; + var $sceMock: Mock; + var $sanitizeMock: ng.sanitize.ISanitizeService; + + beforeEach(() => { + markdownConverterMock = new Mock(); + $sceMock = new Mock(); + $sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html); + component = new MarkdownViewComponent(markdownConverterMock.Object, $sceMock.Object, $sanitizeMock); + }); + + describe("ngOnChanges", () => { + var changes: SimpleChanges; + var markdown: string; + var expectedPlaceholder: string; + var markdownChars: string[]; + + beforeEach(() => { + changes = {}; + markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`; + expectedPlaceholder = `

placeholder

`; + markdownChars = ['#', '-', '>', '`']; + markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text); + $sceMock.setup(mock => mock.trustAsHtml).is((html) => html); + }); + + it("calls markdown converter to convert content to HTML when content is changed", () => { + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + + it("only converts first line of content to HTML if flag is set when content is changed", () => { + component.firstLineOnly = true; + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + const expectedHtml: string = markdown.split('\n') + .filter(line => line.indexOf(' ') != 0) + .filter(line => line.trim().length != 0) + .filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0]; + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml); + }); + + it("sets converted HTML to be a placeholder if flag is set and content is empty", () => { + component.placeholderNeeded = true; + changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled(); + expect(($sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder); + }); + + it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => { + changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect((markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + + it("calls $sanitize service to sanitize changed HTML content", () => { + changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false}; + component.ngOnChanges(changes); + + expect(($sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue); + }); + }); +}); diff --git a/config_app/js/components/markdown/markdown-view.component.ts b/config_app/js/components/markdown/markdown-view.component.ts new file mode 100644 index 000000000..9732da3bc --- /dev/null +++ b/config_app/js/components/markdown/markdown-view.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core'; +import { Converter, ConverterOptions } from 'showdown'; +import './markdown-view.component.css'; + + +/** + * Renders Markdown content to HTML. + */ +@Component({ + selector: 'markdown-view', + templateUrl: require('./markdown-view.component.html'), +}) +export class MarkdownViewComponent implements OnChanges { + + @Input('<') public content: string; + @Input('<') public firstLineOnly: boolean = false; + @Input('<') public placeholderNeeded: boolean = false; + + private convertedHTML: string = ''; + private readonly placeholder: string = `

placeholder

`; + private readonly markdownChars: string[] = ['#', '-', '>', '`']; + + constructor(@Inject('markdownConverter') private markdownConverter: Converter, + @Inject('$sce') private $sce: ng.ISCEService, + @Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) { + + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['content']) { + if (!changes['content'].currentValue && this.placeholderNeeded) { + this.convertedHTML = this.$sce.trustAsHtml(this.placeholder); + } else if (this.firstLineOnly && changes['content'].currentValue) { + const firstLine: string = changes['content'].currentValue.split('\n') + // Skip code lines + .filter(line => line.indexOf(' ') != 0) + // Skip empty lines + .filter(line => line.trim().length != 0) + // Skip control lines + .filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0]; + + this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine)); + } else { + this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue || '')); + } + } + } +} diff --git a/config_app/js/components/markdown/markdown.module.ts b/config_app/js/components/markdown/markdown.module.ts new file mode 100644 index 000000000..18dfd0e75 --- /dev/null +++ b/config_app/js/components/markdown/markdown.module.ts @@ -0,0 +1,97 @@ +import { NgModule } from 'ng-metadata/core'; +import { Converter } from 'showdown'; +import * as showdown from 'showdown'; +import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight'; +import 'highlight.js/styles/vs.css'; +const highlightedLanguages: string[] = require('./highlighted-languages.constant.json'); + +/** + * A type representing a Markdown symbol. + */ +export type MarkdownSymbol = 'heading1' + | 'heading2' + | 'heading3' + | 'bold' + | 'italics' + | 'bulleted-list' + | 'numbered-list' + | 'quote' + | 'code' + | 'link' + | 'code'; + +/** + * Type representing current browser platform. + * TODO: Add more browser platforms. + */ +export type BrowserPlatform = "firefox" + | "chrome"; + +/** + * Dynamically fetch and register a new language with Highlight.js + */ +export const addHighlightedLanguage = (language: string): Promise<{}> => { + return new Promise(async(resolve, reject) => { + try { + // TODO(alecmerdler): Use `import()` here instead of `require` after upgrading to TypeScript 2.4 + const langModule = require(`highlight.js/lib/languages/${language}`); + registerLanguage(language, langModule); + console.debug(`Language ${language} registered for syntax highlighting`); + resolve(); + } catch (error) { + console.debug(`Language ${language} not supported for syntax highlighting`); + reject(error); + } + }); +}; + + +/** + * Showdown JS extension for syntax highlighting using Highlight.js. Will attempt to register detected languages. + */ +export const showdownHighlight = (): showdown.FilterExtension => { + const htmlunencode = (text: string) => { + return (text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>')); + }; + + const left = '
]*>';
+  const right = '
'; + const flags = 'g'; + const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => { + const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length, + leftSide.indexOf('"', leftSide.indexOf('language-'))); + addHighlightedLanguage(language).catch(error => null); + + match = htmlunencode(match); + return leftSide + highlightAuto(match).value + rightSide; + }; + + return { + type: 'output', + filter: (text, converter, options) => { + return (showdown).helper.replaceRecursiveRegExp(text, replacement, left, right, flags); + } + }; +}; + + +// Import default syntax-highlighting supported languages +highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName)); + + +/** + * Markdown editor and view module. + */ +@NgModule({ + imports: [], + declarations: [], + providers: [ + {provide: 'markdownConverter', useValue: new Converter({extensions: [showdownHighlight]})}, + ], +}) +export class MarkdownModule { + +} diff --git a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js index bd3351567..26d80494b 100644 --- a/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js +++ b/config_app/js/components/request-service-key-dialog/request-service-key-dialog.js @@ -1,10 +1,11 @@ +const templateUrl = require('./request-service-key-dialog.html'); /** * An element which displays a dialog for requesting or creating a service key. */ -angular.module('quay').directive('requestServiceKeyDialog', function () { +angular.module('quay-config').directive('requestServiceKeyDialog', function () { var directiveDefinitionObject = { priority: 0, - templateUrl: '/static/directives/request-service-key-dialog.html', + templateUrl, replace: false, transclude: true, restrict: 'C', diff --git a/config_app/js/config-app.module.ts b/config_app/js/config-app.module.ts index 5c8a2c0bb..5f7fa6b7e 100644 --- a/config_app/js/config-app.module.ts +++ b/config_app/js/config-app.module.ts @@ -5,12 +5,17 @@ import { ConfigSetupAppComponent } from './components/config-setup-app/config-se import { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component'; import { LoadConfigComponent } from './components/load-config/load-config.component'; import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.component'; +import { MarkdownModule } from './components/markdown/markdown.module'; +import { MarkdownInputComponent } from './components/markdown/markdown-input.component'; +import { MarkdownViewComponent } from './components/markdown/markdown-view.component'; +import { MarkdownToolbarComponent } from './components/markdown/markdown-toolbar.component'; +import { MarkdownEditorComponent } from './components/markdown/markdown-editor.component'; -const quayDependencies: string[] = [ +const quayDependencies: any[] = [ 'restangular', 'ngCookies', 'angularFileUpload', - 'ngSanitize' + 'ngSanitize', ]; @NgModule(({ @@ -41,12 +46,19 @@ function provideConfig($provide: ng.auto.IProvideService, @NgModule({ - imports: [ DependencyConfig ], + imports: [ + DependencyConfig, + MarkdownModule, + ], declarations: [ ConfigSetupAppComponent, DownloadTarballModalComponent, LoadConfigComponent, KubeDeployModalComponent, + MarkdownInputComponent, + MarkdownViewComponent, + MarkdownToolbarComponent, + MarkdownEditorComponent, ], providers: [] }) diff --git a/config_app/templates/index.html b/config_app/templates/index.html index 25b11b2e1..25804720e 100644 --- a/config_app/templates/index.html +++ b/config_app/templates/index.html @@ -24,6 +24,9 @@ + + + {% for script_path in main_scripts %} From 9faba7f5c32ae430b74a51c0e7f24f8cd8390b10 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Tue, 14 Aug 2018 11:42:11 -0400 Subject: [PATCH 3/3] Add backend ability to generate sec keys in config app --- config_app/config_endpoints/api/superuser.py | 337 ++++++++++-------- .../api/superuser_models_pre_oci.py | 57 +-- .../js/components/markdown/markdown.module.ts | 14 + 3 files changed, 243 insertions(+), 165 deletions(-) diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index fd82ec6ee..7cca94012 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -2,6 +2,7 @@ import logging import pathvalidate import os import subprocess +from datetime import datetime from flask import request, jsonify, make_response @@ -22,173 +23,231 @@ logger = logging.getLogger(__name__) @resource('/v1/superuser/customcerts/') class SuperUserCustomCertificate(ApiResource): - """ Resource for managing a custom certificate. """ + """ Resource for managing a custom certificate. """ - @nickname('uploadCustomCertificate') - def post(self, certpath): - uploaded_file = request.files['file'] - if not uploaded_file: - raise InvalidRequest('Missing certificate file') + @nickname('uploadCustomCertificate') + def post(self, certpath): + uploaded_file = request.files['file'] + if not uploaded_file: + raise InvalidRequest('Missing certificate file') - # Save the certificate. - certpath = pathvalidate.sanitize_filename(certpath) - if not certpath.endswith('.crt'): - raise InvalidRequest('Invalid certificate file: must have suffix `.crt`') + # Save the certificate. + certpath = pathvalidate.sanitize_filename(certpath) + if not certpath.endswith('.crt'): + raise InvalidRequest('Invalid certificate file: must have suffix `.crt`') - logger.debug('Saving custom certificate %s', certpath) - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) - config_provider.save_volume_file(cert_full_path, uploaded_file) - logger.debug('Saved custom certificate %s', certpath) + logger.debug('Saving custom certificate %s', certpath) + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) + config_provider.save_volume_file(cert_full_path, uploaded_file) + logger.debug('Saved custom certificate %s', certpath) - # Validate the certificate. - try: - logger.debug('Loading custom certificate %s', certpath) - with config_provider.get_volume_file(cert_full_path) as f: - load_certificate(f.read()) - except CertInvalidException: - logger.exception('Got certificate invalid error for cert %s', certpath) - return '', 204 - except IOError: - logger.exception('Got IO error for cert %s', certpath) - return '', 204 + # Validate the certificate. + try: + logger.debug('Loading custom certificate %s', certpath) + with config_provider.get_volume_file(cert_full_path) as f: + load_certificate(f.read()) + except CertInvalidException: + logger.exception('Got certificate invalid error for cert %s', certpath) + return '', 204 + except IOError: + logger.exception('Got IO error for cert %s', certpath) + return '', 204 - # Call the update script with config dir location to install the certificate immediately. - if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], - env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0: - raise Exception('Could not install certificates') + # Call the update script with config dir location to install the certificate immediately. + if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], + env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0: + raise Exception('Could not install certificates') - return '', 204 + return '', 204 - @nickname('deleteCustomCertificate') - def delete(self, certpath): - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) - config_provider.remove_volume_file(cert_full_path) - return '', 204 + @nickname('deleteCustomCertificate') + def delete(self, certpath): + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) + config_provider.remove_volume_file(cert_full_path) + return '', 204 @resource('/v1/superuser/customcerts') class SuperUserCustomCertificates(ApiResource): - """ Resource for managing custom certificates. """ + """ Resource for managing custom certificates. """ - @nickname('getCustomCertificates') - def get(self): - has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY) - extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY) - if extra_certs_found is None: - return { - 'status': 'file' if has_extra_certs_path else 'none', - } + @nickname('getCustomCertificates') + def get(self): + has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY) + extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY) + if extra_certs_found is None: + return { + 'status': 'file' if has_extra_certs_path else 'none', + } - cert_views = [] - for extra_cert_path in extra_certs_found: - try: - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path) - with config_provider.get_volume_file(cert_full_path) as f: - certificate = load_certificate(f.read()) - cert_views.append({ - 'path': extra_cert_path, - 'names': list(certificate.names), - 'expired': certificate.expired, - }) - except CertInvalidException as cie: - cert_views.append({ - 'path': extra_cert_path, - 'error': cie.message, - }) - except IOError as ioe: - cert_views.append({ - 'path': extra_cert_path, - 'error': ioe.message, - }) + cert_views = [] + for extra_cert_path in extra_certs_found: + try: + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path) + with config_provider.get_volume_file(cert_full_path) as f: + certificate = load_certificate(f.read()) + cert_views.append({ + 'path': extra_cert_path, + 'names': list(certificate.names), + 'expired': certificate.expired, + }) + except CertInvalidException as cie: + cert_views.append({ + 'path': extra_cert_path, + 'error': cie.message, + }) + except IOError as ioe: + cert_views.append({ + 'path': extra_cert_path, + 'error': ioe.message, + }) - return { - 'status': 'directory', - 'certs': cert_views, - } + return { + 'status': 'directory', + 'certs': cert_views, + } @resource('/v1/superuser/keys') class SuperUserServiceKeyManagement(ApiResource): - """ Resource for managing service keys.""" - schemas = { - 'CreateServiceKey': { - 'id': 'CreateServiceKey', - 'type': 'object', - 'description': 'Description of creation of a service key', - 'required': ['service', 'expiration'], - 'properties': { - 'service': { - 'type': 'string', - 'description': 'The service authenticating with this key', - }, - 'name': { - 'type': 'string', - 'description': 'The friendly name of a service key', - }, - 'metadata': { - 'type': 'object', - 'description': 'The key/value pairs of this key\'s metadata', - }, - 'notes': { - 'type': 'string', - 'description': 'If specified, the extra notes for the key', - }, - 'expiration': { - 'description': 'The expiration date as a unix timestamp', - 'anyOf': [{'type': 'number'}, {'type': 'null'}], - }, - }, + """ Resource for managing service keys.""" + schemas = { + 'CreateServiceKey': { + 'id': 'CreateServiceKey', + 'type': 'object', + 'description': 'Description of creation of a service key', + 'required': ['service', 'expiration'], + 'properties': { + 'service': { + 'type': 'string', + 'description': 'The service authenticating with this key', }, + 'name': { + 'type': 'string', + 'description': 'The friendly name of a service key', + }, + 'metadata': { + 'type': 'object', + 'description': 'The key/value pairs of this key\'s metadata', + }, + 'notes': { + 'type': 'string', + 'description': 'If specified, the extra notes for the key', + }, + 'expiration': { + 'description': 'The expiration date as a unix timestamp', + 'anyOf': [{'type': 'number'}, {'type': 'null'}], + }, + }, + }, + } + + @nickname('listServiceKeys') + def get(self): + keys = pre_oci_model.list_all_service_keys() + + return jsonify({ + 'keys': [key.to_dict() for key in keys], + }) + + @nickname('createServiceKey') + @validate_json_request('CreateServiceKey') + def post(self): + body = request.get_json() + + # Ensure we have a valid expiration date if specified. + expiration_date = body.get('expiration', None) + if expiration_date is not None: + try: + expiration_date = datetime.utcfromtimestamp(float(expiration_date)) + except ValueError as ve: + raise InvalidRequest('Invalid expiration date: %s' % ve) + + if expiration_date <= datetime.now(): + raise InvalidRequest('Expiration date cannot be in the past') + + # Create the metadata for the key. + # Since we don't have logins in the config app, we'll just get any of the superusers + user = config_provider.get_config().get('SUPER_USERS', [None])[0] + if user is None: + raise InvalidRequest('No super users exist, cannot create service key without approver') + + metadata = body.get('metadata', {}) + metadata.update({ + 'created_by': 'Quay Superuser Panel', + 'creator': user, + 'ip': request.remote_addr, + }) + + # Generate a key with a private key that we *never save*. + (private_key, key_id) = pre_oci_model.generate_service_key(body['service'], expiration_date, + metadata=metadata, + name=body.get('name', '')) + # Auto-approve the service key. + pre_oci_model.approve_service_key(key_id, ServiceKeyApprovalType.SUPERUSER, + notes=body.get('notes', '')) + + # Log the creation and auto-approval of the service key. + key_log_metadata = { + 'kid': key_id, + 'preshared': True, + 'service': body['service'], + 'name': body.get('name', ''), + 'expiration_date': expiration_date, + 'auto_approved': True, } - @nickname('listServiceKeys') - def get(self): - keys = pre_oci_model.list_all_service_keys() + log_action('service_key_create', None, key_log_metadata) + log_action('service_key_approve', None, key_log_metadata) - return jsonify({ - 'keys': [key.to_dict() for key in keys], - }) + return jsonify({ + 'kid': key_id, + 'name': body.get('name', ''), + 'service': body['service'], + 'public_key': private_key.publickey().exportKey('PEM'), + 'private_key': private_key.exportKey('PEM'), + }) @resource('/v1/superuser/approvedkeys/') class SuperUserServiceKeyApproval(ApiResource): - """ Resource for approving service keys. """ + """ Resource for approving service keys. """ - schemas = { - 'ApproveServiceKey': { - 'id': 'ApproveServiceKey', - 'type': 'object', - 'description': 'Information for approving service keys', - 'properties': { - 'notes': { - 'type': 'string', - 'description': 'Optional approval notes', - }, - }, + schemas = { + 'ApproveServiceKey': { + 'id': 'ApproveServiceKey', + 'type': 'object', + 'description': 'Information for approving service keys', + 'properties': { + 'notes': { + 'type': 'string', + 'description': 'Optional approval notes', }, - } + }, + }, + } - @nickname('approveServiceKey') - @validate_json_request('ApproveServiceKey') - def post(self, kid): - notes = request.get_json().get('notes', '') - try: - key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) + @nickname('approveServiceKey') + @validate_json_request('ApproveServiceKey') + def post(self, kid): + notes = request.get_json().get('notes', '') + try: + key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) - # Log the approval of the service key. - key_log_metadata = { - 'kid': kid, - 'service': key.service, - 'name': key.name, - 'expiration_date': key.expiration_date, - } + # Log the approval of the service key. + key_log_metadata = { + 'kid': kid, + 'service': key.service, + 'name': key.name, + 'expiration_date': key.expiration_date, + } - # Note: this may not actually be the current person modifying the config, but if they're in the config tool, - # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine - super_user = app.config.get('SUPER_USERS', [None])[0] - log_action('service_key_approve', super_user, key_log_metadata) - except ServiceKeyDoesNotExist: - raise NotFound() - except ServiceKeyAlreadyApproved: - pass + # Note: this may not actually be the current person modifying the config, but if they're in the config tool, + # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine + super_user = app.config.get('SUPER_USERS', [None])[0] + log_action('service_key_approve', super_user, key_log_metadata) + except ServiceKeyDoesNotExist: + raise NotFound() + except ServiceKeyAlreadyApproved: + pass - return make_response('', 201) + return make_response('', 201) diff --git a/config_app/config_endpoints/api/superuser_models_pre_oci.py b/config_app/config_endpoints/api/superuser_models_pre_oci.py index 22cdbf821..85cb7dadc 100644 --- a/config_app/config_endpoints/api/superuser_models_pre_oci.py +++ b/config_app/config_endpoints/api/superuser_models_pre_oci.py @@ -3,45 +3,50 @@ from data import model from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval def _create_user(user): - if user is None: - return None - return User(user.username, user.email, user.verified, user.enabled, user.robot) + if user is None: + return None + return User(user.username, user.email, user.verified, user.enabled, user.robot) def _create_key(key): - approval = None - if key.approval is not None: - approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date, - key.approval.notes) + approval = None + if key.approval is not None: + approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date, + key.approval.notes) - return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, - key.rotation_duration, approval) + return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, + key.rotation_duration, approval) class ServiceKeyDoesNotExist(Exception): - pass + pass class ServiceKeyAlreadyApproved(Exception): - pass + pass class PreOCIModel(SuperuserDataInterface): - """ - PreOCIModel implements the data model for the SuperUser using a database schema - before it was changed to support the OCI specification. - """ - def list_all_service_keys(self): - keys = model.service_keys.list_all_keys() - return [_create_key(key) for key in keys] + """ + PreOCIModel implements the data model for the SuperUser using a database schema + before it was changed to support the OCI specification. + """ + def list_all_service_keys(self): + keys = model.service_keys.list_all_keys() + return [_create_key(key) for key in keys] - def approve_service_key(self, kid, approval_type, notes=''): - try: - key = model.service_keys.approve_service_key(kid, approval_type, notes=notes) - return _create_key(key) - except model.ServiceKeyDoesNotExist: - raise ServiceKeyDoesNotExist - except model.ServiceKeyAlreadyApproved: - raise ServiceKeyAlreadyApproved + def approve_service_key(self, kid, approval_type, notes=''): + try: + key = model.service_keys.approve_service_key(kid, approval_type, notes=notes) + return _create_key(key) + except model.ServiceKeyDoesNotExist: + raise ServiceKeyDoesNotExist + except model.ServiceKeyAlreadyApproved: + raise ServiceKeyAlreadyApproved + + def generate_service_key(self, service, expiration_date, kid=None, name='', metadata=None, rotation_duration=None): + (private_key, key) = model.service_keys.generate_service_key(service, expiration_date, metadata=metadata, name=name) + + return private_key, key.kid pre_oci_model = PreOCIModel() diff --git a/config_app/js/components/markdown/markdown.module.ts b/config_app/js/components/markdown/markdown.module.ts index 18dfd0e75..a457ea0e0 100644 --- a/config_app/js/components/markdown/markdown.module.ts +++ b/config_app/js/components/markdown/markdown.module.ts @@ -27,6 +27,19 @@ export type MarkdownSymbol = 'heading1' export type BrowserPlatform = "firefox" | "chrome"; +/** + * Constant representing current browser platform. Used for determining available features. + * TODO Only rudimentary implementation, should prefer specific feature detection strategies instead. + */ +export const browserPlatform: BrowserPlatform = (() => { + if (navigator.userAgent.toLowerCase().indexOf('firefox') != -1) { + return 'firefox'; + } + else { + return 'chrome'; + } +})(); + /** * Dynamically fetch and register a new language with Highlight.js */ @@ -90,6 +103,7 @@ highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName)); declarations: [], providers: [ {provide: 'markdownConverter', useValue: new Converter({extensions: [showdownHighlight]})}, + {provide: 'BrowserPlatform', useValue: browserPlatform}, ], }) export class MarkdownModule {