Add components for generating sec keys

This commit is contained in:
Sam Chow 2018-08-13 13:58:59 -04:00
parent cc9bedbeb9
commit 0bc22d810a
25 changed files with 955 additions and 8 deletions

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

@ -5,12 +5,13 @@ import subprocess
from flask import request, jsonify, make_response from flask import request, jsonify, make_response
from endpoints.exception import NotFound
from data.database import ServiceKeyApprovalType from data.database import ServiceKeyApprovalType
from data.model import ServiceKeyDoesNotExist 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 app, config_provider, INIT_SCRIPTS_LOCATION from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION
@ -170,7 +171,6 @@ class SuperUserServiceKeyApproval(ApiResource):
@validate_json_request('ApproveServiceKey') @validate_json_request('ApproveServiceKey')
def post(self, kid): def post(self, kid):
notes = request.get_json().get('notes', '') notes = request.get_json().get('notes', '')
approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool
try: try:
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
@ -182,7 +182,10 @@ class SuperUserServiceKeyApproval(ApiResource):
'expiration_date': key.expiration_date, '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: except ServiceKeyDoesNotExist:
raise NotFound() raise NotFound()
except ServiceKeyAlreadyApproved: except ServiceKeyAlreadyApproved:

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

@ -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,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(/&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]})},
],
})
export class MarkdownModule {
}

View file

@ -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. * 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 = { var directiveDefinitionObject = {
priority: 0, priority: 0,
templateUrl: '/static/directives/request-service-key-dialog.html', templateUrl,
replace: false, replace: false,
transclude: true, transclude: true,
restrict: 'C', restrict: 'C',

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>