Merge pull request #3207 from quay/project/gen-sec-key

Add the service key creation to config tool
This commit is contained in:
Sam Chow 2018-08-16 16:48:15 -04:00 committed by GitHub
commit ec14007268
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1480 additions and 137 deletions

View file

@ -155,7 +155,7 @@ def test_mixing_keys_e2e(initialized_db):
# Approve the key and try again. # Approve the key and try again.
admin_user = model.user.get_user('devtable') 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) valid_token = _token(token_data, key_id='newkey', private_key=private_key)

View file

@ -3,6 +3,7 @@ import logging
from flask import Blueprint, request, abort from flask import Blueprint, request, abort
from flask_restful import Resource, Api from flask_restful import Resource, Api
from flask_restful.utils.cors import crossdomain from flask_restful.utils.cors import crossdomain
from data import model
from email.utils import formatdate from email.utils import formatdate
from calendar import timegm from calendar import timegm
from functools import partial, wraps from functools import partial, wraps
@ -29,6 +30,14 @@ api = ApiExceptionHandlingApi()
api.init_app(api_bp) 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): def format_date(date):
""" Output an RFC822 date format. """ """ Output an RFC822 date format. """

View file

@ -2,16 +2,20 @@ import logging
import pathvalidate import pathvalidate
import os import os
import subprocess import subprocess
from datetime import datetime
from flask import request, jsonify 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 util.config.validator import EXTRA_CA_DIRECTORY
from config_app.config_endpoints.exception import InvalidRequest 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_endpoints.api.superuser_models_pre_oci import pre_oci_model
from config_app.config_util.ssl import load_certificate, CertInvalidException 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__) logger = logging.getLogger(__name__)
@ -19,131 +23,231 @@ logger = logging.getLogger(__name__)
@resource('/v1/superuser/customcerts/<certpath>') @resource('/v1/superuser/customcerts/<certpath>')
class SuperUserCustomCertificate(ApiResource): class SuperUserCustomCertificate(ApiResource):
""" Resource for managing a custom certificate. """ """ Resource for managing a custom certificate. """
@nickname('uploadCustomCertificate') @nickname('uploadCustomCertificate')
def post(self, certpath): def post(self, certpath):
uploaded_file = request.files['file'] uploaded_file = request.files['file']
if not uploaded_file: if not uploaded_file:
raise InvalidRequest('Missing certificate file') raise InvalidRequest('Missing certificate file')
# Save the certificate. # Save the certificate.
certpath = pathvalidate.sanitize_filename(certpath) certpath = pathvalidate.sanitize_filename(certpath)
if not certpath.endswith('.crt'): if not certpath.endswith('.crt'):
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`') raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
logger.debug('Saving custom certificate %s', certpath) logger.debug('Saving custom certificate %s', certpath)
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
config_provider.save_volume_file(cert_full_path, uploaded_file) config_provider.save_volume_file(cert_full_path, uploaded_file)
logger.debug('Saved custom certificate %s', certpath) logger.debug('Saved custom certificate %s', certpath)
# Validate the certificate. # Validate the certificate.
try: try:
logger.debug('Loading custom certificate %s', certpath) logger.debug('Loading custom certificate %s', certpath)
with config_provider.get_volume_file(cert_full_path) as f: with config_provider.get_volume_file(cert_full_path) as f:
load_certificate(f.read()) load_certificate(f.read())
except CertInvalidException: except CertInvalidException:
logger.exception('Got certificate invalid error for cert %s', certpath) logger.exception('Got certificate invalid error for cert %s', certpath)
return '', 204 return '', 204
except IOError: except IOError:
logger.exception('Got IO error for cert %s', certpath) logger.exception('Got IO error for cert %s', certpath)
return '', 204 return '', 204
# Call the update script with config dir location to install the certificate immediately. # 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')], if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')],
env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0: env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0:
raise Exception('Could not install certificates') raise Exception('Could not install certificates')
return '', 204 return '', 204
@nickname('deleteCustomCertificate') @nickname('deleteCustomCertificate')
def delete(self, certpath): def delete(self, certpath):
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
config_provider.remove_volume_file(cert_full_path) config_provider.remove_volume_file(cert_full_path)
return '', 204 return '', 204
@resource('/v1/superuser/customcerts') @resource('/v1/superuser/customcerts')
class SuperUserCustomCertificates(ApiResource): class SuperUserCustomCertificates(ApiResource):
""" Resource for managing custom certificates. """ """ Resource for managing custom certificates. """
@nickname('getCustomCertificates') @nickname('getCustomCertificates')
def get(self): def get(self):
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY) has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY) extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
if extra_certs_found is None: if extra_certs_found is None:
return { return {
'status': 'file' if has_extra_certs_path else 'none', 'status': 'file' if has_extra_certs_path else 'none',
} }
cert_views = [] cert_views = []
for extra_cert_path in extra_certs_found: for extra_cert_path in extra_certs_found:
try: try:
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path) 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: with config_provider.get_volume_file(cert_full_path) as f:
certificate = load_certificate(f.read()) certificate = load_certificate(f.read())
cert_views.append({ cert_views.append({
'path': extra_cert_path, 'path': extra_cert_path,
'names': list(certificate.names), 'names': list(certificate.names),
'expired': certificate.expired, 'expired': certificate.expired,
}) })
except CertInvalidException as cie: except CertInvalidException as cie:
cert_views.append({ cert_views.append({
'path': extra_cert_path, 'path': extra_cert_path,
'error': cie.message, 'error': cie.message,
}) })
except IOError as ioe: except IOError as ioe:
cert_views.append({ cert_views.append({
'path': extra_cert_path, 'path': extra_cert_path,
'error': ioe.message, 'error': ioe.message,
}) })
return { return {
'status': 'directory', 'status': 'directory',
'certs': cert_views, 'certs': cert_views,
} }
@resource('/v1/superuser/keys') @resource('/v1/superuser/keys')
class SuperUserServiceKeyManagement(ApiResource): class SuperUserServiceKeyManagement(ApiResource):
""" Resource for managing service keys.""" """ Resource for managing service keys."""
schemas = { schemas = {
'CreateServiceKey': { 'CreateServiceKey': {
'id': 'CreateServiceKey', 'id': 'CreateServiceKey',
'type': 'object', 'type': 'object',
'description': 'Description of creation of a service key', 'description': 'Description of creation of a service key',
'required': ['service', 'expiration'], 'required': ['service', 'expiration'],
'properties': { 'properties': {
'service': { 'service': {
'type': 'string', 'type': 'string',
'description': 'The service authenticating with this key', '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'}],
},
},
}, },
'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') log_action('service_key_create', None, key_log_metadata)
def get(self): log_action('service_key_approve', None, key_log_metadata)
keys = pre_oci_model.list_all_service_keys()
return jsonify({ return jsonify({
'keys': [key.to_dict() for key in keys], '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/<kid>')
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', '')
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,
}
# 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)

View file

@ -3,29 +3,50 @@ from data import model
from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval
def _create_user(user): def _create_user(user):
if user is None: if user is None:
return None return None
return User(user.username, user.email, user.verified, user.enabled, user.robot) return User(user.username, user.email, user.verified, user.enabled, user.robot)
def _create_key(key): def _create_key(key):
approval = None approval = None
if key.approval is not None: if key.approval is not None:
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date, approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
key.approval.notes) key.approval.notes)
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
key.rotation_duration, approval) key.rotation_duration, approval)
class ServiceKeyDoesNotExist(Exception):
pass
class ServiceKeyAlreadyApproved(Exception):
pass
class PreOCIModel(SuperuserDataInterface): class PreOCIModel(SuperuserDataInterface):
""" """
PreOCIModel implements the data model for the SuperUser using a database schema PreOCIModel implements the data model for the SuperUser using a database schema
before it was changed to support the OCI specification. before it was changed to support the OCI specification.
""" """
def list_all_service_keys(self): def list_all_service_keys(self):
keys = model.service_keys.list_all_keys() keys = model.service_keys.list_all_keys()
return [_create_key(key) for key in 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 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() pre_oci_model = PreOCIModel()

View file

@ -0,0 +1,3 @@
<span class="datetime-picker-element">
<input class="form-control" type="text" ng-model="selected_datetime"/>
</span>

View file

@ -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;
});

View file

@ -46,6 +46,7 @@ export class KubeDeployModalComponent {
this.errorMessage = `Could cycle the deployments with the new configuration. Error: ${err.toString()}`; this.errorMessage = `Could cycle the deployments with the new configuration. Error: ${err.toString()}`;
}) })
}).catch(err => { }).catch(err => {
console.log(err)
this.state = 'error'; this.state = 'error';
this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`; this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`;
}) })

View file

@ -0,0 +1,8 @@
[
"javascript",
"python",
"bash",
"nginx",
"xml",
"shell"
]

View file

@ -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;
}

View file

@ -0,0 +1,43 @@
<div class="markdown-editor-element">
<!-- Write/preview tabs -->
<ul class="nav nav-tabs" style="width: 100%;">
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
ng-click="$ctrl.changeEditMode('write')">
<a href="#">Write</a>
</li>
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
ng-click="$ctrl.changeEditMode('preview')">
<a href="#">Preview</a>
</li>
<!-- Editing toolbar -->
<li style="float: right;">
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
</li>
</ul>
<div class="tab-content" style="padding: 10px 0 0 0;">
<div ng-show="$ctrl.editMode == 'write'">
<textarea id="markdown-textarea"
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
ng-model="$ctrl.content"></textarea>
</div>
<div class="markdown-editor-preview"
ng-if="$ctrl.editMode == 'preview'">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
</div>
<div class="markdown-editor-actions">
<div class="markdown-editor-buttons">
<button type="button" class="btn btn-default"
ng-click="$ctrl.discardChanges()">
Close
</button>
<button type="button" class="btn btn-primary"
ng-click="$ctrl.saveChanges()">
Save changes
</button>
</div>
</div>
</div>

View file

@ -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<ng.IAugmentedJQuery | any>;
var documentMock: Mock<HTMLElement & Document>;
var $windowMock: Mock<ng.IWindowService>;
beforeEach(() => {
textarea = new Mock<ng.IAugmentedJQuery | any>();
documentMock = new Mock<HTMLElement & Document>();
$windowMock = new Mock<ng.IWindowService>();
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(<Spy>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((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
(<Spy>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((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
(<Spy>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((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
(<Spy>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();
});
});
});

View file

@ -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<any> = 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(<string>this.textarea.val().substr(0, startPos) +
text +
<string>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";

View file

@ -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;
}

View file

@ -0,0 +1,29 @@
<div class="markdown-input-container">
<div>
<span class="glyphicon glyphicon-edit"
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
ng-click="$ctrl.editContent()"
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
<div ng-if="$ctrl.content && !$ctrl.isEditing">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
<!-- Not set and can write -->
<span class="markdown-input-placeholder-editable"
ng-if="!$ctrl.content && $ctrl.canWrite"
ng-click="$ctrl.editContent()">
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
</span>
<!-- Not set and cannot write -->
<span class="markdown-input-placeholder"
ng-if="!$ctrl.content && !$ctrl.canWrite">
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
</span>
</div>
<!-- Inline editor -->
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
<markdown-editor content="$ctrl.content"
(save)="$ctrl.saveContent($event)"
(discard)="$ctrl.discardContent($event)"></markdown-editor>
</div>
</div>

View file

@ -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", () => {
});
});

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -0,0 +1,61 @@
<div class="markdown-toolbar-element">
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<div class="btn-group">
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
data-toggle="dropdown"
data-title="Add header" data-container="body" bs-tooltip>
<span class="glyphicon glyphicon-text-size"></span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
</ul>
</div>
<button type="button" class="btn btn-default btn-sm"
data-title="Bold" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
<span class="glyphicon glyphicon-bold"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Italics" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
<span class="glyphicon glyphicon-italic"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Block quote" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
<i class="fa fa-quote-left" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Code snippet" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
<span class="glyphicon glyphicon-menu-right"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="URL" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
<span class="glyphicon glyphicon-link"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Bulleted list" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
<span class="glyphicon glyphicon-list"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
<i class="fa fa-list-ol" aria-hidden="true"></i>
</button>
</div>
</div>
</div>

View file

@ -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();
});
});

View file

@ -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();
}

View file

@ -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;
}

View file

@ -0,0 +1,2 @@
<div class="markdown-view-content"
ng-bind-html="$ctrl.convertedHTML"></div>

View file

@ -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<Converter>;
var $sceMock: Mock<ng.ISCEService>;
var $sanitizeMock: ng.sanitize.ISanitizeService;
beforeEach(() => {
markdownConverterMock = new Mock<Converter>();
$sceMock = new Mock<ng.ISCEService>();
$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 = `<p style="visibility:hidden">placeholder</p>`;
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((<Spy>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((<Spy>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((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
expect((<Spy>$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((<Spy>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((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
});
});

View file

@ -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 = `<p style="visibility:hidden">placeholder</p>`;
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 || ''));
}
}
}
}

View file

@ -0,0 +1,111 @@
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";
/**
* 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
*/
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>'));
};
const left = '<pre><code\\b[^>]*>';
const right = '</code></pre>';
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 (<any>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: [<any>showdownHighlight]})},
{provide: 'BrowserPlatform', useValue: browserPlatform},
],
})
export class MarkdownModule {
}

View file

@ -0,0 +1,137 @@
<div class="request-service-key-dialog-element">
<!-- Modal message dialog -->
<div class="co-dialog modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!working">&times;</button>
<h4 class="modal-title">Create key for service {{ requestKeyInfo.service }}</h4>
</div>
<div class="modal-body" ng-show="working">
<div class="cor-loader"></div>
</div>
<div class="modal-body" ng-show="!working">
<!-- Step 0 -->
<div ng-show="step == 0">
<table class="co-option-table">
<tr>
<td><input type="radio" id="automaticKey" ng-model="requestKind" value="automatic"></td>
<td>
<label for="automaticKey">Have the service provide a key</label>
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the single instance is setup now.</div>
</td>
</tr>
<tr>
<td><input type="radio" id="presharedKey" ng-model="requestKind" value="preshared"></td>
<td>
<label for="presharedKey">Generate shared key</label>
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the instances are dynamically started.</div>
</td>
</tr>
</table>
</div>
<!-- Step 1 (automatic) -->
<div ng-show="step == 1 && requestKind == 'automatic'" style="text-align: center">
<div style="margin-top: 20px;">
Please start the <code>{{ requestKeyInfo.service }}</code> service now, configured for <a href="https://github.com/coreos/jwtproxy#autogenerated-private-key" ng-safenewtab>autogenerated private key</a>. The key approval process will continue automatically once the service connects to Quay.
</div>
<div style="margin-top: 20px;">
Waiting for service to connect
</div>
<div style="margin-top: 10px; margin-bottom: 20px;">
<div class="cor-loader-inline"></div>
</div>
</div>
<!-- Step 2 (automatic) -->
<div ng-show="step == 2 && requestKind == 'automatic'" style="text-align: center">
A key for service <code>{{ requestKeyInfo.service }}</code> has been automatically generated, approved and saved in the service's keystore.
</div>
<!-- Step 1 (generate) -->
<div ng-show="step == 1 && requestKind == 'preshared'">
<form name="createForm" ng-submit="createPresharedKey()">
<table class="co-form-table">
<tr>
<td><label for="create-key-name">Key Name:</label></td>
<td>
<input class="form-control" name="create-key-name" type="text" ng-model="preshared.name" placeholder="Friendly Key Name">
<span class="co-help-text">
A friendly name for the key for later reference.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-expiration">Expiration date (optional):</label></td>
<td>
<span class="datetime-picker" datetime="preshared.expiration"></span>
<span class="co-help-text">
The date and time that the key expires. If left blank, the key will never expire.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-notes">Approval Notes (optional):</label></td>
<td>
<markdown-input content="preshared.notes"
can-write="true"
(content-changed)="updateNotes($event.content)"
field-title="notes"></markdown-input>
<span class="co-help-text">
Optional notes for additional human-readable information about why the key was created.
</span>
</td>
</tr>
</table>
</form>
</div>
<!-- Step 2 (generate) -->
<div ng-show="step == 2 && requestKind == 'preshared'">
<div class="co-alert co-alert-warning">
The following key has been generated for service <code>{{ requestKeyInfo.service }}</code>.
<br><br>
Please copy the key's ID and copy/download the key's private contents and place it in the directory with the service's configuration.
<br><br>
<strong>Once this dialog is closed this private key will not be accessible anywhere else!</strong>
</div>
<label>Key ID:</label>
<div class="copy-box" value="createdKey.kid"></div>
<label>Private Key (PEM):</label>
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
</div>
</div>
<div class="modal-footer" ng-show="!working">
<button type="button" class="btn btn-primary" ng-show="step == 1 && requestKind == 'preshared'"
ng-disabled="createForm.$invalid"
ng-click="createPresharedKey()">
Generate Key
</button>
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'preshared'"
ng-click="showGenerate()">
Continue
</button>
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'automatic'"
ng-click="startApproval()">
Start Approval
</button>
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="createdKey && isDownloadSupported()">
<i class="fa fa-download"></i> Download Private Key
</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step == 2">Close</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step != 2">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>
</div>

View file

@ -0,0 +1,125 @@
const templateUrl = require('./request-service-key-dialog.html');
/**
* An element which displays a dialog for requesting or creating a service key.
*/
angular.module('quay-config').directive('requestServiceKeyDialog', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl,
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;
});

View file

@ -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 { DownloadTarballModalComponent } from './components/download-tarball-modal/download-tarball-modal.component';
import { LoadConfigComponent } from './components/load-config/load-config.component'; import { LoadConfigComponent } from './components/load-config/load-config.component';
import { KubeDeployModalComponent } from './components/kube-deploy-modal/kube-deploy-modal.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', 'restangular',
'ngCookies', 'ngCookies',
'angularFileUpload', 'angularFileUpload',
'ngSanitize' 'ngSanitize',
]; ];
@NgModule(({ @NgModule(({
@ -41,12 +46,19 @@ function provideConfig($provide: ng.auto.IProvideService,
@NgModule({ @NgModule({
imports: [ DependencyConfig ], imports: [
DependencyConfig,
MarkdownModule,
],
declarations: [ declarations: [
ConfigSetupAppComponent, ConfigSetupAppComponent,
DownloadTarballModalComponent, DownloadTarballModalComponent,
LoadConfigComponent, LoadConfigComponent,
KubeDeployModalComponent, KubeDeployModalComponent,
MarkdownInputComponent,
MarkdownViewComponent,
MarkdownToolbarComponent,
MarkdownEditorComponent,
], ],
providers: [] providers: []
}) })

View file

@ -24,6 +24,9 @@
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script>
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script> <script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script>
<script src="//cdn.jsdelivr.net/g/momentjs"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js"></script>
{% for script_path in main_scripts %} {% for script_path in main_scripts %}
<script src="/static/{{ script_path }}"></script> <script src="/static/{{ script_path }}"></script>

View file

@ -145,7 +145,7 @@ def set_key_expiration(kid, expiration_date):
service_key.save() service_key.save()
def approve_service_key(kid, approver, approval_type, notes=''): def approve_service_key(kid, approval_type, approver=None, notes=''):
try: try:
with db_transaction(): with db_transaction():
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get() key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()

View file

@ -118,7 +118,7 @@ class PreOCIModel(SuperuserDataInterface):
def approve_service_key(self, kid, approver, approval_type, notes=''): def approve_service_key(self, kid, approver, approval_type, notes=''):
try: 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) return _create_key(key)
except model.ServiceKeyDoesNotExist: except model.ServiceKeyDoesNotExist:
raise ServiceKeyDoesNotExist raise ServiceKeyDoesNotExist

View file

@ -161,8 +161,7 @@ def __generate_service_key(kid, name, user, timestamp, approval_type, expiration
rotation_duration=rotation_duration) rotation_duration=rotation_duration)
if approval_type is not None: if approval_type is not None:
model.service_keys.approve_service_key(key.kid, user, approval_type, model.service_keys.approve_service_key(key.kid, approval_type, notes='The **test** approval')
notes='The **test** approval')
key_metadata = { key_metadata = {
'kid': kid, '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', key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay',
_TEST_JWK, {}, None) _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') notes='Test service key for local/test registry testing')
# Add an app specific token. # Add an app specific token.

View file

@ -559,7 +559,7 @@ class KeyServerTestCase(EndpointTestCase):
}, data=jwk, expected_code=403) }, data=jwk, expected_code=403)
# Approve the key. # 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 # Rotate that new key
with assert_action_logged('service_key_rotate'): with assert_action_logged('service_key_rotate'):
@ -598,7 +598,7 @@ class KeyServerTestCase(EndpointTestCase):
def test_attempt_delete_service_key_with_expired_key(self): def test_attempt_delete_service_key_with_expired_key(self):
# Generate two keys, approving the first. # Generate two keys, approving the first.
private_key, _ = model.service_keys.generate_service_key('sample_service', None, kid='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') model.service_keys.generate_service_key('sample_service', None, kid='second')
# Mint a JWT with our test payload # Mint a JWT with our test payload
@ -661,7 +661,7 @@ class KeyServerTestCase(EndpointTestCase):
expected_code=403, service='sample_service', kid='kid321') expected_code=403, service='sample_service', kid='kid321')
# Approve the second key. # 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 # Using the credentials of our approved key, delete our unapproved key
with assert_action_logged('service_key_delete'): with assert_action_logged('service_key_delete'):

View file

@ -18,8 +18,7 @@ def generate_key(service, name, expiration_date=None, notes=None):
metadata=metadata, metadata=metadata,
name=name) name=name)
# Auto-approve the service key. # Auto-approve the service key.
model.service_keys.approve_service_key(key.kid, None, ServiceKeyApprovalType.AUTOMATIC, model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.AUTOMATIC, notes=notes or '')
notes=notes or '')
# Log the creation and auto-approval of the service key. # Log the creation and auto-approval of the service key.
key_log_metadata = { key_log_metadata = {