[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:
Jamie V 2023-01-20 18:27:19 -08:00 committed by GitHub
parent 20c7b23a4f
commit 4d84b16d8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 139 additions and 12 deletions

View File

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

View File

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

View File

@ -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",

View File

@ -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 {

View File

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

View File

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