Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Clean up RCE content before loading editor
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
Showing
6 changed files
with
388 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
packages/canvas-rce/src/rce/__tests__/transformContent.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.