Skip to content

Commit

Permalink
Clean up RCE content before loading editor
Browse files Browse the repository at this point in the history
Adds logic to the RCE that, on initialization:
- converts absolute URLs referencing canvas  to relative urls
- removes some extraneous data attributes

Test plan:
- create some RCE content with links and images to the current canvas,
  along with some links to other places, along with some elements that
  have 'data-api-endpoint' and 'data-api-returntype' attributes
- save and reload the RCE
- ensure that the URLs remain relative
- ensure that the data attributes were removed

Refs MAT-1070
Change-Id: I6039ed9b6f93370aeb5e33dd0efc7777d22a5eb2

Change-Id: I77030d18695434f9338f1fa65cffe0f177f9e50a
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/309402
Reviewed-by: Ed Schiebel <eschiebel@instructure.com>
QA-Review: Ed Schiebel <eschiebel@instructure.com>
Product-Review: Yona Appletree <yona.appletree@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
  • Loading branch information
Juan Chavez authored and Yona Appletree committed Jan 25, 2023
1 parent 0ba85ef commit f7d97c3
Show file tree
Hide file tree
Showing 6 changed files with 388 additions and 18 deletions.
Expand Up @@ -19,8 +19,9 @@
import htmlEscape from 'escape-html'
import formatMessage from '../format-message'
import {showFlashAlert} from '../common/FlashAlert'
import {isPreviewable, loadDocPreview, showLoadingImage, removeLoadingImage} from './doc_previews'
import {isPreviewable, loadDocPreview, removeLoadingImage, showLoadingImage} from './doc_previews'
import {show} from './jqueryish_funcs'
import {parseUrlOrNull} from '../util/url-util'

const youTubeRegEx = /^https?:\/\/(www\.youtube\.com\/watch.*v(=|\/)|youtu\.be\/)([^&#]*)/
export function youTubeID(path) {
Expand Down Expand Up @@ -149,12 +150,7 @@ export function showFilePreviewInline(event, canvasOrigin, disableGooglePreviews
$link.setAttribute('aria-expanded', 'true')

if (canvasOrigin && canvadoc_session_url !== null) {
try {
canvadoc_session_url = (new URL(canvadoc_session_url, canvasOrigin)).toString();
}
catch(_ex){
canvadoc_session_url = null
}
canvadoc_session_url = parseUrlOrNull(canvadoc_session_url, canvasOrigin)?.toString()
}

const $div = document.querySelector(`[id="${$link.getAttribute('aria-controls')}"]`)
Expand Down
31 changes: 20 additions & 11 deletions packages/canvas-rce/src/rce/RCEWrapper.js
Expand Up @@ -40,11 +40,11 @@ import {sanitizePlugins} from './sanitizePlugins'
import RCEGlobals from './RCEGlobals'
import defaultTinymceConfig from '../defaultTinymceConfig'
import {
FS_ENABLED,
FS_CHANGEEVENT,
FS_ELEMENT,
FS_REQUEST,
FS_ENABLED,
FS_EXIT,
FS_CHANGEEVENT,
FS_REQUEST,
instuiPopupMountNode,
} from '../util/fullscreenHelpers'

Expand All @@ -70,6 +70,7 @@ import launchWordcountModal from './plugins/instructure_wordcount/clickCallback'
import styles from '../skins/skin-delta.css'
import skinCSSBinding from 'tinymce/skins/ui/oxide/skin.min.css'
import contentCSSBinding from 'tinymce/skins/ui/oxide/content.css'
import {transformRceContentForEditing} from './transformContent'

const RestoreAutoSaveModal = React.lazy(() => import('./RestoreAutoSaveModal'))
const RceHtmlEditor = React.lazy(() => import('./RceHtmlEditor'))
Expand Down Expand Up @@ -314,6 +315,11 @@ class RCEWrapper extends React.Component {
this._prettyHtmlEditorRef = React.createRef()
this._showOnFocusButton = null

// Processed initial content
this.initialContent = transformRceContentForEditing(this.props.defaultContent, {
origin: this.props.canvasOrigin || window?.location?.origin,
})

injectTinySkin()

// FWIW, for historic reaasons, the height does not include the
Expand Down Expand Up @@ -898,18 +904,21 @@ class RCEWrapper extends React.Component {
if (this.mceInstance().isDirty()) {
return true
}
const content = this.isHidden() ? this.textareaValue() : this.mceInstance()?.getContent()
return content !== this.cleanInitialContent()
const currentHtml = this.isHidden() ? this.textareaValue() : this.mceInstance()?.getContent()
return currentHtml !== this._mceSerializedInitialHtml
}

cleanInitialContent() {
if (!this._cleanInitialContent) {
/**
* Holds a copy of the initial content of the editor as serialized by tinyMCE to normalize it.
*/
get _mceSerializedInitialHtml() {
if (!this._mceSerializedInitialHtmlCached) {
const el = window.document.createElement('div')
el.innerHTML = this.props.defaultContent
el.innerHTML = this.initialContent
const serializer = this.mceInstance().serializer
this._cleanInitialContent = serializer.serialize(el, {getInner: true})
this._mceSerializedInitialHtmlCached = serializer.serialize(el, {getInner: true})
}
return this._cleanInitialContent
return this._mceSerializedInitialHtmlCached
}

isHtmlView() {
Expand Down Expand Up @@ -2002,7 +2011,7 @@ class RCEWrapper extends React.Component {
id={mceProps.textareaId}
textareaName={mceProps.name}
init={this.tinymceInitOptions}
initialValue={mceProps.defaultContent}
initialValue={this.initialContent}
onInit={this.onInit}
onClick={this.handleFocusEditor}
onKeypress={this.handleFocusEditor}
Expand Down
96 changes: 96 additions & 0 deletions packages/canvas-rce/src/rce/__tests__/transformContent.test.ts
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import {
attributeNamesToRemove,
transformRceContentForEditing,
TransformRceContentForEditingOptions,
} from '../transformContent'

describe('transformRceContentForEditing', () => {
const defaultOptions: TransformRceContentForEditingOptions = {
origin: 'http://canvas.com',
}

it('should not modify falsey inputs', () => {
expect(transformRceContentForEditing(null, defaultOptions)).toEqual(null)
expect(transformRceContentForEditing(undefined, defaultOptions)).toEqual(undefined)
expect(transformRceContentForEditing('', defaultOptions)).toEqual('')
})

it('should relativize urls', () => {
expect(
transformRceContentForEditing(
'<img src="https://canvas.com/image.jpg">' +
'<img random="https://canvas.com/image.jpg">' +
'<img src="https://othercanvas.com/image.jpg">' +
'<div>' +
'<img src="https://canvas.com/image.jpg">' +
'<img src="https://othercanvas.com/image.jpg">' +
'</div>',
defaultOptions
)
).toEqual(
'<img src="/image.jpg">' +
'<img random="https://canvas.com/image.jpg">' +
'<img src="https://othercanvas.com/image.jpg">' +
'<div>' +
'<img src="/image.jpg">' +
'<img src="https://othercanvas.com/image.jpg">' +
'</div>'
)
})

it('should remove unnecessary attributes', () => {
const elements = [
{value: 'iframe', shouldTransform: true},
{value: 'img', shouldTransform: true, selfClosing: true},
{value: 'embed', shouldTransform: true, selfClosing: true},
]

const attributes = [
...attributeNamesToRemove.map(value => ({
value,
shouldRemove: true,
})),
{value: 'random', shouldRemove: false},
]

elements.forEach(element => {
attributes.forEach(attribute => {
const elementWithAttribute = element.selfClosing
? `<${element.value} ${attribute.value}="whatever">`
: `<${element.value} ${attribute.value}="whatever"></${element.value}>`
const withAttributeHtml = `${elementWithAttribute}<div>${elementWithAttribute}</div>`

const elementWithoutAttribute = element.selfClosing
? `<${element.value}>`
: `<${element.value}></${element.value}>`
const withoutAttributeHtml = `${elementWithoutAttribute}<div>${elementWithoutAttribute}</div>`

const transformedHtml = transformRceContentForEditing(withAttributeHtml, defaultOptions)

if (attribute.shouldRemove) {
expect(transformedHtml).toEqual(withoutAttributeHtml)
} else {
expect(transformedHtml).toEqual(withAttributeHtml)
}
})
})
})
})
81 changes: 81 additions & 0 deletions packages/canvas-rce/src/rce/transformContent.ts
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import {relativeHttpUrlForHostname} from '../util/url-util'

export const attributeNamesToUrlRelativize = ['href', 'cite', 'src', 'data']
export const attributeNamesToRemove = ['data-api-endpoint', 'data-api-returntype']

/**
* Transforms a block of HTML for use within the Rich Content Editor, normalizing content to remove extraneous
* things added by the server.
*
* @param inputHtml
* @param options
*/
export function transformRceContentForEditing(
inputHtml: string | null | undefined,
options: TransformRceContentForEditingOptions
) {
if (!inputHtml) {
// It's important to return null/undefined here if that was passed in, otherwise tests fail because
// the change-detection logic doesn't work correctly.
return inputHtml
}

let container: HTMLElement | null

try {
container = new DOMParser().parseFromString(inputHtml, 'text/html').querySelector('body')
} catch (e) {
return inputHtml
}

if (!container) {
return inputHtml
}

// Relativize URLs in attribute
for (const attributeName of attributeNamesToUrlRelativize) {
container.querySelectorAll(`[${attributeName}]`).forEach(element => {
const attributeValue = element.getAttribute(attributeName)

if (attributeValue) {
element.setAttribute(
attributeName,
relativeHttpUrlForHostname(attributeValue, options.origin)
)
}
})
}

// Remove extraneous attributes
container
.querySelectorAll(attributeNamesToRemove.map(it => `[${it}]`).join(','))
.forEach(element => {
for (const attributeName of attributeNamesToRemove) {
element.removeAttribute(attributeName)
}
})

return container.innerHTML
}

export interface TransformRceContentForEditingOptions {
origin: string
}
110 changes: 110 additions & 0 deletions packages/canvas-rce/src/util/__tests__/url.test.ts
@@ -0,0 +1,110 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import {parseUrlOrNull, relativeHttpUrlForHostname} from '../url-util'

describe('parseUrlOrNull', () => {
it('should parse a valid url', () => {
expect(parseUrlOrNull('https://foobar.local/123')?.toString()).toEqual(
'https://foobar.local/123'
)
})

it('should parse a valid url with an origin', () => {
expect(parseUrlOrNull('/123', 'https://foobar.local')?.toString()).toEqual(
'https://foobar.local/123'
)
})

it('should handle falsey values', () => {
expect(parseUrlOrNull(null)).toEqual(null)
})

it('should handle invalid URLs', () => {
expect(parseUrlOrNull('!@#!@#')).toEqual(null)
})
})

describe('relativeHttpUrlForHostname', () => {
it('should only relativize urls when appropriate', () => {
const canvasOrigins = [
{value: 'HTTP://CANVAS.COM', shouldTransform: true},
{value: 'HTTPS://CANVAS.COM', shouldTransform: true},
{value: 'http://canvas.com', shouldTransform: true},
{value: 'https://canvas.com', shouldTransform: true},
{value: 'http://canvas.com:80', shouldTransform: true},
{value: 'https://canvas.com:443', shouldTransform: true},
{value: 'http://canvas.com:443', shouldTransform: true},
{value: 'https://canvas.com:80', shouldTransform: true},
{value: 'http://canvas.com:1234', shouldTransform: true},
]

const urlOrigins = [
{value: 'HTTP://CANVAS.COM', shouldTransform: true},
{value: 'HTTPS://CANVAS.COM', shouldTransform: true},

{value: 'http://canvas.com', shouldTransform: true},
{value: 'https://canvas.com', shouldTransform: true},
{value: 'ftp://canvas.com', shouldTransform: false},

{value: 'http://canvas.com:80', shouldTransform: true},
{value: 'https://canvas.com:443', shouldTransform: true},
{value: 'http://canvas.com:443', shouldTransform: true},
{value: 'https://canvas.com:80', shouldTransform: true},
{value: 'http://canvas.com:1234', shouldTransform: true},
{value: 'https://canvas.com:1234', shouldTransform: true},

{value: 'http://other.canvas.com', shouldTransform: false},
{value: 'https://other.canvas.com', shouldTransform: false},
{value: 'https://google.com', shouldTransform: false},
{value: 'http://nowhere.com', shouldTransform: false},
]

const paths = [
{value: '/other-page', shouldTransform: true},
{value: '/avocado.jpg', shouldTransform: true},
{value: '!@#$%^', shouldTransform: false},
]

const elements = [
{value: 'iframe', shouldTransform: true},
{value: 'img', shouldTransform: true, selfClosing: true},
{value: 'embed', shouldTransform: true, selfClosing: true},
]

canvasOrigins.forEach(canvasOrigin => {
urlOrigins.forEach(urlOrigin => {
paths.forEach(path => {
elements.forEach(element => {
const shouldTransform = [canvasOrigin, urlOrigin, path, element].every(
it => it.shouldTransform
)

const absoluteUrl = `${urlOrigin.value}${path.value}`
const relativeUrl = path.value

const transformedUrl = relativeHttpUrlForHostname(absoluteUrl, canvasOrigin.value)
const expectedUrl = shouldTransform ? relativeUrl : absoluteUrl

expect(transformedUrl).toEqual(expectedUrl)
})
})
})
})
})
})

0 comments on commit f7d97c3

Please sign in to comment.