mirror of
https://github.com/nasa/openmct.git
synced 2025-01-29 15:43:52 +00:00
[Notebook] Convert full links in entries, into clickable links (#6090)
* Automatically promote urls to hyperlinks if matches whitelist * Disable v-html lint warning for notebook entries * Check whether domain endswith given partial Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov> Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
parent
20c7b23a4f
commit
4d84b16d8b
@ -263,4 +263,77 @@ test.describe('Notebook entry tests', () => {
|
||||
});
|
||||
test.fixme('new entries persist through navigation events without save', async ({ page }) => {});
|
||||
test.fixme('previous and new entries can be deleted', async ({ page }) => {});
|
||||
test.fixme('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'http://www.google.com';
|
||||
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Entry Link Test"
|
||||
});
|
||||
|
||||
await expandTreePaneItemByName(page, 'My Items');
|
||||
|
||||
await page.goto(notebook.url);
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
// Start waiting for popup before clicking. Note no await.
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
|
||||
await validLink.click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for the popup to load.
|
||||
await popup.waitForLoadState();
|
||||
expect.soft(popup.url()).toContain('www.google.com');
|
||||
|
||||
expect(await validLink.count()).toBe(1);
|
||||
});
|
||||
test.fixme('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'www.google.com';
|
||||
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Entry Link Test"
|
||||
});
|
||||
|
||||
await expandTreePaneItemByName(page, 'My Items');
|
||||
|
||||
await page.goto(notebook.url);
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
expect(await invalidLink.count()).toBe(0);
|
||||
});
|
||||
test.fixme('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'http://www.google.com?bad=';
|
||||
const TEST_LINK_BAD = `http://www.google.com?bad=<script>alert('gimme your cookies')</script>`;
|
||||
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Entry Link Test"
|
||||
});
|
||||
|
||||
await expandTreePaneItemByName(page, 'My Items');
|
||||
|
||||
await page.goto(notebook.url);
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link, BUT not a bad link: ${TEST_LINK_BAD} is it?`);
|
||||
|
||||
const sanitizedLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
const unsanitizedLink = page.locator(`a[href="${TEST_LINK_BAD}"]`);
|
||||
|
||||
expect.soft(await sanitizedLink.count()).toBe(1);
|
||||
expect(await unsanitizedLink.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
@ -76,6 +76,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').press('Enter');
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2);
|
||||
|
||||
@ -148,14 +149,17 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').press('Enter');
|
||||
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter');
|
||||
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').press('Enter');
|
||||
|
||||
// Add three tags
|
||||
await page.hover(`button:has-text("Add Tag") >> nth=2`);
|
||||
|
@ -55,6 +55,7 @@
|
||||
"plotly.js-gl2d-dist": "2.17.1",
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sanitize-html": "2.8.1",
|
||||
"sass": "1.57.1",
|
||||
"sass-loader": "13.2.0",
|
||||
"sinon": "15.0.1",
|
||||
|
@ -25,13 +25,14 @@ import Notebook from './components/Notebook.vue';
|
||||
import Agent from '@/utils/agent/Agent';
|
||||
|
||||
export default class NotebookViewProvider {
|
||||
constructor(openmct, name, key, type, cssClass, snapshotContainer) {
|
||||
constructor(openmct, name, key, type, cssClass, snapshotContainer, entryUrlWhitelist) {
|
||||
this.openmct = openmct;
|
||||
this.key = key;
|
||||
this.name = `${name} View`;
|
||||
this.type = type;
|
||||
this.cssClass = cssClass;
|
||||
this.snapshotContainer = snapshotContainer;
|
||||
this.entryUrlWhitelist = entryUrlWhitelist;
|
||||
}
|
||||
|
||||
canView(domainObject) {
|
||||
@ -43,6 +44,7 @@ export default class NotebookViewProvider {
|
||||
let openmct = this.openmct;
|
||||
let snapshotContainer = this.snapshotContainer;
|
||||
let agent = new Agent(window);
|
||||
let entryUrlWhitelist = this.entryUrlWhitelist;
|
||||
|
||||
return {
|
||||
show(container) {
|
||||
@ -54,7 +56,8 @@ export default class NotebookViewProvider {
|
||||
provide: {
|
||||
openmct,
|
||||
snapshotContainer,
|
||||
agent
|
||||
agent,
|
||||
entryUrlWhitelist
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
@ -75,12 +76,14 @@
|
||||
class="c-ne__text c-ne__input"
|
||||
aria-label="Notebook Entry Input"
|
||||
tabindex="0"
|
||||
contenteditable="true"
|
||||
:contenteditable="canEdit"
|
||||
@mouseover="checkEditability($event)"
|
||||
@mouseleave="canEdit = true"
|
||||
@focus="editingEntry()"
|
||||
@blur="updateEntryValue($event)"
|
||||
@keydown.enter.exact.prevent
|
||||
@keyup.enter.exact.prevent="forceBlur($event)"
|
||||
v-text="entry.text"
|
||||
v-html="formattedText"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@ -91,7 +94,7 @@
|
||||
class="c-ne__text"
|
||||
contenteditable="false"
|
||||
tabindex="0"
|
||||
v-text="entry.text"
|
||||
v-html="formattedText"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@ -156,10 +159,16 @@ import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
|
||||
import { createNewEmbed } from '../utils/notebook-entries';
|
||||
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
|
||||
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Moment from 'moment';
|
||||
|
||||
const SANITIZATION_SCHEMA = {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
};
|
||||
const URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
const UNKNOWN_USER = 'Unknown';
|
||||
|
||||
export default {
|
||||
@ -167,7 +176,7 @@ export default {
|
||||
NotebookEmbed,
|
||||
TextHighlight
|
||||
},
|
||||
inject: ['openmct', 'snapshotContainer'],
|
||||
inject: ['openmct', 'snapshotContainer', 'entryUrlWhitelist'],
|
||||
props: {
|
||||
domainObject: {
|
||||
type: Object,
|
||||
@ -224,6 +233,8 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editMode: false,
|
||||
canEdit: true,
|
||||
enableEmbedsWrapperScroll: false
|
||||
};
|
||||
},
|
||||
@ -234,6 +245,31 @@ export default {
|
||||
createdOnTime() {
|
||||
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
||||
},
|
||||
formattedText() {
|
||||
// remove ANY tags
|
||||
let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
|
||||
|
||||
if (this.editMode || !this.urlWhitelist) {
|
||||
return text;
|
||||
}
|
||||
|
||||
text = text.replace(URL_REGEX, (match) => {
|
||||
const url = new URL(match);
|
||||
const domain = url.hostname;
|
||||
let result = match;
|
||||
let isMatch = this.urlWhitelist.find((partialDomain) => {
|
||||
return domain.endsWith(partialDomain);
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return text;
|
||||
},
|
||||
isSelectedEntry() {
|
||||
return this.selectedEntryId === this.entry.id;
|
||||
},
|
||||
@ -271,6 +307,9 @@ export default {
|
||||
|
||||
this.manageEmbedLayout();
|
||||
this.dropOnEntry = this.dropOnEntry.bind(this);
|
||||
if (this.entryUrlWhitelist?.length > 0) {
|
||||
this.urlWhitelist = this.entryUrlWhitelist;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.embedsWrapperResizeObserver) {
|
||||
@ -307,6 +346,11 @@ export default {
|
||||
event.dataTransfer.effectAllowed = 'none';
|
||||
}
|
||||
},
|
||||
checkEditability($event) {
|
||||
if ($event.target.nodeName === 'A') {
|
||||
this.canEdit = false;
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$emit('deleteEntry', this.entry.id);
|
||||
},
|
||||
@ -405,9 +449,11 @@ export default {
|
||||
this.$emit('updateEntry', this.entry);
|
||||
},
|
||||
editingEntry() {
|
||||
this.editMode = true;
|
||||
this.$emit('editingEntry');
|
||||
},
|
||||
updateEntryValue($event) {
|
||||
this.editMode = false;
|
||||
const value = $event.target.innerText;
|
||||
if (value !== this.entry.text && value.match(/\S/)) {
|
||||
this.entry.text = value;
|
||||
|
@ -103,7 +103,7 @@ function installBaseNotebookFunctionality(openmct) {
|
||||
monkeyPatchObjectAPIForNotebooks(openmct);
|
||||
}
|
||||
|
||||
function NotebookPlugin(name = 'Notebook') {
|
||||
function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) {
|
||||
return function install(openmct) {
|
||||
if (openmct[NOTEBOOK_INSTALLED_KEY]) {
|
||||
return;
|
||||
@ -118,8 +118,8 @@ function NotebookPlugin(name = 'Notebook') {
|
||||
const notebookType = new NotebookType(name, description, icon);
|
||||
openmct.types.addType(NOTEBOOK_TYPE, notebookType);
|
||||
|
||||
const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer);
|
||||
openmct.objectViews.addProvider(notebookView);
|
||||
const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist);
|
||||
openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
|
||||
|
||||
installBaseNotebookFunctionality(openmct);
|
||||
|
||||
@ -127,7 +127,7 @@ function NotebookPlugin(name = 'Notebook') {
|
||||
};
|
||||
}
|
||||
|
||||
function RestrictedNotebookPlugin(name = 'Notebook Shift Log') {
|
||||
function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist = []) {
|
||||
return function install(openmct) {
|
||||
if (openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) {
|
||||
return;
|
||||
@ -140,8 +140,8 @@ function RestrictedNotebookPlugin(name = 'Notebook Shift Log') {
|
||||
const notebookType = new NotebookType(name, description, icon);
|
||||
openmct.types.addType(RESTRICTED_NOTEBOOK_TYPE, notebookType);
|
||||
|
||||
const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer);
|
||||
openmct.objectViews.addProvider(notebookView);
|
||||
const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist);
|
||||
openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
|
||||
|
||||
installBaseNotebookFunctionality(openmct);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user