@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
207 lines (197 loc) • 7.44 kB
JavaScript
/*
* Copyright (C) 2018 - 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/>.
*/
// I really want to use the native URL api, but it's requirement of an absolute URL
// or a base URL makes testing difficult, esp since window.location is "about:blank"
// in mocha tests.
import RCEGlobals from '../rce/RCEGlobals';
const CONTACT_PROTOCOLS = ['mailto:', 'tel:', 'skype:'];
function parseUrl(url, canvasOrigin = window.location.origin) {
try {
// If the URL is already absolute, use it as-is
return new URL(url);
} catch {
return new URL(`${canvasOrigin}${url.startsWith('/') ? '' : '/'}${url}`);
}
}
function parseCanvasUrl(url, canvasOrigin = window.location.origin) {
if (!url) {
return null;
}
try {
const parsed = parseUrl(url, canvasOrigin);
const canvasUrl = new URL(canvasOrigin);
if (parsed.host && canvasUrl.host !== parsed.host) {
return null;
}
if (CONTACT_PROTOCOLS.includes(parsed.protocol)) {
return null;
}
// Convert URLSearchParams to query object
const query = {};
parsed.searchParams.forEach((value, key) => {
query[key] = value;
});
return {
pathname: parsed.pathname,
search: parsed.search,
hash: parsed.hash,
host: parsed.host,
hostname: parsed.hostname,
protocol: parsed.protocol,
query
};
} catch {
// If URL parsing fails, return null
return null;
}
}
function formatUrl(parsed) {
try {
// Format query string while preserving original encoding
const queryPairs = Object.entries(parsed.query || {}).map(([key, value]) => {
// Use encodeURIComponent to preserve %20 encoding
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
});
const search = queryPairs.join('&');
const pathname = parsed.pathname || '';
const hash = parsed.hash || '';
if (parsed.protocol || parsed.host) {
const protocol = parsed.protocol ? parsed.protocol.replace(/:$/, '') : '';
const host = parsed.host || '';
return `${protocol}://${host}${pathname}${search ? '?' + search : ''}${hash}`;
}
return `${pathname}${search ? '?' + search : ''}${hash}`;
} catch {
return '';
}
}
export function absoluteToRelativeUrl(url, canvasOrigin) {
const parsed = parseCanvasUrl(url, canvasOrigin || window.location.origin);
if (!parsed) {
return url;
}
parsed.host = '';
parsed.hostname = '';
parsed.protocol = '';
return formatUrl(parsed);
}
function changeDownloadToWrapParams(parsedUrl) {
if (parsedUrl.search) {
parsedUrl.search = null;
}
if (parsedUrl.query) {
// Remove all download-related parameters
delete parsedUrl.query.download_frd;
}
if (!parsedUrl.query) {
parsedUrl.query = {};
}
parsedUrl.query.wrap = '1';
if (parsedUrl.pathname) {
parsedUrl.pathname = parsedUrl.pathname.replace(/\/(?:download|preview)\/?$/, '');
}
return parsedUrl;
}
function addContext(parsedUrl, contextType, contextId) {
// if this is a http://canvas/files... url. change it to be contextual
if (parsedUrl.pathname && /^\/files/.test(parsedUrl.pathname)) {
const context = contextType.replace(/([^s])$/, '$1s'); // canvas contexts are plural
parsedUrl.pathname = `/${context}/${contextId}${parsedUrl.pathname}`;
}
return parsedUrl;
}
// simply replaces the download_frd url param with wrap
// wrap=1 will (often) cause the resource to be loaded
// in an iframe on canvas' files page
export function downloadToWrap(url) {
const parsed = parseCanvasUrl(url);
if (!parsed) {
return url;
}
const formattedUrl = formatUrl(changeDownloadToWrapParams(parsed));
return absoluteToRelativeUrl(formattedUrl);
}
// take a url to a file (e.g. /files/17), and convert it to
// it's in-context url (e.g. /courses/2/files/17).
// Add wrap=1 to the url so it previews, not downloads
// If it is a user file or being referenced from a different origin, add the verifier
// NOTE: this can be removed once canvas-rce-api is updated
// to normalize the file URLs it returns.
export function fixupFileUrl(
// it's annoying, but depending on how we got here
// the file may have an href or a url
contextType, contextId, fileInfo, canvasOrigin) {
const key = fileInfo.href ? 'href' : 'url';
if (fileInfo[key]) {
const currentOrigin = canvasOrigin || window.location.origin;
let parsed = parseCanvasUrl(fileInfo[key], currentOrigin);
if (!parsed) {
return fileInfo;
}
parsed = changeDownloadToWrapParams(parsed);
parsed = addContext(parsed, contextType, contextId);
// if this is a user file, add the verifier
// if this is in New Quizzes and the feature flag is enabled, add the verifier
if (fileInfo.uuid && (contextType.includes('user') || !!canvasOrigin && canvasOrigin !== window.location.origin && RCEGlobals.getFeatures()?.file_verifiers_for_quiz_links)) {
parsed.search = null;
parsed.query.verifier = fileInfo.uuid;
} else {
delete parsed.query.verifier;
}
const formattedUrl = formatUrl(parsed);
// Keep absolute URLs if they match the canvas origin and input was absolute
const isAbsoluteUrl = fileInfo[key]?.startsWith('http');
const matchesCanvasOrigin = fileInfo[key]?.startsWith(currentOrigin);
fileInfo[key] = isAbsoluteUrl && matchesCanvasOrigin ? formattedUrl : absoluteToRelativeUrl(formattedUrl, currentOrigin);
}
return fileInfo;
}
// embedded resources, like an <img src=url> with /preview
// in the url will not be logged as a view in canvas.
// This is appropriate for images in some rce content.
// Remove wrap=1 to indicate we want the file downloaded
// (which is necessary to show in an <img> tag), not viewed
export function prepEmbedSrc(url, canvasOrigin = window.location.origin) {
const parsed = parseCanvasUrl(url, canvasOrigin);
if (!parsed) {
return url;
}
if (parsed.pathname && !/\/preview(?:\?|$)/.test(parsed.pathname)) {
parsed.pathname = parsed.pathname.replace(/(?:\/download)?\/?(\?|$)/, '/preview$1');
}
parsed.search = null;
delete parsed.query.wrap;
const formattedUrl = formatUrl(parsed);
// Keep absolute URLs if they match the canvas origin
const isAbsoluteUrl = url.startsWith('http');
const matchesCanvasOrigin = url.startsWith(canvasOrigin);
return isAbsoluteUrl && matchesCanvasOrigin ? formattedUrl : absoluteToRelativeUrl(formattedUrl, canvasOrigin);
}
// when the user opens a link to a resource, we want its view
// logged, so remove /preview
export function prepLinkedSrc(url) {
const parsed = parseCanvasUrl(url);
if (!parsed) {
return url;
}
if (parsed.pathname) {
parsed.pathname = parsed.pathname.replace(/\/preview(?:\?|$)/, '');
}
const formattedUrl = formatUrl(parsed);
return absoluteToRelativeUrl(formattedUrl);
}